From a663462e2744e04f7d2cab58894049e3579cb0c6 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 23 Aug 2022 22:55:19 -0700 Subject: [PATCH 1/2] 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. --- pkg/config/enum.go | 9 ++ pkg/config/page_sizes.go | 7 +- pkg/controller/forum/add_edit.go | 139 +++++++++++++++++ pkg/controller/forum/forum.go | 74 +++++++++ pkg/controller/forum/forums.go | 77 ++++++++++ pkg/controller/forum/manage.go | 48 ++++++ pkg/controller/forum/new_post.go | 170 +++++++++++++++++++++ pkg/controller/forum/thread.go | 88 +++++++++++ pkg/markdown/markdown.go | 11 ++ pkg/models/comment.go | 70 +++++++++ pkg/models/forum.go | 156 +++++++++++++++++++ pkg/models/forum_stats.go | 157 +++++++++++++++++++ pkg/models/models.go | 3 + pkg/models/thread.go | 215 +++++++++++++++++++++++++++ pkg/router/router.go | 7 + pkg/templates/template_funcs.go | 6 + web/templates/base.html | 6 +- web/templates/forum/add_edit.html | 143 ++++++++++++++++++ web/templates/forum/admin.html | 120 +++++++++++++++ web/templates/forum/board_index.html | 127 ++++++++++++++++ web/templates/forum/index.html | 121 +++++++++++++++ web/templates/forum/new_post.html | 148 ++++++++++++++++++ web/templates/forum/thread.html | 153 +++++++++++++++++++ 23 files changed, 2050 insertions(+), 5 deletions(-) create mode 100644 pkg/controller/forum/add_edit.go create mode 100644 pkg/controller/forum/forum.go create mode 100644 pkg/controller/forum/forums.go create mode 100644 pkg/controller/forum/manage.go create mode 100644 pkg/controller/forum/new_post.go create mode 100644 pkg/controller/forum/thread.go create mode 100644 pkg/models/comment.go create mode 100644 pkg/models/forum.go create mode 100644 pkg/models/forum_stats.go create mode 100644 pkg/models/thread.go create mode 100644 web/templates/forum/add_edit.html create mode 100644 web/templates/forum/admin.html create mode 100644 web/templates/forum/board_index.html create mode 100644 web/templates/forum/index.html create mode 100644 web/templates/forum/new_post.html create mode 100644 web/templates/forum/thread.html diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 116b976..2a01ff9 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -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. diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index df1c39d..9c07eec 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -9,6 +9,9 @@ var ( PageSizeAdminFeedback = 20 PageSizeSiteGallery = 18 PageSizeUserGallery = 18 - PageSizeInboxList = 20 // sidebar list - PageSizeInboxThread = 20 // conversation view + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 20 // conversation view + PageSizeForums = 100 // TODO: for main category index view + PageSizeThreadList = 20 + PageSizeForumAdmin = 20 ) diff --git a/pkg/controller/forum/add_edit.go b/pkg/controller/forum/add_edit.go new file mode 100644 index 0000000..8dcf5be --- /dev/null +++ b/pkg/controller/forum/add_edit.go @@ -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 + } + }) +} diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go new file mode 100644 index 0000000..91049ea --- /dev/null +++ b/pkg/controller/forum/forum.go @@ -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 + } + }) +} diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go new file mode 100644 index 0000000..51ff5fb --- /dev/null +++ b/pkg/controller/forum/forums.go @@ -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 + } + }) +} diff --git a/pkg/controller/forum/manage.go b/pkg/controller/forum/manage.go new file mode 100644 index 0000000..7401105 --- /dev/null +++ b/pkg/controller/forum/manage.go @@ -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 + } + }) +} diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go new file mode 100644 index 0000000..c67e92e --- /dev/null +++ b/pkg/controller/forum/new_post.go @@ -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 + } + }) +} diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go new file mode 100644 index 0000000..f822402 --- /dev/null +++ b/pkg/controller/forum/thread.go @@ -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 + } + }) +} diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index eaa69fc..31dede1 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -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") +} diff --git a/pkg/models/comment.go b/pkg/models/comment.go new file mode 100644 index 0000000..4197fd7 --- /dev/null +++ b/pkg/models/comment.go @@ -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 +} diff --git a/pkg/models/forum.go b/pkg/models/forum.go new file mode 100644 index 0000000..44a6b8d --- /dev/null +++ b/pkg/models/forum.go @@ -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 +} diff --git a/pkg/models/forum_stats.go b/pkg/models/forum_stats.go new file mode 100644 index 0000000..86aeb0f --- /dev/null +++ b/pkg/models/forum_stats.go @@ -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 +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 01a8ea3..d8d07df 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -16,4 +16,7 @@ func AutoMigrate() { DB.AutoMigrate(&Friend{}) DB.AutoMigrate(&Block{}) DB.AutoMigrate(&Feedback{}) + DB.AutoMigrate(&Forum{}) + DB.AutoMigrate(&Thread{}) + DB.AutoMigrate(&Comment{}) } diff --git a/pkg/models/thread.go b/pkg/models/thread.go new file mode 100644 index 0000000..07972c3 --- /dev/null +++ b/pkg/models/thread.go @@ -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 +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 413ef00..fbfc73a 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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()) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index f280d73..3065d6e 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -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++ { diff --git a/web/templates/base.html b/web/templates/base.html index f9bb1f8..1854c7f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -52,10 +52,10 @@ Gallery - + Forum + diff --git a/web/templates/forum/add_edit.html b/web/templates/forum/add_edit.html new file mode 100644 index 0000000..e609c08 --- /dev/null +++ b/web/templates/forum/add_edit.html @@ -0,0 +1,143 @@ +{{define "title"}}Forums{{end}} +{{define "content"}} +
+
+
+
+

+ {{if .EditForum}}Edit Forum{{else}}New Forum{{end}} +

+
+
+
+
+ +{{$Root := .}} + +
+
+
+
+
+

Forum Properties

+
+ +
+
+ {{InputCSRF}} + + +
+ + +
+ +
+ + +

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

+
+ +
+ + +

+ Write a short description of the forum. Markdown formatting + is supported here. +

+
+ + {{if .CurrentUser.IsAdmin}} +
+ +
+ +
+
+ {{end}} + +
+ + + +

+ Check this box if the forum is intended for explicit content. Users must + opt-in to see explicit content. +

+ + {{if .CurrentUser.IsAdmin}} + +

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

+ {{end}} + + {{if .CurrentUser.IsAdmin}} + +

+ Check this box if the forum allows photos to be uploaded (not implemented) +

+ {{end}} +
+ +
+ +
+
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/forum/admin.html b/web/templates/forum/admin.html new file mode 100644 index 0000000..b6906ac --- /dev/null +++ b/web/templates/forum/admin.html @@ -0,0 +1,120 @@ +{{define "title"}}Forums{{end}} +{{define "content"}} +
+
+
+
+

+ + Forum Administration +

+
+
+
+
+ +{{$Root := .}} + +
+ + + Create New Forum + +
+ +

+ Found {{.Pager.Total}} forum{{Pluralize64 .Pager.Total}} you can manage + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ +
+ +
+ {{range .Forums}} +
+
+
+

{{.Title}}

+

+ /f/{{.Fragment}} + {{if .Category}}{{.Category}}{{end}} + + by {{.Owner.Username}} + {{if .Owner.IsAdmin}}{{end}} + +

+ +
+

+ +

+ + {{if eq .Description ""}} +

No description

+ {{else}} + {{ToMarkdown .Description}} + {{end}} +
+ +
+ {{if .Explicit}} +
+ + Explicit +
+ {{end}} + + {{if .Privileged}} +
+ + Privileged +
+ {{end}} + + {{if .PermitPhotos}} +
+ + PermitPhotos +
+ {{end}} + +
+ Created {{.CreatedAt.Format "2006-01-02 15:04:05"}} +
+
+ Updated {{.UpdatedAt.Format "2006-01-02 15:04:05"}} +
+
+
+ + +
+ +
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/web/templates/forum/board_index.html b/web/templates/forum/board_index.html new file mode 100644 index 0000000..7e900b4 --- /dev/null +++ b/web/templates/forum/board_index.html @@ -0,0 +1,127 @@ +{{define "title"}}{{.Forum.Title}}{{end}} +{{define "content"}} +
+
+
+
+

+ + {{.Forum.Title}} +

+
+
+
+
+ +{{$Root := .}} + +
+
+
+ +
+ +
+
+ +

+ Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this forum + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ +
+ +{{$Root := .}} +
+{{range .Threads}} + {{$Stats := $Root.ThreadMap.Get .ID}} + +{{end}} +
+ +{{end}} \ No newline at end of file diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html new file mode 100644 index 0000000..662b952 --- /dev/null +++ b/web/templates/forum/index.html @@ -0,0 +1,121 @@ +{{define "title"}}Forums{{end}} +{{define "content"}} +
+
+
+
+

+ + Forums +

+
+
+
+
+ +
+
+
To Do
+ {{if .CurrentUser.IsAdmin}} + + {{end}} +
+
+ +{{$Root := .}} +{{range .Categories}} +
+

{{.Category}}

+ + {{if eq (len .Forums) 0}} + There are no forums under this category. + {{else}} + {{range .Forums}} + {{$Stats := $Root.ForumMap.Get .ID}} +
+ +
+
+
+ +

+ {{.Title}} +

+ +
+ {{if .Description}} + {{ToMarkdown .Description}} + {{else}} + No description + {{end}} +
+ +
+ {{if .Explicit}} + + + Explicit + + {{end}} +
+ +
+
+
+

Latest Post

+ Hello world! +
+
+
+
+
+
+

Topics

+ {{if $Stats}} + {{$Stats.Threads}} + {{else}} + err + {{end}} +
+
+
+
+

Posts

+ {{if $Stats}} + {{$Stats.Posts}} + {{else}} + err + {{end}} +
+
+ +
+
+ +
+
+
+ {{end}} + {{end}} +
+{{end}} + +{{end}} \ No newline at end of file diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html new file mode 100644 index 0000000..f732c8e --- /dev/null +++ b/web/templates/forum/new_post.html @@ -0,0 +1,148 @@ +{{define "title"}} + {{if .EditCommentID}} + Edit Comment + {{else if .Thread}} + Reply to Thread + {{else}} + New Forum Post + {{end}} +{{end}} +{{define "content"}} +
+
+
+
+

+ {{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}} +

+

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

+
+
+
+ +
+
+
+ +
+ +
+ + {{if and (eq .Request.Method "POST") (ne .Message "")}} + +
+ {{ToMarkdown .Message}} +
+ {{end}} + +
+ {{InputCSRF}} + + {{if not .Thread}} +
+ + +

Required.

+
+ {{end}} + +
+ + +

+ Markdown formatting supported. +

+
+ + {{if not .Thread}} +
+
Options
+ +
+ +
+ + {{if .CurrentUser.IsAdmin}} +
+ +
+ {{end}} +
+ {{end}} + +
+ + +
+
+ +
+
+ +
+
+
+ +
+ + +{{end}} \ No newline at end of file diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html new file mode 100644 index 0000000..05837f6 --- /dev/null +++ b/web/templates/forum/thread.html @@ -0,0 +1,153 @@ +{{define "title"}}{{.Forum.Title}}{{end}} +{{define "content"}} +
+
+
+
+

+ + {{.Forum.Title}} +

+
+
+
+
+ +{{$Root := .}} + +
+ +
+ +

{{or .Thread.Title "Untitled Thread"}}

+ +

+ Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this thread + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ +
+ +{{$Root := .}} +
+{{range .Comments}} + +{{end}} +
+ +
+ + + Add Reply + +
+ +{{end}} \ No newline at end of file From bb08ec56cebda250da5b4805f74da28b350a5997 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 24 Aug 2022 21:17:34 -0700 Subject: [PATCH 2/2] Finish Forums + Likes & Notifications Finish implementing the basic forum features: * Pinned threads (admin or board owner only) * Edit Thread settings when you edit the top-most comment. * NoReply threads remove all the reply buttons. * Explicit forums and threads are filtered out unless opted-in (admins always see them). * Count the unique members who participated in each forum. * Get the most recently updated thread to show on forum list page. * Contact/Report page: handle receiving a comment ID to report on. Implement Likes & Notifications * Like buttons added to Photos and Profile Pages. Implemented via simple vanilla JS (likes.js) to make ajax requests to back-end to like/unlike. * Notifications: for your photo or profile being liked. If you unlike, the existing notifications about the like are revoked. * The notifications appear as an alert number in the nav bar and are read on the User Dashboard. Click to mark a notification as "read" or click the "mark all as read" button. Update DeleteUser to scrub likes, notifications, threads, and comments. --- pkg/config/page_sizes.go | 25 +-- pkg/controller/account/dashboard.go | 39 +++- pkg/controller/account/profile.go | 4 + pkg/controller/admin/feedback.go | 14 ++ pkg/controller/api/json_layer.go | 55 +++++ pkg/controller/api/likes.go | 124 +++++++++++ pkg/controller/api/read_notification.go | 76 +++++++ pkg/controller/forum/forum.go | 11 + pkg/controller/forum/forums.go | 2 +- pkg/controller/forum/new_post.go | 48 ++++- pkg/controller/index/contact.go | 9 + pkg/controller/photo/site_gallery.go | 8 + pkg/controller/photo/user_gallery.go | 8 + pkg/middleware/csrf.go | 6 + pkg/models/deletion/delete_user.go | 78 +++++++ pkg/models/forum.go | 7 +- pkg/models/forum_stats.go | 272 ++++++++++++++---------- pkg/models/like.go | 145 +++++++++++++ pkg/models/models.go | 2 + pkg/models/notification.go | 175 +++++++++++++++ pkg/models/photo.go | 15 ++ pkg/models/thread.go | 55 ++++- pkg/router/router.go | 2 + pkg/templates/template_vars.go | 33 ++- web/static/js/likes.js | 56 +++++ web/templates/account/dashboard.html | 143 ++++++++++++- web/templates/account/profile.html | 21 +- web/templates/admin/user_actions.html | 12 -- web/templates/base.html | 23 +- web/templates/forum/board_index.html | 39 +++- web/templates/forum/index.html | 32 ++- web/templates/forum/new_post.html | 20 +- web/templates/forum/thread.html | 72 ++++++- web/templates/partials/like_button.html | 2 + web/templates/photo/gallery.html | 32 +++ 35 files changed, 1471 insertions(+), 194 deletions(-) create mode 100644 pkg/controller/api/json_layer.go create mode 100644 pkg/controller/api/likes.go create mode 100644 pkg/controller/api/read_notification.go create mode 100644 pkg/models/like.go create mode 100644 pkg/models/notification.go create mode 100644 web/static/js/likes.js create mode 100644 web/templates/partials/like_button.html diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index 9c07eec..9ea120d 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -2,16 +2,17 @@ package config // Pagination sizes per page. var ( - PageSizeMemberSearch = 60 - PageSizeFriends = 12 - PageSizeBlockList = 12 - PageSizeAdminCertification = 20 - PageSizeAdminFeedback = 20 - PageSizeSiteGallery = 18 - PageSizeUserGallery = 18 - PageSizeInboxList = 20 // sidebar list - PageSizeInboxThread = 20 // conversation view - PageSizeForums = 100 // TODO: for main category index view - PageSizeThreadList = 20 - PageSizeForumAdmin = 20 + PageSizeMemberSearch = 60 + PageSizeFriends = 12 + PageSizeBlockList = 12 + PageSizeAdminCertification = 20 + PageSizeAdminFeedback = 20 + PageSizeSiteGallery = 18 + PageSizeUserGallery = 18 + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 20 // conversation view + PageSizeForums = 100 // TODO: for main category index view + PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread + PageSizeForumAdmin = 20 + PageSizeDashboardNotifications = 50 ) diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go index d529ac9..a2d8592 100644 --- a/pkg/controller/account/dashboard.go +++ b/pkg/controller/account/dashboard.go @@ -3,6 +3,9 @@ package account 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" ) @@ -10,7 +13,41 @@ import ( func Dashboard() http.HandlerFunc { tmpl := templates.Must("account/dashboard.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := tmpl.Execute(w, r, nil); err != nil { + currentUser, err := session.CurrentUser(r) + if err != nil { + http.Error(w, "Couldn't get currentUser", http.StatusInternalServerError) + return + } + + // Mark all notifications read? + if r.FormValue("intent") == "read-notifications" { + models.MarkNotificationsRead(currentUser) + session.Flash(w, r, "All of your notifications have been marked as 'read!'") + templates.Redirect(w, "/me") + return + } + + // Get our notifications. + pager := &models.Pagination{ + Page: 1, + PerPage: config.PageSizeDashboardNotifications, + Sort: "created_at desc", + } + pager.ParsePage(r) + notifs, err := models.PaginateNotifications(currentUser, pager) + if err != nil { + session.FlashError(w, r, "Couldn't get your notifications: %s", err) + } + + // Map our notifications. + notifMap := models.MapNotifications(notifs) + + var vars = map[string]interface{}{ + "Notifications": notifs, + "NotifMap": notifMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 0cb52d9..3082b99 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -57,8 +57,12 @@ func Profile() http.HandlerFunc { isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved" ) + // Get Likes for this profile. + likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID}) + vars := map[string]interface{}{ "User": user, + "LikeMap": likeMap, "IsFriend": isFriend, "IsPrivate": isPrivate, } diff --git a/pkg/controller/admin/feedback.go b/pkg/controller/admin/feedback.go index f0fe354..b1b5604 100644 --- a/pkg/controller/admin/feedback.go +++ b/pkg/controller/admin/feedback.go @@ -89,6 +89,20 @@ func Feedback() http.HandlerFunc { return } } + case "comments": + // Get this comment. + comment, err := models.GetComment(fb.TableID) + if err != nil { + session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err) + } else { + // What was the comment on? + switch comment.TableName { + case "threads": + // Visit the thread. + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID)) + return + } + } default: session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID) } diff --git a/pkg/controller/api/json_layer.go b/pkg/controller/api/json_layer.go new file mode 100644 index 0000000..ca65423 --- /dev/null +++ b/pkg/controller/api/json_layer.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Envelope is the standard JSON response envelope. +type Envelope struct { + Data interface{} `json:"data"` + StatusCode int +} + +// ParseJSON request body. +func ParseJSON(r *http.Request, v interface{}) error { + if r.Header.Get("Content-Type") != "application/json" { + return errors.New("request Content-Type must be application/json") + } + + // Parse request body. + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } + + log.Error("body: %+v", body) + + // Parse params from JSON. + if err := json.Unmarshal(body, v); err != nil { + return err + } + + return nil +} + +// SendJSON response. +func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) { + buf, err := json.Marshal(Envelope{ + Data: v, + StatusCode: statusCode, + }) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(buf) +} diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go new file mode 100644 index 0000000..c7333b0 --- /dev/null +++ b/pkg/controller/api/likes.go @@ -0,0 +1,124 @@ +package api + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" +) + +// Likes API. +func Likes() http.HandlerFunc { + // Request JSON schema. + type Request struct { + TableName string `json:"name"` + TableID uint64 `json:"id"` + Unlike bool `json:"unlike,omitempty"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + Likes int64 `json:"likes"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Who do we notify about this like? + var targetUser *models.User + switch req.TableName { + case "photos": + if photo, err := models.GetPhoto(req.TableID); err == nil { + if user, err := models.GetUser(photo.UserID); err == nil { + targetUser = user + } + } else { + log.Error("For like on photos table: didn't find photo %d: %s", req.TableID, err) + } + case "users": + log.Error("subject is users, find %d", req.TableID) + if user, err := models.GetUser(req.TableID); err == nil { + targetUser = user + log.Warn("found user %s", targetUser.Username) + } else { + log.Error("For like on users table: didn't find user %d: %s", req.TableID, err) + } + } + + // Is the table likeable? + if _, ok := models.LikeableTables[req.TableName]; !ok { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Can't like table %s: not allowed.", req.TableName), + }) + return + } + + // Put in a like. + if req.Unlike { + if err := models.Unlike(currentUser, req.TableName, req.TableID); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error unliking: %s", err), + }) + return + } + + // Remove the target's notification about this like. + models.RemoveNotification(req.TableName, req.TableID) + } else { + if err := models.AddLike(currentUser, req.TableName, req.TableID); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error liking: %s", err), + }) + return + } + + // Notify the recipient of the like. + log.Info("Added like on %s:%d, notifying owner %+v", req.TableName, req.TableID, targetUser) + if targetUser != nil { + notif := &models.Notification{ + UserID: targetUser.ID, + User: *currentUser, + Type: models.NotificationLike, + TableName: req.TableName, + TableID: req.TableID, + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create Likes notification: %s", err) + } + } + } + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + Likes: models.CountLikes(req.TableName, req.TableID), + }) + }) +} diff --git a/pkg/controller/api/read_notification.go b/pkg/controller/api/read_notification.go new file mode 100644 index 0000000..3a34edc --- /dev/null +++ b/pkg/controller/api/read_notification.go @@ -0,0 +1,76 @@ +package api + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" +) + +// ReadNotification API to mark a notif ID as "read." +func ReadNotification() http.HandlerFunc { + // Request JSON schema. + type Request struct { + ID uint64 `json:"id"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Get this notification. + notif, err := models.GetNotification(req.ID) + if err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + Error: err.Error(), + }) + return + } + + // Ensure it's ours to read. + if notif.UserID != currentUser.ID { + SendJSON(w, http.StatusForbidden, Response{ + Error: "That is not your notification.", + }) + return + } + + // Mark it read. + notif.Read = true + notif.Save() + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + }) + }) +} diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go index 91049ea..f77cd16 100644 --- a/pkg/controller/forum/forum.go +++ b/pkg/controller/forum/forum.go @@ -41,6 +41,14 @@ func Forum() http.HandlerFunc { return } + // Get the pinned threads. + pinned, err := models.PinnedThreads(forum) + if err != nil { + session.FlashError(w, r, "Couldn't get pinned threads: %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{ @@ -57,6 +65,9 @@ func Forum() http.HandlerFunc { return } + // Inject pinned threads on top. + threads = append(pinned, threads...) + // Map the statistics (replies, views) of these threads. threadMap := models.MapThreadStatistics(threads) diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go index 51ff5fb..2987040 100644 --- a/pkg/controller/forum/forums.go +++ b/pkg/controller/forum/forums.go @@ -51,7 +51,7 @@ func Landing() http.HandlerFunc { } pager.ParsePage(r) - forums, err := models.PaginateForums(currentUser.ID, config.ForumCategories, pager) + forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager) if err != nil { session.FlashError(w, r, "Couldn't paginate forums: %s", err) templates.Redirect(w, "/") diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index c67e92e..9589a82 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -24,12 +24,18 @@ func NewPost() http.HandlerFunc { intent = r.FormValue("intent") // preview or submit title = r.FormValue("title") // for new forum post only message = r.PostFormValue("message") // comment body + isPinned = r.PostFormValue("pinned") == "true" // owners or admins only 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 + + // If we are modifying a comment (post) and it's the OG post of the + // thread, we show and accept the thread settings to be updated as + // well (pinned, explicit, noreply) + isOriginalComment bool ) // Get the current user. @@ -88,6 +94,18 @@ func NewPost() http.HandlerFunc { message = comment.Message } + // Is this the OG thread of the post? + if thread.CommentID == comment.ID { + isOriginalComment = true + + // Restore the checkbox option form values from thread settings. + if r.Method == http.MethodGet { + isPinned = thread.Pinned + isExplicit = thread.Explicit + isNoReply = thread.NoReply + } + } + // Are we DELETING this comment? if isDelete { if err := thread.DeleteReply(comment); err != nil { @@ -116,6 +134,17 @@ func NewPost() http.HandlerFunc { // Are we modifying an existing comment? if comment != nil { comment.Message = message + + // Can we update the thread props? + if isOriginalComment { + thread.Pinned = isPinned + thread.Explicit = isExplicit + thread.NoReply = isNoReply + if err := thread.Save(); err != nil { + session.FlashError(w, r, "Couldn't save thread properties: %s", err) + } + } + if err := comment.Save(); err != nil { session.FlashError(w, r, "Couldn't save comment: %s", err) } else { @@ -142,6 +171,7 @@ func NewPost() http.HandlerFunc { forum.ID, title, message, + isPinned, isExplicit, isNoReply, ); err != nil { @@ -155,12 +185,18 @@ func NewPost() http.HandlerFunc { } var vars = map[string]interface{}{ - "Forum": forum, - "Thread": thread, - "Intent": intent, - "PostTitle": title, - "EditCommentID": editCommentID, - "Message": message, + "Forum": forum, + "Thread": thread, + "Intent": intent, + "PostTitle": title, + "EditCommentID": editCommentID, + "EditThreadSettings": isOriginalComment, + "Message": message, + + // Thread settings (for editing the original comment esp.) + "IsPinned": isPinned, + "IsExplicit": isExplicit, + "IsNoReply": isNoReply, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/index/contact.go b/pkg/controller/index/contact.go index e13ca24..1769ff0 100644 --- a/pkg/controller/index/contact.go +++ b/pkg/controller/index/contact.go @@ -65,6 +65,15 @@ func Contact() http.HandlerFunc { case "report.message": tableName = "messages" tableLabel = "Direct Message conversation" + case "report.comment": + tableName = "comments" + + // Find this comment. + if comment, err := models.GetComment(uint64(tableID)); err == nil { + tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username) + } else { + log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err) + } } } diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 8593627..c99e0e5 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -46,10 +46,18 @@ func SiteGallery() http.HandlerFunc { session.FlashError(w, r, "Failed to MapUsers: %s", err) } + // Get Likes information about these photos. + var photoIDs = []uint64{} + for _, p := range photos { + photoIDs = append(photoIDs, p.ID) + } + likeMap := models.MapLikes(currentUser, "photos", photoIDs) + var vars = map[string]interface{}{ "IsSiteGallery": true, "Photos": photos, "UserMap": userMap, + "LikeMap": likeMap, "Pager": pager, "ViewStyle": viewStyle, } diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 5e261e3..3738454 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -91,11 +91,19 @@ func UserPhotos() http.HandlerFunc { explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility) } + // Get Likes information about these photos. + var photoIDs = []uint64{} + for _, p := range photos { + photoIDs = append(photoIDs, p.ID) + } + likeMap := models.MapLikes(currentUser, "photos", photoIDs) + var vars = map[string]interface{}{ "IsOwnPhotos": currentUser.ID == user.ID, "User": user, "Photos": photos, "Pager": pager, + "LikeMap": likeMap, "ViewStyle": viewStyle, "ExplicitCount": explicitCount, } diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index ef26bf3..7f6a680 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -18,6 +18,12 @@ func CSRF(handler http.Handler) http.Handler { token := MakeCSRFCookie(r, w) ctx := context.WithValue(r.Context(), session.CSRFKey, token) + // If it's a JSON post, allow it thru. + if r.Header.Get("Content-Type") == "application/json" { + handler.ServeHTTP(w, r.WithContext(ctx)) + return + } + // If we are running a POST request, validate the CSRF form value. if r.Method != http.MethodGet { r.ParseMultipartForm(config.MultipartMaxMemory) diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 10dd7a4..a08ad3f 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -19,6 +19,10 @@ func DeleteUser(user *models.User) error { } var todo = []remover{ + {"Notifications", DeleteNotifications}, + {"Likes", DeleteLikes}, + {"Threads", DeleteForumThreads}, + {"Comments", DeleteComments}, {"Photos", DeleteUserPhotos}, {"Certification Photo", DeleteCertification}, {"Messages", DeleteUserMessages}, @@ -112,6 +116,26 @@ func DeleteFriends(userID uint64) error { return result.Error } +// DeleteNotifications scrubs all notifications about a user. +func DeleteNotifications(userID uint64) error { + log.Error("DeleteUser: DeleteNotifications(%d)", userID) + result := models.DB.Where( + "user_id = ? OR about_user_id = ?", + userID, userID, + ).Delete(&models.Notification{}) + return result.Error +} + +// DeleteLikes scrubs all Likes about a user. +func DeleteLikes(userID uint64) error { + log.Error("DeleteUser: DeleteLikes(%d)", userID) + result := models.DB.Where( + "user_id = ? OR (table_name='users' AND table_id=?)", + userID, userID, + ).Delete(&models.Like{}) + return result.Error +} + // DeleteProfile scrubs data for deleting a user. func DeleteProfile(userID uint64) error { log.Error("DeleteUser: DeleteProfile(%d)", userID) @@ -121,3 +145,57 @@ func DeleteProfile(userID uint64) error { ).Delete(&models.ProfileField{}) return result.Error } + +// DeleteForumThreads scrubs all forum threads started by the user. +func DeleteForumThreads(userID uint64) error { + log.Error("DeleteUser: DeleteForumThreads(%d)", userID) + + var threadIDs = []uint64{} + result := models.DB.Table( + "threads", + ).Joins( + "JOIN comments ON (threads.comment_id = comments.id)", + ).Select( + "distinct(threads.id) as id", + ).Where( + "comments.user_id = ?", + userID, + ).Scan(&threadIDs) + + if result.Error != nil { + return fmt.Errorf("Couldn't list thread IDs created by user: %s", result.Error) + } + + log.Warn("thread IDs to wipe: %+v", threadIDs) + + // Wipe all these threads and their comments. + if len(threadIDs) > 0 { + result = models.DB.Where( + "table_name = ? AND table_id IN ?", + "threads", threadIDs, + ).Delete(&models.Comment{}) + if result.Error != nil { + return fmt.Errorf("Couldn't wipe threads of comments: %s", result.Error) + } + + // And finish the threads off too. + result = models.DB.Where( + "id IN ?", + threadIDs, + ).Delete(&models.Thread{}) + return result.Error + } + + return nil +} + +// DeleteComments deletes all comments by the user. +func DeleteComments(userID uint64) error { + log.Error("DeleteUser: DeleteComments(%d)", userID) + + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.Comment{}) + return result.Error +} diff --git a/pkg/models/forum.go b/pkg/models/forum.go index 44a6b8d..cd44c46 100644 --- a/pkg/models/forum.go +++ b/pkg/models/forum.go @@ -62,7 +62,7 @@ Parameters: - categories: optional, filter within categories - pager */ -func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*Forum, error) { +func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) { var ( fs = []*Forum{} query = (&Forum{}).Preload() @@ -75,6 +75,11 @@ func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*F placeholders = append(placeholders, categories) } + // Hide explicit forum if user hasn't opted into it. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit = false") + } + // Filters? if len(wheres) > 0 { query = query.Where( diff --git a/pkg/models/forum_stats.go b/pkg/models/forum_stats.go index 86aeb0f..8985ecc 100644 --- a/pkg/models/forum_stats.go +++ b/pkg/models/forum_stats.go @@ -25,119 +25,11 @@ func MapForumStatistics(forums []*Forum) ForumStatsMap { 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. + // Gather all the statistics. + result.generateThreadCount(IDs) + result.generatePostCount(IDs) + result.generateUserCount(IDs) + result.generateRecentThreads(IDs) return result } @@ -155,3 +47,157 @@ func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics { } return nil } + +// Compute the count of threads in each of the forum 'IDs'. +func (ts ForumStatsMap) generateThreadCount(IDs []uint64) { + // 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 { + if stats, ok := ts[row.ID]; ok { + stats.Threads = row.Threads + } + } +} + +// Compute the count of all posts in each of the forum 'IDs'. +func (ts ForumStatsMap) generatePostCount(IDs []uint64) { + 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 { + if stats, ok := ts[row.ID]; ok { + stats.Posts = row.Posts + } + } +} + +// Compute the count of all users in each of the forum 'IDs'. +func (ts ForumStatsMap) generateUserCount(IDs []uint64) { + type group struct { + ForumID 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( + "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 { + if stats, ok := ts[row.ForumID]; ok { + stats.Users = row.Users + } + } +} + +// Compute the recent threads for each of the forum 'IDs'. +func (ts ForumStatsMap) generateRecentThreads(IDs []uint64) { + var threadIDs = []map[string]interface{}{} + err := DB.Table( + "threads", + ).Select( + "forum_id, id AS thread_id, updated_at", + ).Where( + `updated_at = (SELECT MAX(updated_at) + FROM threads t2 + WHERE threads.forum_id = t2.forum_id) + AND threads.forum_id IN ?`, + IDs, + ).Order( + "updated_at desc", + ).Scan(&threadIDs) + + if err.Error != nil { + log.Error("Getting most recent thread IDs: %s", err.Error) + } + + // Map them easier. + var ( + threadForumMap = map[uint64]uint64{} + allThreadIDs = []uint64{} + ) + for _, row := range threadIDs { + if row["thread_id"] == nil || row["forum_id"] == nil { + continue + } + + var ( + threadID = uint64(row["thread_id"].(int64)) + forumID = uint64(row["forum_id"].(int64)) + ) + + allThreadIDs = append(allThreadIDs, threadID) + threadForumMap[threadID] = forumID + } + + // Select and map these threads in. + if threadMap, err := GetThreads(allThreadIDs); err == nil { + for threadID, thread := range threadMap { + if forumID, ok := threadForumMap[threadID]; ok { + if stats, ok := ts[forumID]; ok { + stats.RecentThread = thread + } + } + } + } +} diff --git a/pkg/models/like.go b/pkg/models/like.go new file mode 100644 index 0000000..b9a6323 --- /dev/null +++ b/pkg/models/like.go @@ -0,0 +1,145 @@ +package models + +import ( + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Like table. +type Like struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + TableName string + TableID uint64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// LikeableTables are the set of table names that allow likes (used by the JSON API). +var LikeableTables = map[string]interface{}{ + "photos": nil, + "users": nil, +} + +// AddLike to something. +func AddLike(user *User, tableName string, tableID uint64) error { + // Already has a like? + var like = &Like{} + exist := DB.Model(like).Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).First(&like) + if exist.Error == nil { + return nil + } + + // Create it. + like = &Like{ + UserID: user.ID, + TableName: tableName, + TableID: tableID, + } + return DB.Create(like).Error +} + +// Unlike something. +func Unlike(user *User, tableName string, tableID uint64) error { + result := DB.Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).Delete(&Like{}) + return result.Error +} + +// CountLikes on something. +func CountLikes(tableName string, tableID uint64) int64 { + var count int64 + DB.Model(&Like{}).Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Count(&count) + return count +} + +// LikedIDs filters a set of table IDs to ones the user likes. +func LikedIDs(user *User, tableName string, tableIDs []uint64) ([]uint64, error) { + var result = []uint64{} + if r := DB.Table( + "likes", + ).Select( + "table_id", + ).Where( + "user_id = ? AND table_name = ? AND table_id IN ?", + user.ID, tableName, tableIDs, + ).Scan(&result); r.Error != nil { + return result, r.Error + } + + return result, nil +} + +// LikeMap maps table IDs to Likes metadata. +type LikeMap map[uint64]*LikeStats + +// Get like stats from the map. +func (lm LikeMap) Get(id uint64) *LikeStats { + if stats, ok := lm[id]; ok { + return stats + } + return &LikeStats{} +} + +// LikeStats holds mapped statistics about liked objects. +type LikeStats struct { + Count int64 // how many total + UserLikes bool // current user likes it +} + +// MapLikes over a set of table IDs. +func MapLikes(user *User, tableName string, tableIDs []uint64) LikeMap { + var result = LikeMap{} + + // Initialize the result set. + for _, id := range tableIDs { + result[id] = &LikeStats{} + } + + // Hold the result of the grouped count query. + type group struct { + ID uint64 + Likes int64 + } + var groups = []group{} + + // Map the counts of likes to each of these IDs. + if res := DB.Table( + "likes", + ).Select( + "table_id AS id, count(id) AS likes", + ).Where( + "table_name = ? AND table_id IN ?", + tableName, tableIDs, + ).Group("table_id").Scan(&groups); res.Error != nil { + log.Error("MapLikes: count query: %s", res.Error) + } + + // Map the counts back in. + for _, row := range groups { + if stats, ok := result[row.ID]; ok { + stats.Count = row.Likes + } + } + + // Does the CURRENT USER like any of these IDs? + if likedIDs, err := LikedIDs(user, tableName, tableIDs); err == nil { + log.Error("USER LIKES IDS: %+v", likedIDs) + for _, id := range likedIDs { + if stats, ok := result[id]; ok { + stats.UserLikes = true + } + } + } + + return result +} diff --git a/pkg/models/models.go b/pkg/models/models.go index d8d07df..651547c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -19,4 +19,6 @@ func AutoMigrate() { DB.AutoMigrate(&Forum{}) DB.AutoMigrate(&Thread{}) DB.AutoMigrate(&Comment{}) + DB.AutoMigrate(&Like{}) + DB.AutoMigrate(&Notification{}) } diff --git a/pkg/models/notification.go b/pkg/models/notification.go new file mode 100644 index 0000000..cf34d72 --- /dev/null +++ b/pkg/models/notification.go @@ -0,0 +1,175 @@ +package models + +import ( + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" + "gorm.io/gorm" +) + +// Notification table. +type Notification struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + AboutUserID *uint64 `form:"index"` // the other party of this notification + User User `gorm:"foreignKey:about_user_id"` + Type NotificationType // like, comment, ... + Read bool `gorm:"index"` + TableName string // on which of your tables (photos, comments, ...) + TableID uint64 + Message string // text associated, e.g. copy of comment added + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (n *Notification) Preload() *gorm.DB { + return DB.Preload("User.ProfilePhoto") +} + +type NotificationType string + +const ( + NotificationLike NotificationType = "like" + NotificationComment = "comment" + NotificationCustom = "custom" // custom message pushed +) + +// CreateNotification +func CreateNotification(n *Notification) error { + return DB.Create(n).Error +} + +// GetNotification by ID. +func GetNotification(id uint64) (*Notification, error) { + var n *Notification + result := DB.Model(n).First(&n, id) + return n, result.Error +} + +// RemoveNotification about a table ID, e.g. when removing a like. +func RemoveNotification(tableName string, tableID uint64) error { + result := DB.Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Delete(&Notification{}) + return result.Error +} + +// MarkNotificationsRead sets all a user's notifications to read. +func MarkNotificationsRead(user *User) error { + return DB.Model(&Notification{}).Where( + "user_id = ? AND read IS NOT TRUE", + user.ID, + ).Update("read", true).Error +} + +// CountUnreadNotifications gets the count of unread Notifications for a user. +func CountUnreadNotifications(userID uint64) (int64, error) { + query := DB.Where( + "user_id = ? AND read = ?", + userID, + false, + ) + + var count int64 + result := query.Model(&Notification{}).Count(&count) + return count, result.Error +} + +// PaginateNotifications returns the user's notifications. +func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) { + var ns = []*Notification{} + + query := (&Notification{}).Preload().Where( + "user_id = ?", + user.ID, + ).Order( + pager.Sort, + ) + + query.Model(&Notification{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ns) + return ns, result.Error +} + +// Save a notification. +func (n *Notification) Save() error { + return DB.Save(n).Error +} + +// NotificationBody can store remote tables mapped. +type NotificationBody struct { + PhotoID uint64 + Photo *Photo +} + +type NotificationMap map[uint64]*NotificationBody + +// Get a notification's body from the map. +func (m NotificationMap) Get(id uint64) *NotificationBody { + if body, ok := m[id]; ok { + return body + } + return &NotificationBody{} +} + +// MapNotifications loads associated assets, like Photos, mapped to their notification ID. +func MapNotifications(ns []*Notification) NotificationMap { + var ( + IDs = []uint64{} + result = NotificationMap{} + ) + + // Collect notification IDs. + for _, row := range ns { + IDs = append(IDs, row.ID) + result[row.ID] = &NotificationBody{} + } + + type scanner struct { + PhotoID uint64 + NotificationID uint64 + } + var scan []scanner + + // Load all of these that have photos. + err := DB.Table( + "notifications", + ).Joins( + "JOIN photos ON (notifications.table_name='photos' AND notifications.table_id=photos.id)", + ).Select( + "photos.id AS photo_id", + "notifications.id AS notification_id", + ).Where( + "notifications.id IN ?", + IDs, + ).Scan(&scan) + if err != nil { + log.Error("Couldn't select photo IDs for notifications: %s", err) + } + + // Collect and load all the photos by ID. + var photoIDs = []uint64{} + for _, row := range scan { + // Store the photo ID in the result now. + result[row.NotificationID].PhotoID = row.PhotoID + photoIDs = append(photoIDs, row.PhotoID) + } + + // Load the photos. + if len(photoIDs) > 0 { + if photos, err := GetPhotos(photoIDs); err != nil { + log.Error("Couldn't load photo IDs for notifications: %s", err) + } else { + // Marry them to their notification IDs. + for _, body := range result { + if photo, ok := photos[body.PhotoID]; ok { + body.Photo = photo + } + } + } + } + + return result +} diff --git a/pkg/models/photo.go b/pkg/models/photo.go index 576d841..3d4a10e 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -76,6 +76,21 @@ func GetPhoto(id uint64) (*Photo, error) { return p, result.Error } +// GetPhotos by an array of IDs, mapped to their IDs. +func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) { + var ( + mp = map[uint64]*Photo{} + ps = []*Photo{} + ) + + result := DB.Model(&Photo{}).Where("id IN ?", IDs).Find(&ps) + for _, row := range ps { + mp[row.ID] = row + } + + return mp, result.Error +} + /* PaginateUserPhotos gets a page of photos belonging to a user ID. */ diff --git a/pkg/models/thread.go b/pkg/models/thread.go index 07972c3..f78c346 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -3,6 +3,7 @@ package models import ( "errors" "fmt" + "strings" "time" "git.kirsle.net/apps/gosocial/pkg/log" @@ -14,6 +15,7 @@ type Thread struct { ID uint64 `gorm:"primaryKey"` ForumID uint64 `gorm:"index"` Forum Forum + Pinned bool `gorm:"index"` Explicit bool `gorm:"index"` NoReply bool Title string @@ -36,11 +38,27 @@ func GetThread(id uint64) (*Thread, error) { return t, result.Error } +// GetThreads queries a set of thread IDs and returns them mapped. +func GetThreads(IDs []uint64) (map[uint64]*Thread, error) { + var ( + mt = map[uint64]*Thread{} + ts = []*Thread{} + ) + + result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts) + for _, row := range ts { + mt[row.ID] = row + } + + return mt, 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) { +func CreateThread(user *User, forumID uint64, title, message string, pinned, explicit, noReply bool) (*Thread, error) { thread := &Thread{ ForumID: forumID, Title: title, + Pinned: pinned, Explicit: explicit, NoReply: noReply && user.IsAdmin, Comment: Comment{ @@ -92,16 +110,41 @@ func (t *Thread) DeleteReply(comment *Comment) error { return comment.Delete() } -// PaginateThreads provides a forum index view of posts. -func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { +// PinnedThreads returns all pinned threads in a forum (there should generally be few of these). +func PinnedThreads(forum *Forum) ([]*Thread, error) { var ( ts = []*Thread{} - query = (&Thread{}).Preload() + query = (&Thread{}).Preload().Where( + "forum_id = ? AND pinned IS TRUE", + forum.ID, + ).Order("updated_at desc") ) + result := query.Find(&ts) + return ts, result.Error +} + +// PaginateThreads provides a forum index view of posts, minus pinned posts. +func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { + var ( + ts = []*Thread{} + query = (&Thread{}).Preload() + wheres = []string{} + placeholders = []interface{}{} + ) + + // Always filters. + wheres = append(wheres, "forum_id = ? AND pinned IS NOT TRUE") + placeholders = append(placeholders, forum.ID) + + // If the user hasn't opted in for Explicit, hide NSFW threads. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit IS NOT TRUE") + } + query = query.Where( - "forum_id = ?", - forum.ID, + strings.Join(wheres, " AND "), + placeholders..., ).Order(pager.Sort) query.Model(&Thread{}).Count(&pager.Total) diff --git a/pkg/router/router.go b/pkg/router/router.go index fbfc73a..2d87c5f 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -72,6 +72,8 @@ func New() http.Handler { // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/users/me", api.LoginOK()) + mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes())) + mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) // Static files. mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath)))) diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 16f57bb..9bf0bb3 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -13,6 +13,8 @@ import ( // MergeVars mixes in globally available template variables. The http.Request is optional. func MergeVars(r *http.Request, m map[string]interface{}) { m["Title"] = config.Title + m["BuildHash"] = config.RuntimeBuild + m["BuildDate"] = config.RuntimeBuildDate m["Subtitle"] = config.Subtitle m["YYYY"] = time.Now().Year() @@ -31,9 +33,10 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["SessionImpersonated"] = false // User notification counts for nav bar. - m["NavUnreadMessages"] = 0 // New messages - m["NavFriendRequests"] = 0 // Friend requests - m["NavTotalNotifications"] = 0 // Total of above + m["NavUnreadMessages"] = 0 // New messages + m["NavFriendRequests"] = 0 // Friend requests + m["NavUnreadNotifications"] = 0 // general notifications + m["NavTotalNotifications"] = 0 // Total of above // Admin notification counts for nav bar. m["NavCertificationPhotos"] = 0 // Cert. photos needing approval @@ -50,11 +53,21 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["LoggedIn"] = true m["CurrentUser"] = user + // Get user recent notifications. + /*notifPager := &models.Pagination{ + Page: 1, + PerPage: 10, + } + if notifs, err := models.PaginateNotifications(user, notifPager); err == nil { + m["Notifications"] = notifs + }*/ + // Collect notification counts. var ( // For users - countMessages int64 - countFriendReqs int64 + countMessages int64 + countFriendReqs int64 + countNotifications int64 // For admins countCertPhotos int64 @@ -77,6 +90,14 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) } + // Count other notifications. + if count, err := models.CountUnreadNotifications(user.ID); err == nil { + m["NavUnreadNotifications"] = count + countNotifications = count + } else { + log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) + } + // Are we admin? if user.IsAdmin { // Any pending certification photos or feedback? @@ -90,6 +111,6 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { } // Total count for user notifications. - m["NavTotalNotifications"] = countMessages + countFriendReqs + countCertPhotos + countFeedback + m["NavTotalNotifications"] = countMessages + countFriendReqs + countNotifications + countCertPhotos + countFeedback } } diff --git a/web/static/js/likes.js b/web/static/js/likes.js new file mode 100644 index 0000000..4f5ae26 --- /dev/null +++ b/web/static/js/likes.js @@ -0,0 +1,56 @@ +// Like button handler. +document.addEventListener('DOMContentLoaded', () => { + const red = "has-text-danger"; + let busy = false; + + // Bind to the like buttons. + (document.querySelectorAll(".nonshy-like-button") || []).forEach(node => { + node.addEventListener("click", (e) => { + if (busy) return; + + let $icon = node.querySelector(".icon"), + $label = node.querySelector(".nonshy-likes"), + tableName = node.dataset.tableName, + tableID = node.dataset.tableId, + liking = false; + + // Toggle the color of the heart. + if ($icon.classList.contains(red)) { + $icon.classList.remove(red); + } else { + liking = true; + $icon.classList.add(red); + } + + // Ajax request to backend. + busy = true; + return fetch("/v1/likes", { + method: "POST", + mode: "same-origin", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "name": tableName, // TODO + "id": parseInt(tableID), + "unlike": !liking, + }), + }) + .then((response) => response.json()) + .then((data) => { + let likes = data.data.likes; + if (likes === 0) { + $label.innerHTML = "Like"; + } else { + $label.innerHTML = `Like (${likes})`; + } + }).catch(resp => { + window.alert(resp); + }).finally(() => { + busy = false; + }) + }); + }); +}); \ No newline at end of file diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 4a5b4fc..e55d47f 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -136,18 +136,157 @@ + {{$Root := .}} +
-
+

Notifications

- TBD. +
+
+ {{if gt .NavUnreadNotifications 0}} + {{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}. + {{else}} + No unread notifications. + {{end}} +
+ +
+ + + + {{range .Notifications}} + {{$Body := $Root.NotifMap.Get .ID}} + + + + {{end}} + +
+
+
+ {{if not .Read}} +
+ NEW! +
+ {{end}} + +
+ {{if .User.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+
+
+ {{if eq .Type "like"}} + + + {{.User.Username}} + liked your + {{if eq .TableName "photos"}} + photo. + {{else if eq .TableName "users"}} + profile page. + {{else}} + {{.TableName}}. + {{end}} + + {{else}} + {{.User.Username}} {{.Type}} {{.TableName}} {{.TableID}} + {{end}} +
+ + + {{if $Body.Photo}} +
+ {{or $Body.Photo.Caption "No caption."}} +
+ {{end}} + +
+ + {{SincePrettyCoarse .CreatedAt}} ago + +
+ + + {{if $Body.PhotoID}} +
+ + + {{if $Body.Photo.Caption}} + {{$Body.Photo.Caption}} + {{else}} + No caption. + {{end}} +
+ {{end}} +
+ +
+ + {{if .Pager.HasNext}} + + {{end}}
+ + {{end}} \ No newline at end of file diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 1e046fa..f162679 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -121,16 +121,21 @@ - + {{$Like := .LikeMap.Get .User.ID}} +
+ -
--> +
diff --git a/web/templates/admin/user_actions.html b/web/templates/admin/user_actions.html index 0961aec..d22b2db 100644 --- a/web/templates/admin/user_actions.html +++ b/web/templates/admin/user_actions.html @@ -151,16 +151,4 @@
- - {{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html index 1854c7f..3d80f33 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -108,7 +108,7 @@
+ {{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}} New Thread + {{end}}
@@ -41,6 +45,13 @@ (page {{.Pager.Page}} of {{.Pager.Pages}}).

+{{if .Forum.Privileged}} +
+ + Only moderators may create new threads on this forum. You may be able to reply to threads here. +
+{{end}} +
diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html index 662b952..a8a5aec 100644 --- a/web/templates/forum/index.html +++ b/web/templates/forum/index.html @@ -33,7 +33,10 @@

{{.Category}}

{{if eq (len .Forums) 0}} - There are no forums under this category. + + There are no forums under this category. + {{if not $Root.CurrentUser.Explicit}}Your content filters (non-explicit) may be hiding some forums.{{end}} + {{else}} {{range .Forums}} {{$Stats := $Root.ForumMap.Get .ID}} @@ -63,7 +66,14 @@ {{if .Explicit}} - Explicit + Explicit + + {{end}} + + {{if .Privileged}} + + + Privileged {{end}}
@@ -71,8 +81,18 @@
-

Latest Post

- Hello world! +

Latest Post

+ {{if $Stats.RecentThread}} +
+ {{$Stats.RecentThread.Title}} + + by {{$Stats.RecentThread.Comment.User.Username}} +
+ Last updated {{SincePrettyCoarse $Stats.RecentThread.UpdatedAt}} ago +
+ {{else}} + No posts found. + {{end}}
@@ -97,7 +117,7 @@ {{end}}
- + diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html index f732c8e..3425f50 100644 --- a/web/templates/forum/new_post.html +++ b/web/templates/forum/new_post.html @@ -81,19 +81,31 @@

- {{if not .Thread}} + {{if or (not .Thread) .EditThreadSettings}}
-
Options
+ {{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}} +
+ +
+ {{end}} + {{if or .CurrentUser.Explicit .IsExplicit}}
+ {{end}} {{if .CurrentUser.IsAdmin}}
@@ -101,7 +113,7 @@ + {{if .IsNoReply}}checked{{end}}> No replies allowed
diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 05837f6..7dd921a 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -5,8 +5,10 @@

- - {{.Forum.Title}} + + + {{.Forum.Title}} +

@@ -29,15 +31,45 @@
+ {{if not .Thread.NoReply}} Add Reply + {{end}}
-

{{or .Thread.Title "Untitled Thread"}}

+

+ {{if .Thread.Pinned}}{{end}} + {{or .Thread.Title "Untitled Thread"}} +

+ +
+ {{if .Thread.Pinned}} + + + Pinned + + {{end}} + + {{if .Thread.Explicit}} + + + NSFW + + {{end}} + + {{if .Thread.NoReply}} + + + No Reply + + {{end}} + + Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago +

Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this thread @@ -97,18 +129,21 @@


-
+
{{SincePrettyCoarse .CreatedAt}} ago
+ + + {{if not $Root.Thread.NoReply}} + {{end}} {{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID)}}
@@ -137,17 +173,33 @@
{{end}}
+ + {{if $Root.CurrentUser.IsAdmin}} +
+ + + ID: {{.ID}} + +
+ {{end}}
{{end}} - +{{if .Thread.NoReply}} +
+ + This thread is not accepting any new replies. +
+{{else}} + +{{end}} {{end}} \ No newline at end of file diff --git a/web/templates/partials/like_button.html b/web/templates/partials/like_button.html new file mode 100644 index 0000000..3d74e92 --- /dev/null +++ b/web/templates/partials/like_button.html @@ -0,0 +1,2 @@ +{{define "like-button"}} +{{end}} \ No newline at end of file diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 7b85a81..4073079 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -259,6 +259,22 @@ {{else}}No caption{{end}} {{template "card-body" .}} + + +
+ {{$Like := $Root.LikeMap.Get .ID}} + +