Forum Search, User Profile Statistics
* Add a Search page to the forums to filter by user ID and find threads and replies matching your search terms, with "quoted phrases" and -negation support. * On user profile pages, add an "Activity" box showing statistics on their forum threads/comments, likes given/received, photo counts, etc. * On the "Newest" and Search page for Forums: show an indicator whenever a post includes an attached photo.
This commit is contained in:
parent
64ce5a9d7c
commit
cca449090a
|
@ -121,9 +121,15 @@ func Profile() http.HandlerFunc {
|
|||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||
"FriendCount": models.CountFriends(user.ID),
|
||||
"ForumThreadCount": models.CountThreadsByUser(user),
|
||||
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
|
||||
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
|
||||
"CommentsReceivedCount": models.CountCommentsReceived(user),
|
||||
"LikesGivenCount": models.CountLikesGiven(user),
|
||||
"LikesReceivedCount": models.CountLikesReceived(user),
|
||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
||||
|
||||
// Details on who likes the photo.
|
||||
// Details on who likes their profile page.
|
||||
"LikeExample": likeExample,
|
||||
"LikeRemainder": likeRemainder,
|
||||
"LikeTableName": "users",
|
||||
|
|
|
@ -61,6 +61,9 @@ func Likes() http.HandlerFunc {
|
|||
req.Referrer = ""
|
||||
}
|
||||
|
||||
// The link to attach to the notification.
|
||||
var linkTo = req.Referrer
|
||||
|
||||
// Is the ID an integer?
|
||||
var tableID uint64
|
||||
if v, err := strconv.Atoi(req.TableID); err != nil {
|
||||
|
@ -138,7 +141,9 @@ func Likes() http.HandlerFunc {
|
|||
if comment, err := models.GetComment(tableID); err == nil {
|
||||
targetUser = &comment.User
|
||||
notificationMessage = comment.Message
|
||||
log.Warn("found user %s", targetUser.Username)
|
||||
|
||||
// Set the notification link to the /go/comment route.
|
||||
linkTo = fmt.Sprintf("/go/comment?id=%d", comment.ID)
|
||||
|
||||
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||
if models.IsBlocking(currentUser.ID, targetUser.ID) {
|
||||
|
@ -189,7 +194,7 @@ func Likes() http.HandlerFunc {
|
|||
TableName: req.TableName,
|
||||
TableID: tableID,
|
||||
Message: notificationMessage,
|
||||
Link: req.Referrer,
|
||||
Link: linkTo,
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create Likes notification: %s", err)
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -35,9 +36,20 @@ func Newest() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Get any photo attachments for these comments.
|
||||
var comments = []*models.Comment{}
|
||||
for _, post := range posts {
|
||||
comments = append(comments, post.Comment, &post.Thread.Comment)
|
||||
}
|
||||
photos, err := models.MapCommentPhotos(comments)
|
||||
if err != nil {
|
||||
log.Error("Couldn't MapCommentPhotos: %s", err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"RecentPosts": posts,
|
||||
"PhotoMap": photos,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
118
pkg/controller/forum/search.go
Normal file
118
pkg/controller/forum/search.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Search the forums.
|
||||
func Search() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/search.html")
|
||||
|
||||
// Whitelist for ordering options.
|
||||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
searchTerm = r.FormValue("q")
|
||||
byUsername = r.FormValue("username")
|
||||
postType = r.FormValue("type")
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
||||
// Sort options.
|
||||
for _, v := range sortWhitelist {
|
||||
if sort == v {
|
||||
sortOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sortOK {
|
||||
sort = sortWhitelist[0]
|
||||
}
|
||||
|
||||
// All comments, or threads only?
|
||||
if postType != "threads" {
|
||||
postType = "all"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Filters: find the user ID.
|
||||
var filterUserID uint64
|
||||
if byUsername != "" {
|
||||
user, err := models.FindUser(byUsername)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't search posts by that username: no such username found.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
filterUserID = user.ID
|
||||
}
|
||||
|
||||
// Parse their search term.
|
||||
var (
|
||||
search = models.ParseSearchString(searchTerm)
|
||||
filters = models.ForumSearchFilters{
|
||||
UserID: filterUserID,
|
||||
ThreadsOnly: postType == "threads",
|
||||
}
|
||||
pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeThreadList,
|
||||
Sort: sort,
|
||||
}
|
||||
)
|
||||
pager.ParsePage(r)
|
||||
|
||||
posts, err := models.SearchForum(currentUser, search, filters, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't search the forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Map the originating threads to each comment.
|
||||
threadMap, err := models.MapForumCommentThreads(posts)
|
||||
if err != nil {
|
||||
log.Error("Couldn't map forum threads to comments: %s", err)
|
||||
}
|
||||
|
||||
// Get any photo attachments for these comments.
|
||||
photos, err := models.MapCommentPhotos(posts)
|
||||
if err != nil {
|
||||
log.Error("Couldn't MapCommentPhotos: %s", err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"Comments": posts,
|
||||
"ThreadMap": threadMap,
|
||||
"PhotoMap": photos,
|
||||
|
||||
"SearchTerm": searchTerm,
|
||||
"ByUsername": byUsername,
|
||||
"Type": postType,
|
||||
"Sort": sort,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -68,6 +68,31 @@ func AddComment(user *User, tableName string, tableID uint64, message string) (*
|
|||
return c, result.Error
|
||||
}
|
||||
|
||||
// CountCommentsByUser returns the total number of comments written by a user.
|
||||
func CountCommentsByUser(user *User, tableName string) int64 {
|
||||
var count int64
|
||||
result := DB.Where(
|
||||
"table_name = ? AND user_id = ?",
|
||||
tableName, user.ID,
|
||||
).Model(&Comment{}).Count(&count)
|
||||
if result.Error != nil {
|
||||
log.Error("CountCommentsByUser(%d): %s", user.ID, result.Error)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// CountCommentsReceived returns the total number of comments received on a user's photos.
|
||||
func CountCommentsReceived(user *User) int64 {
|
||||
var count int64
|
||||
DB.Model(&Comment{}).Joins(
|
||||
"LEFT OUTER JOIN photos ON (comments.table_name = 'photos' AND comments.table_id = photos.id)",
|
||||
).Where(
|
||||
"comments.table_name = 'photos' AND photos.user_id = ?",
|
||||
user.ID,
|
||||
).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// PaginateComments provides a page of comments on something.
|
||||
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
|
||||
var (
|
||||
|
|
183
pkg/models/forum_search.go
Normal file
183
pkg/models/forum_search.go
Normal file
|
@ -0,0 +1,183 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Search represents a parsed search query with inclusions and exclusions.
|
||||
type Search struct {
|
||||
Includes []string
|
||||
Excludes []string
|
||||
}
|
||||
|
||||
// ParseSearchString parses a user search query and supports "quoted phrases" and -negations.
|
||||
func ParseSearchString(input string) *Search {
|
||||
var result = new(Search)
|
||||
|
||||
var (
|
||||
negate bool
|
||||
phrase bool
|
||||
buf = []rune{}
|
||||
commit = func() {
|
||||
var text = strings.TrimSpace(string(buf))
|
||||
if len(text) == 0 {
|
||||
return
|
||||
}
|
||||
if negate {
|
||||
result.Excludes = append(result.Excludes, text)
|
||||
negate = false
|
||||
} else {
|
||||
result.Includes = append(result.Includes, text)
|
||||
}
|
||||
buf = []rune{}
|
||||
}
|
||||
)
|
||||
|
||||
for _, char := range input {
|
||||
// Inside a quoted phrase?
|
||||
if phrase {
|
||||
if char == '"' {
|
||||
// End of quoted phrase.
|
||||
commit()
|
||||
phrase = false
|
||||
continue
|
||||
}
|
||||
buf = append(buf, char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Start a quoted phrase?
|
||||
if char == '"' {
|
||||
phrase = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Negation indicator?
|
||||
if len(buf) == 0 && char == '-' {
|
||||
negate = true
|
||||
continue
|
||||
}
|
||||
|
||||
// End of a word?
|
||||
if char == ' ' {
|
||||
commit()
|
||||
continue
|
||||
}
|
||||
|
||||
buf = append(buf, char)
|
||||
}
|
||||
|
||||
// Last word?
|
||||
commit()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ForumSearchFilters apply additional filters specific to searching the forum.
|
||||
type ForumSearchFilters struct {
|
||||
UserID uint64
|
||||
ThreadsOnly bool
|
||||
}
|
||||
|
||||
// SearchForum searches the forum.
|
||||
func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) {
|
||||
var (
|
||||
coms = []*Comment{}
|
||||
query = (&Comment{}).Preload()
|
||||
blockedUserIDs = BlockedUserIDs(user)
|
||||
wheres = []string{"table_name = 'threads'"}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
// Hide explicit forum if user hasn't opted into it.
|
||||
if !user.Explicit && !user.IsAdmin {
|
||||
wheres = append(wheres, "forums.explicit = false")
|
||||
}
|
||||
|
||||
// Circle membership.
|
||||
if !user.IsInnerCircle() {
|
||||
wheres = append(wheres, "forums.inner_circle is not true")
|
||||
}
|
||||
|
||||
// Blocked users?
|
||||
if len(blockedUserIDs) > 0 {
|
||||
wheres = append(wheres, "comments.user_id NOT IN ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
}
|
||||
|
||||
// Don't show comments from banned or disabled accounts.
|
||||
wheres = append(wheres, `
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = comments.user_id
|
||||
AND users.status = 'active'
|
||||
)
|
||||
`)
|
||||
|
||||
// Apply their search terms.
|
||||
if filters.UserID > 0 {
|
||||
wheres = append(wheres, "comments.user_id = ?")
|
||||
placeholders = append(placeholders, filters.UserID)
|
||||
}
|
||||
if filters.ThreadsOnly {
|
||||
wheres = append(wheres, "comments.id = threads.comment_id")
|
||||
}
|
||||
for _, term := range search.Includes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
wheres = append(wheres, "(comments.message ILIKE ? OR threads.title ILIKE ?)")
|
||||
placeholders = append(placeholders, ilike, ilike)
|
||||
}
|
||||
for _, term := range search.Excludes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
wheres = append(wheres, "(comments.message NOT ILIKE ? AND threads.title NOT ILIKE ?)")
|
||||
placeholders = append(placeholders, ilike, ilike)
|
||||
}
|
||||
|
||||
query = query.Joins(
|
||||
"JOIN threads ON (comments.table_id = threads.id)",
|
||||
).Joins(
|
||||
"JOIN forums ON (threads.forum_id = forums.id)",
|
||||
).Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Order(pager.Sort)
|
||||
|
||||
query.Model(&Comment{}).Count(&pager.Total)
|
||||
res := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&coms)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
|
||||
// Inject user relationships into all comments now.
|
||||
SetUserRelationshipsInComments(user, coms)
|
||||
|
||||
return coms, nil
|
||||
}
|
||||
|
||||
// Equals inspects if the search result matches, to help with unit tests.
|
||||
func (s Search) Equals(other Search) bool {
|
||||
if len(s.Includes) != len(other.Includes) || len(s.Excludes) != len(other.Excludes) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i, v := range s.Includes {
|
||||
if other.Includes[i] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for i, v := range s.Excludes {
|
||||
if other.Excludes[i] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s Search) String() string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
130
pkg/models/forum_search_test.go
Normal file
130
pkg/models/forum_search_test.go
Normal file
|
@ -0,0 +1,130 @@
|
|||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
)
|
||||
|
||||
func TestSearchPars(t *testing.T) {
|
||||
var table = []struct {
|
||||
Query string
|
||||
Expect models.Search
|
||||
}{
|
||||
{
|
||||
Query: "hello world",
|
||||
Expect: models.Search{
|
||||
Includes: []string{"hello", "world"},
|
||||
Excludes: []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: "hello world -mars",
|
||||
Expect: models.Search{
|
||||
Includes: []string{"hello", "world"},
|
||||
Excludes: []string{"mars"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `"hello world" -mars`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"hello world"},
|
||||
Excludes: []string{"mars"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `the "quick brown" fox -jumps -"over the" lazy -dog`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"the", "quick brown", "fox", "lazy"},
|
||||
Excludes: []string{"jumps", "over the", "dog"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `how now brown cow`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"how", "now", "brown", "cow"},
|
||||
Excludes: []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `"this exact phrase"`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"this exact phrase"},
|
||||
Excludes: []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `-"not this exact phrase"`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{},
|
||||
Excludes: []string{"not this exact phrase"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `something "bust"ed" -this "-way" comes `,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"something", "bust", "ed -this"},
|
||||
Excludes: []string{"way comes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: "",
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: `"`,
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: "-",
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: "-1",
|
||||
Expect: models.Search{
|
||||
Excludes: []string{"1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `""`,
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: `"""`,
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: `""""`,
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: `--`,
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: `---`,
|
||||
Expect: models.Search{},
|
||||
},
|
||||
{
|
||||
Query: `"chat room" -spam -naked`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"chat room"},
|
||||
Excludes: []string{"spam", "naked"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Query: `yes1 yes2 -no1 -no2 -no3 yes3 -no4 -no5 yes4 -no6`,
|
||||
Expect: models.Search{
|
||||
Includes: []string{"yes1", "yes2", "yes3", "yes4"},
|
||||
Excludes: []string{"no1", "no2", "no3", "no4", "no5", "no6"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range table {
|
||||
actual := models.ParseSearchString(test.Query)
|
||||
if !actual.Equals(test.Expect) {
|
||||
t.Errorf("Test #%d failed: search string `%s` expected %s but got %s", i, test.Query, test.Expect, actual)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -64,6 +64,36 @@ func CountLikes(tableName string, tableID uint64) int64 {
|
|||
return count
|
||||
}
|
||||
|
||||
// CountLikesGiven by a user.
|
||||
func CountLikesGiven(user *User) int64 {
|
||||
var count int64
|
||||
DB.Model(&Like{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountLikesReceived by a user.
|
||||
func CountLikesReceived(user *User) int64 {
|
||||
var count int64
|
||||
DB.Model(&Like{}).Joins(
|
||||
"LEFT OUTER JOIN photos ON (likes.table_name = 'photos' AND likes.table_id = photos.id)",
|
||||
).Joins(
|
||||
"LEFT OUTER JOIN users ON (likes.table_name = 'users' AND likes.table_id = users.id)",
|
||||
).Joins(
|
||||
"LEFT OUTER JOIN comments ON (likes.table_name = 'comments' AND likes.table_id = comments.id)",
|
||||
).Where(`
|
||||
(likes.table_name = 'photos' AND photos.user_id = ?)
|
||||
OR
|
||||
(likes.table_name = 'users' AND likes.table_id = ?)
|
||||
OR
|
||||
(likes.table_name = 'comments' AND comments.user_id = ?)`,
|
||||
user.ID, user.ID, user.ID,
|
||||
).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// WhoLikes something. Returns the first couple users and a count of the remainder.
|
||||
func WhoLikes(currentUser *User, tableName string, tableID uint64) ([]*User, int64, error) {
|
||||
var (
|
||||
|
|
|
@ -208,6 +208,21 @@ func PinnedThreads(forum *Forum) ([]*Thread, error) {
|
|||
return ts, result.Error
|
||||
}
|
||||
|
||||
// CountThreadsByUser returns the total number of forum threads started by a user.
|
||||
func CountThreadsByUser(user *User) int64 {
|
||||
var count int64
|
||||
result := DB.Joins(
|
||||
"JOIN comments ON (comments.id = threads.comment_id)",
|
||||
).Where(
|
||||
"comments.user_id = ?",
|
||||
user.ID,
|
||||
).Model(&Thread{}).Count(&count)
|
||||
if result.Error != nil {
|
||||
log.Error("CountThreadsByUser(%d): %s", user.ID, result.Error)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// PaginateThreads provides a forum index view of posts, minus pinned posts.
|
||||
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
|
||||
var (
|
||||
|
@ -369,3 +384,41 @@ func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ForumCommentThreadMap map[uint64]*Thread
|
||||
|
||||
// MapForumCommentThreads maps a set of comments to the forum thread they are posted on.
|
||||
func MapForumCommentThreads(comments []*Comment) (ForumCommentThreadMap, error) {
|
||||
var (
|
||||
result = ForumCommentThreadMap{}
|
||||
threadIDs = []uint64{}
|
||||
)
|
||||
|
||||
for _, com := range comments {
|
||||
if com.TableName != "threads" {
|
||||
continue
|
||||
}
|
||||
threadIDs = append(threadIDs, com.TableID)
|
||||
}
|
||||
|
||||
if len(threadIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
threads, err := GetThreads(threadIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, com := range comments {
|
||||
if thr, ok := threads[com.TableID]; ok {
|
||||
result[com.ID] = thr
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m ForumCommentThreadMap) Get(commentID uint64) *Thread {
|
||||
return m[commentID]
|
||||
}
|
||||
|
|
|
@ -85,6 +85,7 @@ func New() http.Handler {
|
|||
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
||||
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
||||
mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest()))
|
||||
mux.Handle("/forum/search", middleware.CertRequired(forum.Search()))
|
||||
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
|
||||
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
// Generics
|
||||
|
@ -30,6 +32,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
"InputCSRF": InputCSRF(r),
|
||||
"SincePrettyCoarse": SincePrettyCoarse(),
|
||||
"FormatNumberShort": FormatNumberShort(),
|
||||
"FormatNumberCommas": FormatNumberCommas(),
|
||||
"ComputeAge": utility.Age,
|
||||
"Split": strings.Split,
|
||||
"ToMarkdown": ToMarkdown,
|
||||
|
@ -134,6 +137,31 @@ func FormatNumberShort() func(v interface{}) template.HTML {
|
|||
}
|
||||
}
|
||||
|
||||
// FormatNumberCommas will pretty print a long number by adding commas.
|
||||
func FormatNumberCommas() func(v interface{}) template.HTML {
|
||||
return func(v interface{}) template.HTML {
|
||||
var number int64
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
number = int64(t)
|
||||
case int64:
|
||||
number = int64(t)
|
||||
case uint:
|
||||
number = int64(t)
|
||||
case uint64:
|
||||
number = int64(t)
|
||||
case float32:
|
||||
number = int64(t)
|
||||
case float64:
|
||||
number = int64(t)
|
||||
default:
|
||||
return template.HTML("#INVALID#")
|
||||
}
|
||||
p := message.NewPrinter(language.English)
|
||||
return template.HTML(p.Sprintf("%d", number))
|
||||
}
|
||||
}
|
||||
|
||||
// ToMarkdown renders input text as Markdown.
|
||||
func ToMarkdown(input string) template.HTML {
|
||||
return template.HTML(markdown.Render(input))
|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
<span class="icon">
|
||||
<i class="fa-solid fa-certificate has-text-success"></i>
|
||||
</span>
|
||||
<strong class="has-text-success">Certified!</strong>
|
||||
<strong class="has-text-info">Certified!</strong>
|
||||
|
||||
<!-- Admin link to see it -->
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
|
@ -448,6 +448,100 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<header class="card-header has-background-info">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-chart-line pr-2"></i>
|
||||
Activity
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<strong class="has-text-info">Statistics</strong>
|
||||
<table class="table is-fullwidth" style="font-size: small">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<strong class="is-size-7">Photos shared:</label>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/photo/u/{{.User.Username}}" class="has-text-info">
|
||||
<i class="fa fa-image mr-1"></i>
|
||||
{{FormatNumberCommas .PhotoCount}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="is-size-7">Forum threads written:</label>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/forum/search?username={{.User.Username}}&type=threads" class="has-text-info">
|
||||
<i class="fa fa-comment mr-1"></i>
|
||||
{{FormatNumberCommas .ForumThreadCount}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="is-size-7">Forum comments:</label>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/forum/search?username={{.User.Username}}" class="has-text-info">
|
||||
<i class="fa fa-comments mr-1"></i>
|
||||
{{FormatNumberCommas .ForumReplyCount}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<strong class="has-text-info">Social</strong>
|
||||
<table class="table is-fullwidth" style="font-size: small">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<strong class="is-size-7">Friends:</label>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/friends/u/{{.User.Username}}" class="has-text-info">
|
||||
<i class="fa fa-user-group mr-1"></i> {{FormatNumberCommas .FriendCount}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="is-size-7">Photo comments written:</label>
|
||||
</td>
|
||||
<td>
|
||||
<i class="fa fa-comments mr-1"></i> {{FormatNumberCommas .PhotoCommentCount}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="is-size-7">Photo comments received:</label>
|
||||
</td>
|
||||
<td>
|
||||
<i class="fa fa-comments mr-1"></i> {{FormatNumberCommas .CommentsReceivedCount}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="is-size-7">Likes given:</label>
|
||||
</td>
|
||||
<td>
|
||||
<i class="fa fa-heart mr-1"></i> {{FormatNumberCommas .LikesGivenCount}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="is-size-7">Likes received:</label>
|
||||
</td>
|
||||
<td>
|
||||
<i class="fa fa-heart mr-1"></i> {{FormatNumberCommas .LikesReceivedCount}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Actions-->
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<div class="card block">
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
Found {{.Pager.Total}} user{{Pluralize64 .Pager.Total}}
|
||||
Found {{FormatNumberCommas .Pager.Total}} user{{Pluralize64 .Pager.Total}}
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
</div>
|
||||
</div>
|
||||
|
@ -210,7 +210,7 @@
|
|||
<div class="column px-1">
|
||||
<div class="field">
|
||||
<label class="label" for="sort">Sort by:</label>
|
||||
<div class="select is-full-width">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="sort" name="sort">
|
||||
<option value="last_login_at desc"{{if eq .Sort "last_login_at desc"}} selected{{end}}>Last login</option>
|
||||
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Signup date</option>
|
||||
|
|
|
@ -42,6 +42,11 @@
|
|||
Newest
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/forum/search">
|
||||
<i class="fa fa-search mr-2"></i> Search
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -29,12 +29,17 @@
|
|||
Newest
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/forum/search">
|
||||
<i class="fa fa-search mr-2"></i> Search
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
Found {{.Pager.Total}} posts (page {{.Pager.Page}} of {{.Pager.Pages}})
|
||||
Found {{FormatNumberCommas .Pager.Total}} posts (page {{.Pager.Page}} of {{.Pager.Pages}})
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
@ -82,6 +87,14 @@
|
|||
{{TrimEllipses .Thread.Comment.Message 256}}
|
||||
</a>
|
||||
|
||||
{{$Photos := $Root.PhotoMap.Get .Thread.Comment.ID}}
|
||||
{{if $Photos}}
|
||||
<div class="is-size-7 mt-2 has-text-success">
|
||||
<i class="fa fa-image pr-1"></i>
|
||||
Photo attachment
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="is-size-7 is-italic is-grey pt-1">
|
||||
{{if eq .Comment.ID .Thread.CommentID}}new {{end}}thread by {{.Thread.Comment.User.Username}}
|
||||
in <a href="/f/{{.Forum.Fragment}}">
|
||||
|
@ -135,6 +148,14 @@
|
|||
{{TrimEllipses .Comment.Message 256}}
|
||||
</a>
|
||||
|
||||
{{$Photos := $Root.PhotoMap.Get .Comment.ID}}
|
||||
{{if $Photos}}
|
||||
<div class="is-size-7 mt-2 has-text-success">
|
||||
<i class="fa fa-image pr-1"></i>
|
||||
Photo attachment
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="is-size-7 is-italic is-grey mt-1">
|
||||
{{SincePrettyCoarse .Comment.UpdatedAt}} ago
|
||||
</div>
|
||||
|
|
195
web/templates/forum/search.html
Normal file
195
web/templates/forum/search.html
Normal file
|
@ -0,0 +1,195 @@
|
|||
{{define "title"}}Newest Posts - Forums{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-success">
|
||||
<div class="hero-body">
|
||||
<h1 class="title">
|
||||
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||
<span>Forums</span>
|
||||
</h1>
|
||||
<h2 class="subtitle">
|
||||
<i class="fa fa-search mr-1"></i> Search
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{$Root := .}}
|
||||
|
||||
<div class="block p-4 mb-0">
|
||||
<div class="tabs is-boxed">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/forum">
|
||||
Categories
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/forum/newest">
|
||||
Newest
|
||||
</a>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<a href="/forum/search">
|
||||
<i class="fa fa-search mr-2"></i> Search
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search fields -->
|
||||
<div class="p-4">
|
||||
|
||||
<form action="{{.Request.URL.Path}}" method="GET">
|
||||
|
||||
<div class="card nonshy-collapsible-mobile">
|
||||
<header class="card-header has-background-link-light">
|
||||
<p class="card-header-title">
|
||||
Search Filters
|
||||
</p>
|
||||
<button class="card-header-icon" type="button">
|
||||
<span class="icon">
|
||||
<i class="fa fa-angle-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
|
||||
<div class="column pr-1">
|
||||
<div class="field">
|
||||
<label class="label" for="q">Search terms:</label>
|
||||
<input type="text" class="input"
|
||||
name="q" id="q"
|
||||
autocomplete="off"
|
||||
value="{{.SearchTerm}}">
|
||||
<p class="help">
|
||||
Tip: you can <span class="has-text-success">"quote exact phrases"</span> and
|
||||
<span class="has-text-success">-exclude</span> words (or
|
||||
<span class="has-text-success">-"exclude phrases"</span>) from your search.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column px-1">
|
||||
<div class="field">
|
||||
<label class="label" for="username">Written by username:</label>
|
||||
<input type="text" class="input"
|
||||
name="username" id="username"
|
||||
autocomplete="off"
|
||||
value="{{.ByUsername}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="columns">
|
||||
|
||||
<div class="column px-1">
|
||||
<div class="field">
|
||||
<label class="label" for="type">Show:</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select id="type" name="type">
|
||||
<option value="all"{{if eq .Type "all"}} selected{{end}}>All threads and replies</option>
|
||||
<option value="threads"{{if eq .Type "threads"}} selected{{end}}>Threads only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column px-1">
|
||||
<div class="field">
|
||||
<label class="label" for="sort">Sort by:</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select id="sort" name="sort">
|
||||
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Newest</option>
|
||||
<option value="created_at asc"{{if eq .Sort "created_at asc"}} selected{{end}}>Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow pl-1 has-text-right">
|
||||
<label class="label"> </label>
|
||||
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
|
||||
<button type="submit" class="button is-success">
|
||||
<span>Search</span>
|
||||
<span class="icon"><i class="fa fa-search"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
Found {{FormatNumberCommas .Pager.Total}} posts (page {{.Pager.Page}} of {{.Pager.Pages}})
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{{SimplePager .Pager}}
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{{range .Comments}}
|
||||
{{$Thread := $Root.ThreadMap.Get .ID}}
|
||||
<div class="card block has-background-link-light">
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
<div class="column is-narrow has-text-centered pt-0 pb-1">
|
||||
<a href="/u/{{.User.Username}}">
|
||||
{{template "avatar-96x96" .User}}
|
||||
<div>
|
||||
<a href="/u/{{.User.Username}}" class="is-size-7">{{or .User.Username "[unavailable]"}}</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column py-0">
|
||||
|
||||
<div class="columns is-multiline is-gapless mb-0">
|
||||
<div class="column is-narrow">
|
||||
<a href="/go/comment?id={{.ID}}" class="has-text-grey">
|
||||
<h2 class="is-size-5 py-0 is-italic has-text-grey">
|
||||
On thread:
|
||||
{{if $Thread}}
|
||||
{{$Thread.Title}}
|
||||
{{else}}
|
||||
[unavailable]
|
||||
{{end}}
|
||||
</h2>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<a href="/go/comment?id={{.ID}}" class="has-text-dark">
|
||||
{{TrimEllipses .Message 512}}
|
||||
</a>
|
||||
|
||||
{{$Photos := $Root.PhotoMap.Get .ID}}
|
||||
{{if $Photos}}
|
||||
<div class="is-size-7 mt-2 has-text-success">
|
||||
<i class="fa fa-image pr-1"></i>
|
||||
Photo attachment
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="is-size-7 is-italic is-grey mt-1">
|
||||
{{SincePrettyCoarse .UpdatedAt}} ago
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{{SimplePager .Pager}}
|
||||
</div>
|
||||
|
||||
{{end}}
|
|
@ -33,16 +33,6 @@ section.hero {
|
|||
color: {{$cardTitleFG}} !important;
|
||||
}
|
||||
|
||||
.container div.card-content a {
|
||||
color: {{$cardLinkFG}};
|
||||
}
|
||||
.menu-list a {
|
||||
color: inherit !important;
|
||||
}
|
||||
.container div.card-content a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
{{if eq $cardLightness "light"}}
|
||||
div.box, .container div.card-content, table.table, table.table strong {
|
||||
background-color: #fff;
|
||||
|
@ -55,7 +45,7 @@ section.hero {
|
|||
|
||||
/* More text color overrides (h1's etc. look light on prefers-dark color schemes) */
|
||||
.container div.card-content .content * {
|
||||
color: #4a4a4a !important;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
/* Slightly less light on dark theme devices */
|
||||
|
@ -77,7 +67,7 @@ section.hero {
|
|||
|
||||
/* More text color overrides (h1's etc. look dark on prefers-light color schemes) */
|
||||
.container div.card-content .content * {
|
||||
color: #f5f5f5 !important;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Even darker on dark theme devices */
|
||||
|
@ -88,6 +78,21 @@ section.hero {
|
|||
}
|
||||
}
|
||||
{{end}}
|
||||
|
||||
.container div.card-content a {
|
||||
color: {{$cardLinkFG}} !important;
|
||||
}
|
||||
.card-content .menu-list li a {
|
||||
color: inherit !important;
|
||||
}
|
||||
.container div.card-content a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Override link color for the Activity box, so users don't set black-on-black and wreck the links */
|
||||
.card-content table a.has-text-info {
|
||||
color: #3e8ed0 !important;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -198,7 +198,7 @@
|
|||
<div class="level-item">
|
||||
{{if .Pager.Total}}
|
||||
<span>
|
||||
Found <strong>{{.Pager.Total}}</strong> photo{{Pluralize64 .Pager.Total}} (page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
Found <strong>{{FormatNumberCommas .Pager.Total}}</strong> photo{{Pluralize64 .Pager.Total}} (page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
{{if .ExplicitCount}}
|
||||
{{.ExplicitCount}} explicit photo{{Pluralize64 .ExplicitCount}} hidden per your <a href="/settings#prefs">settings</a>.
|
||||
{{end}}
|
||||
|
|
Loading…
Reference in New Issue
Block a user