Forums: Basic Support WIP
Adds initial code for basically functional forums: * Forums landing page shows hard-coded list of Categories along with any forums in the DB that use those categories. * Admin: Create, Edit forums and view forums you own or have admin rights to modify. * Landing page forums list shows the title/description and dynamic count of number of Topics and total number of Posts in each forum. TODO: distinct count of Users who posted in each forum. * Board Index page shows list of Threads (posts) with a Replies count and Views count on each thread. * Thread view is basically an array of Comments. Users can post, edit and delete (their own) comments. Deleting the first comment removes the entire Thread - the thread points to a first Comment to provide its body. * Reply and Quote-Reply options working.
This commit is contained in:
parent
e6c08cf0d3
commit
a663462e27
|
@ -82,9 +82,18 @@ var (
|
|||
{"report.user", "Report a problematic user"},
|
||||
{"report.photo", "Report a problematic photo"},
|
||||
{"report.message", "Report a direct message conversation"},
|
||||
{"report.comment", "Report a forum post or comment"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Default forum categories for forum landing page.
|
||||
ForumCategories = []string{
|
||||
"Rules and Announcements",
|
||||
"Nudists",
|
||||
"Exhibitionists",
|
||||
"Anything Goes",
|
||||
}
|
||||
)
|
||||
|
||||
// ContactUs choices for the subject drop-down.
|
||||
|
|
|
@ -11,4 +11,7 @@ var (
|
|||
PageSizeUserGallery = 18
|
||||
PageSizeInboxList = 20 // sidebar list
|
||||
PageSizeInboxThread = 20 // conversation view
|
||||
PageSizeForums = 100 // TODO: for main category index view
|
||||
PageSizeThreadList = 20
|
||||
PageSizeForumAdmin = 20
|
||||
)
|
||||
|
|
139
pkg/controller/forum/add_edit.go
Normal file
139
pkg/controller/forum/add_edit.go
Normal file
|
@ -0,0 +1,139 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// AddEdit page.
|
||||
func AddEdit() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/add_edit.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Are we editing a forum or creating a new one?
|
||||
var editID uint64
|
||||
if editStr := r.FormValue("id"); editStr != "" {
|
||||
if i, err := strconv.Atoi(editStr); err == nil {
|
||||
editID = uint64(i)
|
||||
} else {
|
||||
session.FlashError(w, r, "Edit parameter: id was not an integer")
|
||||
templates.Redirect(w, "/forum/admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// If editing, look up the existing forum.
|
||||
var forum *models.Forum
|
||||
if editID > 0 {
|
||||
if found, err := models.GetForum(editID); err != nil {
|
||||
session.FlashError(w, r, "Couldn't get forum: %s", err)
|
||||
templates.Redirect(w, "/forum/admin")
|
||||
return
|
||||
} else {
|
||||
// Do we have permission?
|
||||
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
forum = found
|
||||
}
|
||||
}
|
||||
|
||||
// Saving?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
title = strings.TrimSpace(r.PostFormValue("title"))
|
||||
fragment = strings.TrimSpace(strings.ToLower(r.PostFormValue("fragment")))
|
||||
description = strings.TrimSpace(r.PostFormValue("description"))
|
||||
category = strings.TrimSpace(r.PostFormValue("category"))
|
||||
isExplicit = r.PostFormValue("explicit") == "true"
|
||||
isPrivileged = r.PostFormValue("privileged") == "true"
|
||||
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
|
||||
)
|
||||
|
||||
// Sanity check admin-only settings.
|
||||
if !currentUser.IsAdmin {
|
||||
isPrivileged = false
|
||||
isPermitPhotos = false
|
||||
}
|
||||
|
||||
// Were we editing an existing forum?
|
||||
if forum != nil {
|
||||
forum.Title = title
|
||||
forum.Description = description
|
||||
forum.Category = category
|
||||
forum.Explicit = isExplicit
|
||||
forum.Privileged = isPrivileged
|
||||
forum.PermitPhotos = isPermitPhotos
|
||||
|
||||
// Save it.
|
||||
if err := forum.Save(); err == nil {
|
||||
session.Flash(w, r, "Forum has been updated!")
|
||||
templates.Redirect(w, "/forum/admin")
|
||||
return
|
||||
} else {
|
||||
session.FlashError(w, r, "Error saving the forum: %s", err)
|
||||
}
|
||||
} else {
|
||||
// Validate the fragment. Front-end enforces the pattern so this
|
||||
// is just a sanity check.
|
||||
if m := FragmentRegexp.FindStringSubmatch(fragment); m == nil {
|
||||
session.FlashError(w, r, "The fragment format is invalid.")
|
||||
templates.Redirect(w, "/forum/admin")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the fragment is unique.
|
||||
if _, err := models.ForumByFragment(fragment); err == nil {
|
||||
session.FlashError(w, r, "The forum fragment is already in use.")
|
||||
} else {
|
||||
// Create the forum.
|
||||
forum = &models.Forum{
|
||||
Owner: *currentUser,
|
||||
Category: category,
|
||||
Fragment: fragment,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Explicit: isExplicit,
|
||||
Privileged: isPrivileged,
|
||||
PermitPhotos: isPermitPhotos,
|
||||
}
|
||||
|
||||
if err := models.CreateForum(forum); err == nil {
|
||||
session.Flash(w, r, "The forum has been created!")
|
||||
templates.Redirect(w, "/forum/admin")
|
||||
return
|
||||
} else {
|
||||
session.FlashError(w, r, "Error creating the forum: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = editID
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"EditID": editID,
|
||||
"EditForum": forum,
|
||||
"Categories": config.ForumCategories,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
74
pkg/controller/forum/forum.go
Normal file
74
pkg/controller/forum/forum.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// Forum view for a specific board index.
|
||||
func Forum() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/board_index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the path parameters
|
||||
var (
|
||||
forum *models.Forum
|
||||
)
|
||||
|
||||
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
// Look up the forum itself.
|
||||
if found, err := models.ForumByFragment(m[1]); 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
|
||||
}
|
||||
|
||||
// Get all the categorized index forums.
|
||||
// XXX: we get a large page size to get ALL official forums
|
||||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeThreadList,
|
||||
Sort: "updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
threads, err := models.PaginateThreads(currentUser, forum, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate threads: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Map the statistics (replies, views) of these threads.
|
||||
threadMap := models.MapThreadStatistics(threads)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Threads": threads,
|
||||
"ThreadMap": threadMap,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
77
pkg/controller/forum/forums.go
Normal file
77
pkg/controller/forum/forums.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// Regular expressions
|
||||
var (
|
||||
FragmentPattern = `[a-z0-9._-]{1,30}`
|
||||
FragmentRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^(%s)$`, FragmentPattern),
|
||||
)
|
||||
|
||||
// Forum path parameters.
|
||||
ForumPathRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^/f/(%s)`, FragmentPattern),
|
||||
)
|
||||
ForumPostRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern),
|
||||
)
|
||||
ForumThreadRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern),
|
||||
)
|
||||
)
|
||||
|
||||
// Landing page for forums.
|
||||
func Landing() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Get all the categorized index forums.
|
||||
// XXX: we get a large page size to get ALL official forums
|
||||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeForums,
|
||||
Sort: "title asc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
forums, err := models.PaginateForums(currentUser.ID, config.ForumCategories, 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, config.ForumCategories)
|
||||
|
||||
// Map statistics for these forums.
|
||||
forumMap := models.MapForumStatistics(forums)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"Categories": categorized,
|
||||
"ForumMap": forumMap,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
48
pkg/controller/forum/manage.go
Normal file
48
pkg/controller/forum/manage.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// Manage page for forums -- admin only for now but may open up later.
|
||||
func Manage() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/admin.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Get forums the user owns or can manage.
|
||||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeForumAdmin,
|
||||
Sort: "updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"Forums": forums,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
170
pkg/controller/forum/new_post.go
Normal file
170
pkg/controller/forum/new_post.go
Normal file
|
@ -0,0 +1,170 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/markdown"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// NewPost view.
|
||||
func NewPost() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/new_post.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
var (
|
||||
fragment = r.FormValue("to") // forum to (new post)
|
||||
toThreadID = r.FormValue("thread") // add reply to a thread ID
|
||||
quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment
|
||||
editCommentID = r.FormValue("edit") // edit your comment
|
||||
intent = r.FormValue("intent") // preview or submit
|
||||
title = r.FormValue("title") // for new forum post only
|
||||
message = r.PostFormValue("message") // comment body
|
||||
isExplicit = r.PostFormValue("explicit") == "true" // for thread only
|
||||
isNoReply = r.PostFormValue("noreply") == "true" // for thread only
|
||||
isDelete = r.FormValue("delete") == "true" // delete comment (along with edit=$id)
|
||||
forum *models.Forum
|
||||
thread *models.Thread // if replying to a thread
|
||||
comment *models.Comment // if editing a comment
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Look up the forum itself.
|
||||
if found, err := models.ForumByFragment(fragment); err != nil {
|
||||
session.FlashError(w, r, "Couldn't post to forum %s: not found.", fragment)
|
||||
templates.Redirect(w, "/forum")
|
||||
return
|
||||
} else {
|
||||
forum = found
|
||||
}
|
||||
|
||||
// Are we manipulating a reply to an existing thread?
|
||||
if len(toThreadID) > 0 {
|
||||
if i, err := strconv.Atoi(toThreadID); err == nil {
|
||||
if found, err := models.GetThread(uint64(i)); err != nil {
|
||||
session.FlashError(w, r, "Couldn't find that thread ID!")
|
||||
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
|
||||
return
|
||||
} else {
|
||||
thread = found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Are we pre-filling the message with a quotation of an existing comment?
|
||||
if len(quoteCommentID) > 0 {
|
||||
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
||||
if comment, err := models.GetComment(uint64(i)); err == nil {
|
||||
message = markdown.Quotify(comment.Message) + "\n\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Are we editing or deleting our comment?
|
||||
if len(editCommentID) > 0 {
|
||||
if i, err := strconv.Atoi(editCommentID); err == nil {
|
||||
if found, err := models.GetComment(uint64(i)); err == nil {
|
||||
comment = found
|
||||
|
||||
// Verify that it is indeed OUR comment.
|
||||
if currentUser.ID != comment.UserID && !currentUser.IsAdmin {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the form w/ the content of this message.
|
||||
if r.Method == http.MethodGet {
|
||||
message = comment.Message
|
||||
}
|
||||
|
||||
// Are we DELETING this comment?
|
||||
if isDelete {
|
||||
if err := thread.DeleteReply(comment); err != nil {
|
||||
session.FlashError(w, r, "Error deleting your post: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Your post has been deleted.")
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Comment not found - show the Forbidden page anyway.
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Submitting the form.
|
||||
if r.Method == http.MethodPost {
|
||||
// Default intent is preview unless told to submit.
|
||||
if intent == "submit" {
|
||||
// Are we modifying an existing comment?
|
||||
if comment != nil {
|
||||
comment.Message = message
|
||||
if err := comment.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Comment updated!")
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
}
|
||||
|
||||
// Are we replying to an existing thread?
|
||||
if thread != nil {
|
||||
if _, err := thread.Reply(currentUser, message); err != nil {
|
||||
session.FlashError(w, r, "Couldn't add reply to thread: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Reply added to the thread!")
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new thread?
|
||||
if thread, err := models.CreateThread(
|
||||
currentUser,
|
||||
forum.ID,
|
||||
title,
|
||||
message,
|
||||
isExplicit,
|
||||
isNoReply,
|
||||
); err != nil {
|
||||
session.FlashError(w, r, "Couldn't create thread: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Thread created!")
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Intent": intent,
|
||||
"PostTitle": title,
|
||||
"EditCommentID": editCommentID,
|
||||
"Message": message,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
88
pkg/controller/forum/thread.go
Normal file
88
pkg/controller/forum/thread.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
|
||||
|
||||
// Thread view for a specific board index.
|
||||
func Thread() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/thread.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the path parameters
|
||||
var (
|
||||
forum *models.Forum
|
||||
thread *models.Thread
|
||||
)
|
||||
|
||||
if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
if threadID, err := strconv.Atoi(m[1]); err != nil {
|
||||
session.FlashError(w, r, "Invalid thread ID in the address bar.")
|
||||
templates.Redirect(w, "/forum")
|
||||
return
|
||||
} else {
|
||||
// Load the thread.
|
||||
if found, err := models.GetThread(uint64(threadID)); err != nil {
|
||||
session.FlashError(w, r, "That thread does not exist.")
|
||||
templates.Redirect(w, "/forum")
|
||||
return
|
||||
} else {
|
||||
thread = found
|
||||
forum = &thread.Forum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Ping the view count on this thread.
|
||||
if err := thread.View(); err != nil {
|
||||
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||
}
|
||||
|
||||
// Paginate the comments on this thread.
|
||||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeThreadList,
|
||||
Sort: "created_at asc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate comments: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -2,6 +2,8 @@
|
|||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/shurcooL/github_flavored_markdown"
|
||||
)
|
||||
|
@ -16,3 +18,12 @@ func Render(input string) string {
|
|||
safened := p.SanitizeBytes(html)
|
||||
return string(safened)
|
||||
}
|
||||
|
||||
// Quotify a message putting it into a Markdown "> quotes" block.
|
||||
func Quotify(input string) string {
|
||||
var lines = []string{}
|
||||
for _, line := range strings.Split(input, "\n") {
|
||||
lines = append(lines, "> "+line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
|
70
pkg/models/comment.go
Normal file
70
pkg/models/comment.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Comment table - in forum threads, on profiles or photos, etc.
|
||||
type Comment struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
TableName string `gorm:"index"`
|
||||
TableID uint64 `gorm:"index"`
|
||||
UserID uint64 `gorm:"index"`
|
||||
User User
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Preload related tables for the forum (classmethod).
|
||||
func (c *Comment) Preload() *gorm.DB {
|
||||
return DB.Preload("User.ProfilePhoto")
|
||||
}
|
||||
|
||||
// GetComment by ID.
|
||||
func GetComment(id uint64) (*Comment, error) {
|
||||
c := &Comment{}
|
||||
result := c.Preload().First(&c, id)
|
||||
return c, result.Error
|
||||
}
|
||||
|
||||
// AddComment about anything.
|
||||
func AddComment(user *User, tableName string, tableID uint64, message string) (*Comment, error) {
|
||||
c := &Comment{
|
||||
TableName: tableName,
|
||||
TableID: tableID,
|
||||
User: *user,
|
||||
Message: message,
|
||||
}
|
||||
result := DB.Create(c)
|
||||
return c, result.Error
|
||||
}
|
||||
|
||||
// PaginateComments provides a page of comments on something.
|
||||
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
|
||||
var (
|
||||
cs = []*Comment{}
|
||||
query = (&Comment{}).Preload()
|
||||
)
|
||||
|
||||
query = query.Where(
|
||||
"table_name = ? AND table_id = ?",
|
||||
tableName, tableID,
|
||||
).Order(pager.Sort)
|
||||
|
||||
query.Model(&Comment{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs)
|
||||
return cs, result.Error
|
||||
}
|
||||
|
||||
// Save a comment.
|
||||
func (c *Comment) Save() error {
|
||||
return DB.Save(c).Error
|
||||
}
|
||||
|
||||
// Delete a comment.
|
||||
func (c *Comment) Delete() error {
|
||||
return DB.Delete(c).Error
|
||||
}
|
156
pkg/models/forum.go
Normal file
156
pkg/models/forum.go
Normal file
|
@ -0,0 +1,156 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Forum table.
|
||||
type Forum struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
OwnerID uint64 `gorm:"index"`
|
||||
Owner User `gorm:"foreignKey:owner_id"`
|
||||
Category string `gorm:"index"`
|
||||
Fragment string `gorm:"uniqueIndex"`
|
||||
Title string
|
||||
Description string
|
||||
Explicit bool `gorm:"index"`
|
||||
Privileged bool
|
||||
PermitPhotos bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Preload related tables for the forum (classmethod).
|
||||
func (f *Forum) Preload() *gorm.DB {
|
||||
return DB.Preload("Owner")
|
||||
}
|
||||
|
||||
// GetForum by ID.
|
||||
func GetForum(id uint64) (*Forum, error) {
|
||||
forum := &Forum{}
|
||||
result := forum.Preload().First(&forum, id)
|
||||
return forum, result.Error
|
||||
}
|
||||
|
||||
// ForumByFragment looks up a forum by its URL fragment.
|
||||
func ForumByFragment(fragment string) (*Forum, error) {
|
||||
if fragment == "" {
|
||||
return nil, errors.New("the URL fragment is required")
|
||||
}
|
||||
|
||||
var (
|
||||
f = &Forum{}
|
||||
result = f.Preload().Where(
|
||||
"fragment = ?",
|
||||
fragment,
|
||||
).First(&f)
|
||||
)
|
||||
|
||||
return f, result.Error
|
||||
}
|
||||
|
||||
/*
|
||||
PaginateForums scans over the available forums for a user.
|
||||
|
||||
Parameters:
|
||||
|
||||
- userID: of who is looking
|
||||
- categories: optional, filter within categories
|
||||
- pager
|
||||
*/
|
||||
func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*Forum, error) {
|
||||
var (
|
||||
fs = []*Forum{}
|
||||
query = (&Forum{}).Preload()
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
if categories != nil && len(categories) > 0 {
|
||||
wheres = append(wheres, "category IN ?")
|
||||
placeholders = append(placeholders, categories)
|
||||
}
|
||||
|
||||
// Filters?
|
||||
if len(wheres) > 0 {
|
||||
query = query.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
)
|
||||
}
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
query.Model(&Forum{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||
return fs, result.Error
|
||||
}
|
||||
|
||||
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
|
||||
func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) {
|
||||
var (
|
||||
fs = []*Forum{}
|
||||
query = (&Forum{}).Preload()
|
||||
)
|
||||
|
||||
if !isAdmin {
|
||||
query = query.Where(
|
||||
"owner_id = ?",
|
||||
userID,
|
||||
)
|
||||
}
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
query.Model(&Forum{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||
return fs, result.Error
|
||||
}
|
||||
|
||||
// CreateForum.
|
||||
func CreateForum(f *Forum) error {
|
||||
result := DB.Create(f)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// Save a forum.
|
||||
func (f *Forum) Save() error {
|
||||
return DB.Save(f).Error
|
||||
}
|
||||
|
||||
// CategorizedForum supports the main index page with custom categories.
|
||||
type CategorizedForum struct {
|
||||
Category string
|
||||
Forums []*Forum
|
||||
}
|
||||
|
||||
// CategorizeForums buckets forums into categories for front-end.
|
||||
func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
|
||||
var (
|
||||
result = []*CategorizedForum{}
|
||||
idxMap = map[string]int{}
|
||||
)
|
||||
|
||||
// Initialize the result set.
|
||||
for i, category := range categories {
|
||||
result = append(result, &CategorizedForum{
|
||||
Category: category,
|
||||
Forums: []*Forum{},
|
||||
})
|
||||
idxMap[category] = i
|
||||
}
|
||||
|
||||
// Bucket the forums into their categories.
|
||||
for _, forum := range fs {
|
||||
category := forum.Category
|
||||
if category == "" {
|
||||
continue
|
||||
}
|
||||
idx := idxMap[category]
|
||||
result[idx].Forums = append(result[idx].Forums, forum)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
157
pkg/models/forum_stats.go
Normal file
157
pkg/models/forum_stats.go
Normal file
|
@ -0,0 +1,157 @@
|
|||
package models
|
||||
|
||||
import "git.kirsle.net/apps/gosocial/pkg/log"
|
||||
|
||||
// ForumStatistics queries for forum-level statistics.
|
||||
type ForumStatistics struct {
|
||||
RecentThread *Thread
|
||||
Threads uint64
|
||||
Posts uint64
|
||||
Users uint64
|
||||
}
|
||||
|
||||
type ForumStatsMap map[uint64]*ForumStatistics
|
||||
|
||||
// MapForumStatistics looks up statistics for a set of forums.
|
||||
func MapForumStatistics(forums []*Forum) ForumStatsMap {
|
||||
var (
|
||||
result = ForumStatsMap{}
|
||||
IDs = []uint64{}
|
||||
)
|
||||
|
||||
// Collect forum IDs and initialize the map.
|
||||
for _, forum := range forums {
|
||||
IDs = append(IDs, forum.ID)
|
||||
result[forum.ID] = &ForumStatistics{}
|
||||
}
|
||||
|
||||
// FIRST: count the threads in each forum.
|
||||
{
|
||||
// Hold the result of the count/group by query.
|
||||
type group struct {
|
||||
ID uint64
|
||||
Threads uint64
|
||||
}
|
||||
var groups = []group{}
|
||||
|
||||
// Count comments grouped by thread IDs.
|
||||
err := DB.Table(
|
||||
"threads",
|
||||
).Select(
|
||||
"forum_id AS id, count(id) AS threads",
|
||||
).Where(
|
||||
"forum_id IN ?",
|
||||
IDs,
|
||||
).Group("forum_id").Scan(&groups)
|
||||
|
||||
if err.Error != nil {
|
||||
log.Error("MapForumStatistics: SQL error: %s", err.Error)
|
||||
}
|
||||
|
||||
// Map the results in.
|
||||
for _, row := range groups {
|
||||
log.Error("Got row: %+v", row)
|
||||
if stats, ok := result[row.ID]; ok {
|
||||
stats.Threads = row.Threads
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THEN: count all posts in all those threads.
|
||||
{
|
||||
type group struct {
|
||||
ID uint64
|
||||
Posts uint64
|
||||
}
|
||||
var groups = []group{}
|
||||
|
||||
err := DB.Table(
|
||||
"comments",
|
||||
).Joins(
|
||||
"JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||
).Joins(
|
||||
"JOIN forums ON (threads.forum_id = forums.id)",
|
||||
).Select(
|
||||
"forums.id AS id, count(comments.id) AS posts",
|
||||
).Where(
|
||||
`table_name = 'threads' AND EXISTS (
|
||||
SELECT 1
|
||||
FROM threads
|
||||
WHERE table_id = threads.id
|
||||
AND threads.forum_id IN ?
|
||||
)`,
|
||||
IDs,
|
||||
).Group("forums.id").Scan(&groups)
|
||||
|
||||
if err.Error != nil {
|
||||
log.Error("SQL error collecting posts for forum: %s", err.Error)
|
||||
}
|
||||
|
||||
// Map the results in.
|
||||
for _, row := range groups {
|
||||
log.Error("Got row2: %+v", row)
|
||||
if stats, ok := result[row.ID]; ok {
|
||||
stats.Posts = row.Posts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THEN: count all distinct users in those threads.
|
||||
// TODO: hairy
|
||||
// {
|
||||
// type group struct {
|
||||
// ID uint64
|
||||
// Users uint64
|
||||
// }
|
||||
// var groups = []group{}
|
||||
|
||||
// err := DB.Table(
|
||||
// "comments",
|
||||
// ).Joins(
|
||||
// "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||
// ).Joins(
|
||||
// "JOIN forums ON (threads.forum_id = forums.id)",
|
||||
// ).Select(
|
||||
// "forums.id AS forum_id, count(distinct(comments.user_id)) AS users",
|
||||
// ).Where(
|
||||
// // `table_name = 'threads' AND EXISTS (
|
||||
// // SELECT 1
|
||||
// // FROM threads
|
||||
// // WHERE table_id = threads.id
|
||||
// // AND threads.forum_id IN ?
|
||||
// // )`,
|
||||
// "forums.id IN ?",
|
||||
// IDs,
|
||||
// ).Group("forums.id").Scan(&groups)
|
||||
|
||||
// if err.Error != nil {
|
||||
// log.Error("SQL error collecting users for forum: %s", err.Error)
|
||||
// }
|
||||
|
||||
// // Map the results in.
|
||||
// for _, row := range groups {
|
||||
// log.Error("Got row2: %+v", row)
|
||||
// if stats, ok := result[row.ID]; ok {
|
||||
// stats.Users = row.Users
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Get THE most recent thread on this forum.
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Has stats for this thread? (we should..)
|
||||
func (ts ForumStatsMap) Has(threadID uint64) bool {
|
||||
_, ok := ts[threadID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get thread stats.
|
||||
func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics {
|
||||
if stats, ok := ts[threadID]; ok {
|
||||
return stats
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -16,4 +16,7 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&Friend{})
|
||||
DB.AutoMigrate(&Block{})
|
||||
DB.AutoMigrate(&Feedback{})
|
||||
DB.AutoMigrate(&Forum{})
|
||||
DB.AutoMigrate(&Thread{})
|
||||
DB.AutoMigrate(&Comment{})
|
||||
}
|
||||
|
|
215
pkg/models/thread.go
Normal file
215
pkg/models/thread.go
Normal file
|
@ -0,0 +1,215 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Thread table - a post within a Forum.
|
||||
type Thread struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
ForumID uint64 `gorm:"index"`
|
||||
Forum Forum
|
||||
Explicit bool `gorm:"index"`
|
||||
NoReply bool
|
||||
Title string
|
||||
CommentID uint64 `gorm:"index"`
|
||||
Comment Comment // first comment of the thread
|
||||
Views uint64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Preload related tables for the forum (classmethod).
|
||||
func (f *Thread) Preload() *gorm.DB {
|
||||
return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto")
|
||||
}
|
||||
|
||||
// GetThread by ID.
|
||||
func GetThread(id uint64) (*Thread, error) {
|
||||
t := &Thread{}
|
||||
result := t.Preload().First(&t, id)
|
||||
return t, result.Error
|
||||
}
|
||||
|
||||
// CreateThread creates a new thread with proper Comment structure.
|
||||
func CreateThread(user *User, forumID uint64, title, message string, explicit, noReply bool) (*Thread, error) {
|
||||
thread := &Thread{
|
||||
ForumID: forumID,
|
||||
Title: title,
|
||||
Explicit: explicit,
|
||||
NoReply: noReply && user.IsAdmin,
|
||||
Comment: Comment{
|
||||
User: *user,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
log.Error("CreateThread: Going to post %+v", thread)
|
||||
|
||||
// Create the thread & comment first...
|
||||
result := DB.Create(thread)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
// Fill out the Comment with proper reverse foreign keys.
|
||||
thread.Comment.TableName = "threads"
|
||||
thread.Comment.TableID = thread.ID
|
||||
log.Error("Saving updated comment: %+v", thread)
|
||||
result = DB.Save(&thread.Comment)
|
||||
return thread, result.Error
|
||||
}
|
||||
|
||||
// Reply to a thread, adding an additional comment.
|
||||
func (t *Thread) Reply(user *User, message string) (*Comment, error) {
|
||||
// Save the thread on reply, updating its timestamp.
|
||||
if err := t.Save(); err != nil {
|
||||
log.Error("Thread.Reply: couldn't ping UpdatedAt on thread: %s", err)
|
||||
}
|
||||
|
||||
return AddComment(user, "threads", t.ID, message)
|
||||
}
|
||||
|
||||
// DeleteReply removes a comment from a thread. If it is the primary comment, deletes the whole thread.
|
||||
func (t *Thread) DeleteReply(comment *Comment) error {
|
||||
// Sanity check that this reply is one of ours.
|
||||
if !(comment.TableName == "threads" && comment.TableID == t.ID) {
|
||||
return errors.New("that comment doesn't belong to this thread")
|
||||
}
|
||||
|
||||
// Is this the primary comment that started the thread? If so, delete the whole thread.
|
||||
if comment.ID == t.CommentID {
|
||||
log.Error("DeleteReply(%d): this is the parent comment of a thread (%d '%s'), remove the whole thread", comment.ID, t.ID, t.Title)
|
||||
return t.Delete()
|
||||
}
|
||||
|
||||
// Remove just this comment.
|
||||
return comment.Delete()
|
||||
}
|
||||
|
||||
// PaginateThreads provides a forum index view of posts.
|
||||
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
|
||||
var (
|
||||
ts = []*Thread{}
|
||||
query = (&Thread{}).Preload()
|
||||
)
|
||||
|
||||
query = query.Where(
|
||||
"forum_id = ?",
|
||||
forum.ID,
|
||||
).Order(pager.Sort)
|
||||
|
||||
query.Model(&Thread{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts)
|
||||
return ts, result.Error
|
||||
}
|
||||
|
||||
// View a thread, incrementing its View count but not its UpdatedAt.
|
||||
func (t *Thread) View() error {
|
||||
return DB.Model(&Thread{}).Where(
|
||||
"id = ?",
|
||||
t.ID,
|
||||
).Updates(map[string]interface{}{
|
||||
"views": t.Views + 1,
|
||||
"updated_at": t.UpdatedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Save a thread, updating its timestamp.
|
||||
func (t *Thread) Save() error {
|
||||
return DB.Save(t).Error
|
||||
}
|
||||
|
||||
// Delete a thread and all of its comments.
|
||||
func (t *Thread) Delete() error {
|
||||
// Remove all comments.
|
||||
result := DB.Where(
|
||||
"table_name = ? AND table_id = ?",
|
||||
"threads", t.ID,
|
||||
).Delete(&Comment{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("deleting comments for thread: %s", result.Error)
|
||||
}
|
||||
|
||||
// Remove the thread itself.
|
||||
return DB.Delete(t).Error
|
||||
}
|
||||
|
||||
// ThreadStatistics queries for reply/view count for threads.
|
||||
type ThreadStatistics struct {
|
||||
Replies uint64
|
||||
Views uint64
|
||||
}
|
||||
|
||||
type ThreadStatsMap map[uint64]*ThreadStatistics
|
||||
|
||||
// MapThreadStatistics looks up statistics for a set of threads.
|
||||
func MapThreadStatistics(threads []*Thread) ThreadStatsMap {
|
||||
var (
|
||||
result = ThreadStatsMap{}
|
||||
IDs = []uint64{}
|
||||
)
|
||||
|
||||
// Collect thread IDs and initialize the map.
|
||||
for _, thread := range threads {
|
||||
IDs = append(IDs, thread.ID)
|
||||
result[thread.ID] = &ThreadStatistics{
|
||||
Views: thread.Views,
|
||||
}
|
||||
}
|
||||
|
||||
// Hold the result of the count/group by query.
|
||||
type group struct {
|
||||
ID uint64
|
||||
Replies uint64
|
||||
}
|
||||
var groups = []group{}
|
||||
|
||||
// Count comments grouped by thread IDs.
|
||||
err := DB.Table(
|
||||
"comments",
|
||||
).Select(
|
||||
"table_id AS id, count(id) AS replies",
|
||||
).Where(
|
||||
"table_name = ? AND table_id IN ?",
|
||||
"threads", IDs,
|
||||
).Group("table_id").Scan(&groups)
|
||||
|
||||
if err != nil {
|
||||
log.Error("MapThreadStatistics: SQL error: %s")
|
||||
}
|
||||
|
||||
// Map the results in.
|
||||
for _, row := range groups {
|
||||
log.Error("Got row: %+v", row)
|
||||
if stats, ok := result[row.ID]; ok {
|
||||
stats.Replies = row.Replies
|
||||
|
||||
// Remove the OG comment from the count.
|
||||
if stats.Replies > 0 {
|
||||
stats.Replies--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Has stats for this thread? (we should..)
|
||||
func (ts ThreadStatsMap) Has(threadID uint64) bool {
|
||||
_, ok := ts[threadID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get thread stats.
|
||||
func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics {
|
||||
if stats, ok := ts[threadID]; ok {
|
||||
return stats
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"git.kirsle.net/apps/gosocial/pkg/controller/admin"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/api"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/block"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/forum"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/friend"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/inbox"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/index"
|
||||
|
@ -55,12 +56,18 @@ func New() http.Handler {
|
|||
// Certification Required. Pages that only full (verified) members can access.
|
||||
mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
|
||||
mux.Handle("/members", middleware.CertRequired(account.Search()))
|
||||
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
|
||||
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
||||
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
||||
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
|
||||
|
||||
// Admin endpoints.
|
||||
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
|
||||
mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification()))
|
||||
mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback()))
|
||||
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions()))
|
||||
mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage()))
|
||||
mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit()))
|
||||
|
||||
// JSON API endpoints.
|
||||
mux.HandleFunc("/v1/version", api.Version())
|
||||
|
|
|
@ -58,6 +58,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
}
|
||||
return value[:n]
|
||||
},
|
||||
"TrimEllipses": func(value string, n int) string {
|
||||
if n > len(value) {
|
||||
return value
|
||||
}
|
||||
return value[:n] + "…"
|
||||
},
|
||||
"IterRange": func(start, n int) []int {
|
||||
var result = []int{}
|
||||
for i := start; i <= n; i++ {
|
||||
|
|
|
@ -52,10 +52,10 @@
|
|||
<span>Gallery</span>
|
||||
</a>
|
||||
|
||||
<!-- <a class="navbar-item" href="/forums">
|
||||
<a class="navbar-item" href="/forum">
|
||||
<span class="icon"><i class="fa fa-comments"></i></span>
|
||||
<span>Forums</span>
|
||||
</a> -->
|
||||
<span>Forum</span>
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}">
|
||||
<span class="icon"><i class="fa fa-user-group"></i></span>
|
||||
|
|
143
web/templates/forum/add_edit.html
Normal file
143
web/templates/forum/add_edit.html
Normal file
|
@ -0,0 +1,143 @@
|
|||
{{define "title"}}Forums{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-danger">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
{{if .EditForum}}Edit Forum{{else}}New Forum{{end}}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-two-thirds">
|
||||
<div class="card">
|
||||
<header class="card-header has-background-info">
|
||||
<p class="card-header-title has-text-light">Forum Properties</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<form action="{{.Request.URL.Path}}" method="POST">
|
||||
{{InputCSRF}}
|
||||
<input type="hidden" name="id" value="{{.EditID}}">
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="title">
|
||||
Forum Title
|
||||
</label>
|
||||
<input type="text" class="input"
|
||||
name="title" id="title"
|
||||
placeholder="Forum Title"
|
||||
required
|
||||
{{if .EditForum}}value="{{.EditForum.Title}}"{{end}}>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="fragment">
|
||||
URL Fragment
|
||||
</label>
|
||||
<input type="text" class="input"
|
||||
name="fragment" id="fragment"
|
||||
placeholder="url_fragment"
|
||||
pattern="^[a-z0-9._-]+$"
|
||||
required
|
||||
{{if .EditForum}}value="{{.EditForum.Fragment}}" disabled{{end}}>
|
||||
<p class="help">
|
||||
A unique URL path component for this forum. You can not modify this
|
||||
after the forum is created. Acceptable characters in the range a-z, 0-9 and . - _
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="description">
|
||||
Description
|
||||
</label>
|
||||
<textarea class="textarea" cols="80" rows="4"
|
||||
name="description" id="description"
|
||||
placeholder="A short description of the forum.">{{if .EditForum}}{{.EditForum.Description}}{{end}}</textarea>
|
||||
<p class="help">
|
||||
Write a short description of the forum. Markdown formatting
|
||||
is supported here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<div class="field">
|
||||
<label class="label" for="category">
|
||||
Category
|
||||
<i class="fa fa-gavel has-text-danger ml-2" title="Admin Only"></i>
|
||||
</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="category" id="category">
|
||||
{{range .Categories}}
|
||||
<option value="{{.}}"{{if and $Root.EditForum (eq $Root.EditForum.Category .)}} selected{{end}}>
|
||||
{{.}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Options</label>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="explicit"
|
||||
value="true"
|
||||
{{if and .EditForum .EditForum.Explicit}}checked{{end}}>
|
||||
Explicit <i class="fa fa-fire has-text-danger ml-1"></i>
|
||||
</label>
|
||||
<p class="help">
|
||||
Check this box if the forum is intended for explicit content. Users must
|
||||
opt-in to see explicit content.
|
||||
</p>
|
||||
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<label class="checkbox mt-3">
|
||||
<input type="checkbox"
|
||||
name="privileged"
|
||||
value="true"
|
||||
{{if and .EditForum .EditForum.Privileged}}checked{{end}}>
|
||||
Privileged <i class="fa fa-gavel has-text-danger ml-1"></i>
|
||||
</label>
|
||||
<p class="help">
|
||||
Check this box if only privileged users are allowed to create new threads
|
||||
in this forum. Privileged users include the forum owner, site admins, and
|
||||
forum moderators.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<label class="checkbox mt-3">
|
||||
<input type="checkbox"
|
||||
name="permit_photos"
|
||||
value="true"
|
||||
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
|
||||
Permit Photos <i class="fa fa-camera ml-1"></i>
|
||||
</label>
|
||||
<p class="help">
|
||||
Check this box if the forum allows photos to be uploaded (not implemented)
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-success">
|
||||
{{if .EditForum}}Save Forum{{else}}Create Forum{{end}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
120
web/templates/forum/admin.html
Normal file
120
web/templates/forum/admin.html
Normal file
|
@ -0,0 +1,120 @@
|
|||
{{define "title"}}Forums{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-danger">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
<span class="icon mr-4"><i class="fa fa-gavel"></i></span>
|
||||
<span>Forum Administration</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
|
||||
<div class="block p-2">
|
||||
<a href="/forum/admin/edit" class="button is-success">
|
||||
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||
<span>Create New Forum</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="block p-2">
|
||||
Found <strong>{{.Pager.Total}}</strong> forum{{Pluralize64 .Pager.Total}} you can manage
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
</p>
|
||||
|
||||
<div class="block p-2">
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
|
||||
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
|
||||
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||
<ul class="pagination-list">
|
||||
{{$Root := .}}
|
||||
{{range .Pager.Iter}}
|
||||
<li>
|
||||
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||
aria-label="Page {{.Page}}"
|
||||
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||
{{.Page}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="block p-2">
|
||||
{{range .Forums}}
|
||||
<div class="box">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h1 class="title">{{.Title}}</h1>
|
||||
<h2 class="subtitle">
|
||||
/f/{{.Fragment}}
|
||||
{{if .Category}}<span class="ml-4">{{.Category}}</span>{{end}}
|
||||
<span class="ml-4">
|
||||
by <strong><a href="/u/{{.Owner.Username}}">{{.Owner.Username}}</a></strong>
|
||||
{{if .Owner.IsAdmin}}<i class="fa fa-gavel has-text-danger ml-1"></i>{{end}}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="content">
|
||||
<p>
|
||||
|
||||
</p>
|
||||
|
||||
{{if eq .Description ""}}
|
||||
<p><em>No description</em></p>
|
||||
{{else}}
|
||||
{{ToMarkdown .Description}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{if .Explicit}}
|
||||
<div class="tag is-danger is-light">
|
||||
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||
Explicit
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Privileged}}
|
||||
<div class="tag is-warning is-light">
|
||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||
Privileged
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .PermitPhotos}}
|
||||
<div class="tag is-info is-light">
|
||||
<span class="icon"><i class="fa fa-camera"></i></span>
|
||||
PermitPhotos
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="tag is-light">
|
||||
Created {{.CreatedAt.Format "2006-01-02 15:04:05"}}
|
||||
</div>
|
||||
<div class="tag is-light">
|
||||
Updated {{.UpdatedAt.Format "2006-01-02 15:04:05"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/admin/edit?id={{.ID}}" class="button has-text-success">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
127
web/templates/forum/board_index.html
Normal file
127
web/templates/forum/board_index.html
Normal file
|
@ -0,0 +1,127 @@
|
|||
{{define "title"}}{{.Forum.Title}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-success">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||
<span>{{.Forum.Title}}</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
|
||||
<div class="block px-4">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/forum">Forums</a></li>
|
||||
<li class="is-active">
|
||||
<a href="{{.Request.URL.Path}}" aria-current="page">{{.Forum.Title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-primary">
|
||||
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||
<span>New Thread</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="block p-2">
|
||||
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this forum
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
</p>
|
||||
|
||||
<div class="block p-2">
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
|
||||
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
|
||||
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||
<ul class="pagination-list">
|
||||
{{$Root := .}}
|
||||
{{range .Pager.Iter}}
|
||||
<li>
|
||||
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||
aria-label="Page {{.Page}}"
|
||||
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||
{{.Page}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
<div class="block p-2">
|
||||
{{range .Threads}}
|
||||
{{$Stats := $Root.ThreadMap.Get .ID}}
|
||||
<div class="box has-background-link-light">
|
||||
<div class="columns">
|
||||
<div class="column is-2 has-text-centered">
|
||||
<div>
|
||||
<a href="/u/{{.Comment.User.Username}}">
|
||||
<figure class="image is-96x96 is-inline-block">
|
||||
{{if .Comment.User.ProfilePhoto.ID}}
|
||||
<img src="{{PhotoURL .Comment.User.ProfilePhoto.CroppedFilename}}">
|
||||
{{else}}
|
||||
<img src="/static/img/shy.png">
|
||||
{{end}}
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
<a href="/u/{{.Comment.User.Username}}">{{.Comment.User.Username}}</a>
|
||||
</div>
|
||||
<div class="column content">
|
||||
<a href="/forum/thread/{{.ID}}" class="has-text-dark">
|
||||
<h1 class="title pt-0">
|
||||
{{or .Title "Untitled"}}
|
||||
</h1>
|
||||
{{TrimEllipses .Comment.Message 256}}
|
||||
</a>
|
||||
|
||||
<hr class="mb-1">
|
||||
<div>
|
||||
<em>Updated {{SincePrettyCoarse .UpdatedAt}} ago</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column has-text-centered">
|
||||
<div class="box">
|
||||
<p class="is-size-7">Replies</p>
|
||||
{{if $Stats}}
|
||||
{{$Stats.Replies}}
|
||||
{{else}}
|
||||
err
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column has-text-centered">
|
||||
<div class="box">
|
||||
<p class="is-size-7">Views</p>
|
||||
{{if $Stats}}
|
||||
{{$Stats.Views}}
|
||||
{{else}}
|
||||
err
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{end}}
|
121
web/templates/forum/index.html
Normal file
121
web/templates/forum/index.html
Normal file
|
@ -0,0 +1,121 @@
|
|||
{{define "title"}}Forums{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-success">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||
<span>Forums</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="block p-2">
|
||||
<div class="columns">
|
||||
<div class="column">To Do</div>
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/admin" class="button is-small has-text-danger">
|
||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||
<span>Manage Forums</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
{{range .Categories}}
|
||||
<div class="block p-2">
|
||||
<h1 class="title">{{.Category}}</h1>
|
||||
|
||||
{{if eq (len .Forums) 0}}
|
||||
<em>There are no forums under this category.</em>
|
||||
{{else}}
|
||||
{{range .Forums}}
|
||||
{{$Stats := $Root.ForumMap.Get .ID}}
|
||||
<div class="card block has-background-primary-light">
|
||||
<!-- <header class="card-header has-background-success">
|
||||
<p class="card-header-title has-text-light">
|
||||
{{.Title}}
|
||||
</p>
|
||||
</header> -->
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
|
||||
<h1 class="title">
|
||||
<a href="/f/{{.Fragment}}">{{.Title}}</a>
|
||||
</h1>
|
||||
|
||||
<div class="content">
|
||||
{{if .Description}}
|
||||
{{ToMarkdown .Description}}
|
||||
{{else}}
|
||||
<em>No description</em>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{if .Explicit}}
|
||||
<span class="tag is-danger is-light">
|
||||
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||
Explicit
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="box has-background-success-light">
|
||||
<h2 class="subtitle">Latest Post</h2>
|
||||
Hello world!
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column has-text-centered">
|
||||
<div class="box has-background-warning-light">
|
||||
<p class="is-size-7">Topics</p>
|
||||
{{if $Stats}}
|
||||
{{$Stats.Threads}}
|
||||
{{else}}
|
||||
err
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column has-text-centered">
|
||||
<div class="box has-background-warning-light">
|
||||
<p class="is-size-7">Posts</p>
|
||||
{{if $Stats}}
|
||||
{{$Stats.Posts}}
|
||||
{{else}}
|
||||
err
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="column has-text-centered">
|
||||
<div class="box has-background-warning-light">
|
||||
<p class="is-size-7">Users</p>
|
||||
{{if $Stats}}
|
||||
{{$Stats.Users}}
|
||||
{{else}}
|
||||
err
|
||||
{{end}}
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}<!-- range .Categories -->
|
||||
|
||||
{{end}}
|
148
web/templates/forum/new_post.html
Normal file
148
web/templates/forum/new_post.html
Normal file
|
@ -0,0 +1,148 @@
|
|||
{{define "title"}}
|
||||
{{if .EditCommentID}}
|
||||
Edit Comment
|
||||
{{else if .Thread}}
|
||||
Reply to Thread
|
||||
{{else}}
|
||||
New Forum Post
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
{{if .EditCommentID}}
|
||||
Edit comment on: {{or .Thread.Title "Untitled Thread"}}
|
||||
{{else if .Thread}}
|
||||
Reply to: {{or .Thread.Title "Untitled Thread"}}
|
||||
{{else}}
|
||||
Post to: {{.Forum.Title}}
|
||||
{{end}}
|
||||
</h1>
|
||||
<h2 class="subtitle">
|
||||
/f/{{.Forum.Fragment}}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
|
||||
<div class="card" style="width: 100%; max-width: 640px">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
<span class="icon"><i class="fa fa-message"></i></span>
|
||||
{{if .EditCommentID}}
|
||||
Edit Comment
|
||||
{{else if .Thread}}
|
||||
Reply to Thread
|
||||
{{else}}
|
||||
New Thread
|
||||
{{end}}
|
||||
</p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
|
||||
{{if and (eq .Request.Method "POST") (ne .Message "")}}
|
||||
<label class="label">Preview:</label>
|
||||
<div class="box content has-background-warning-light">
|
||||
{{ToMarkdown .Message}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/forum/post?to={{.Forum.Fragment}}{{if .Thread}}&thread={{.Thread.ID}}{{end}}{{if .EditCommentID}}&edit={{.EditCommentID}}{{end}}" method="POST">
|
||||
{{InputCSRF}}
|
||||
|
||||
{{if not .Thread}}
|
||||
<div class="field block">
|
||||
<label for="title" class="label">Title</label>
|
||||
<input type="text" class="input"
|
||||
name="title" id="title"
|
||||
placeholder="A title for your post"
|
||||
value="{{.PostTitle}}"
|
||||
required>
|
||||
<p class="help">Required.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field block">
|
||||
<label for="message" class="label">Message</label>
|
||||
<textarea class="textarea" cols="80" rows="8"
|
||||
name="message"
|
||||
id="message"
|
||||
required
|
||||
placeholder="Message">{{.Message}}</textarea>
|
||||
<p class="help">
|
||||
Markdown formatting supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{if not .Thread}}
|
||||
<div class="field block">
|
||||
<div class="label">Options</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="explicit"
|
||||
value="true"
|
||||
{{if .Explicit}}checked{{end}}>
|
||||
Mark as Explicit (NSFW)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<div>
|
||||
<label class="checkbox has-text-danger">
|
||||
<input type="checkbox"
|
||||
name="noreply"
|
||||
value="true"
|
||||
{{if .Explicit}}checked{{end}}>
|
||||
No replies allowed <i class="fa fa-gavel ml-1"></i>
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field has-text-centered">
|
||||
<button type="submit"
|
||||
name="intent"
|
||||
value="preview"
|
||||
class="button is-link">
|
||||
Preview
|
||||
</button>
|
||||
<button type="submit"
|
||||
name="intent"
|
||||
value="submit"
|
||||
class="button is-success">
|
||||
Post Message
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("DOMContentLoaded", (event) => {
|
||||
let $file = document.querySelector("#file"),
|
||||
$fileName = document.querySelector("#fileName");
|
||||
|
||||
$file.addEventListener("change", function() {
|
||||
let file = this.files[0];
|
||||
$fileName.innerHTML = file.name;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
153
web/templates/forum/thread.html
Normal file
153
web/templates/forum/thread.html
Normal file
|
@ -0,0 +1,153 @@
|
|||
{{define "title"}}{{.Forum.Title}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-success">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||
<span>{{.Forum.Title}}</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
|
||||
<div class="block px-4">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/forum">Forums</a></li>
|
||||
<li><a href="/f/{{.Forum.Fragment}}">{{.Forum.Title}}</a></Li>
|
||||
<li class="is-active">
|
||||
<a href="{{.Request.URL.Path}}" aria-current="page">{{or .Thread.Title "Untitled Thread"}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
|
||||
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||
<span>Add Reply</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="title px-4">{{or .Thread.Title "Untitled Thread"}}</h1>
|
||||
|
||||
<p class="block p-4">
|
||||
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this thread
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
</p>
|
||||
|
||||
<div class="block p-2">
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
|
||||
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
|
||||
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||
<ul class="pagination-list">
|
||||
{{$Root := .}}
|
||||
{{range .Pager.Iter}}
|
||||
<li>
|
||||
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||
aria-label="Page {{.Page}}"
|
||||
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||
{{.Page}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
<div class="block p-2">
|
||||
{{range .Comments}}
|
||||
<div class="box has-background-link-light">
|
||||
<div class="columns">
|
||||
<div class="column is-2 has-text-centered">
|
||||
<div>
|
||||
<a href="/u/{{.User.Username}}">
|
||||
<figure class="image is-96x96 is-inline-block">
|
||||
{{if .User.ProfilePhoto.ID}}
|
||||
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
|
||||
{{else}}
|
||||
<img src="/static/img/shy.png">
|
||||
{{end}}
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
|
||||
{{if .User.IsAdmin}}
|
||||
<div class="is-size-7 mt-1">
|
||||
<span class="tag is-danger">
|
||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||
<span>Admin</span>
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="column content">
|
||||
{{ToMarkdown .Message}}
|
||||
|
||||
<hr class="has-background-grey mb-2">
|
||||
|
||||
<div class="columns is-mobile is-multiline is-size-7">
|
||||
<div class="column is-narrow">
|
||||
<span title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">
|
||||
{{SincePrettyCoarse .CreatedAt}} ago
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
|
||||
<span class="icon"><i class="fa fa-flag"></i></span>
|
||||
<span>Report</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"e={{.ID}}" class="has-text-dark">
|
||||
<span class="icon"><i class="fa fa-quote-right"></i></span>
|
||||
<span>Quote</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}" class="has-text-dark">
|
||||
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||
<span>Reply</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID)}}
|
||||
<div class="column is-narrow">
|
||||
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&edit={{.ID}}" class="has-text-dark">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
</div>
|
||||
<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">
|
||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||
<span>Delete</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="block p-2 has-text-right">
|
||||
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
|
||||
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||
<span>Add Reply</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{end}}
|
Reference in New Issue
Block a user