From cca449090a596921b7ce56ac04755935bf5ccaf7 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 6 Jan 2024 16:44:05 -0800 Subject: [PATCH] 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. --- pkg/controller/account/profile.go | 24 ++-- pkg/controller/api/likes.go | 9 +- pkg/controller/forum/newest.go | 12 ++ pkg/controller/forum/search.go | 118 +++++++++++++++++ pkg/models/comment.go | 25 ++++ pkg/models/forum_search.go | 183 +++++++++++++++++++++++++++ pkg/models/forum_search_test.go | 130 +++++++++++++++++++ pkg/models/like.go | 30 +++++ pkg/models/thread.go | 53 ++++++++ pkg/router/router.go | 1 + pkg/templates/template_funcs.go | 48 +++++-- web/templates/account/profile.html | 96 +++++++++++++- web/templates/account/search.html | 4 +- web/templates/forum/index.html | 5 + web/templates/forum/newest.html | 23 +++- web/templates/forum/search.html | 195 +++++++++++++++++++++++++++++ web/templates/partials/themes.html | 29 +++-- web/templates/photo/gallery.html | 2 +- 18 files changed, 949 insertions(+), 38 deletions(-) create mode 100644 pkg/controller/forum/search.go create mode 100644 pkg/models/forum_search.go create mode 100644 pkg/models/forum_search_test.go create mode 100644 web/templates/forum/search.html diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 2707412..94508f8 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -114,16 +114,22 @@ func Profile() http.HandlerFunc { } vars := map[string]interface{}{ - "User": user, - "LikeMap": likeMap, - "IsFriend": isFriend, - "IsPrivate": isPrivate, - "PhotoCount": models.CountPhotosICanSee(user, currentUser), - "NoteCount": models.CountNotesAboutUser(currentUser, user), - "FriendCount": models.CountFriends(user.ID), - "OnChat": worker.GetChatStatistics().IsOnline(user.Username), + "User": user, + "LikeMap": likeMap, + "IsFriend": isFriend, + "IsPrivate": isPrivate, + "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", diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go index b58d6b7..3a8ddc3 100644 --- a/pkg/controller/api/likes.go +++ b/pkg/controller/api/likes.go @@ -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) diff --git a/pkg/controller/forum/newest.go b/pkg/controller/forum/newest.go index 3ed5875..6ff075f 100644 --- a/pkg/controller/forum/newest.go +++ b/pkg/controller/forum/newest.go @@ -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) diff --git a/pkg/controller/forum/search.go b/pkg/controller/forum/search.go new file mode 100644 index 0000000..0a884c0 --- /dev/null +++ b/pkg/controller/forum/search.go @@ -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 + } + }) +} diff --git a/pkg/models/comment.go b/pkg/models/comment.go index f9812d1..31d042a 100644 --- a/pkg/models/comment.go +++ b/pkg/models/comment.go @@ -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 ( diff --git a/pkg/models/forum_search.go b/pkg/models/forum_search.go new file mode 100644 index 0000000..d2d0ff7 --- /dev/null +++ b/pkg/models/forum_search.go @@ -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) +} diff --git a/pkg/models/forum_search_test.go b/pkg/models/forum_search_test.go new file mode 100644 index 0000000..e4cea8a --- /dev/null +++ b/pkg/models/forum_search_test.go @@ -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) + } + } +} diff --git a/pkg/models/like.go b/pkg/models/like.go index 95510f1..021c3d8 100644 --- a/pkg/models/like.go +++ b/pkg/models/like.go @@ -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 ( diff --git a/pkg/models/thread.go b/pkg/models/thread.go index 4a16aca..3959b93 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -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] +} diff --git a/pkg/router/router.go b/pkg/router/router.go index d21c3f3..acd8231 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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())) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index a34a037..5dd0cdb 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -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 @@ -27,16 +29,17 @@ type Number interface { // TemplateFuncs available to all pages. func TemplateFuncs(r *http.Request) template.FuncMap { return template.FuncMap{ - "InputCSRF": InputCSRF(r), - "SincePrettyCoarse": SincePrettyCoarse(), - "FormatNumberShort": FormatNumberShort(), - "ComputeAge": utility.Age, - "Split": strings.Split, - "ToMarkdown": ToMarkdown, - "ToJSON": ToJSON, - "ToHTML": ToHTML, - "PhotoURL": photo.URLPath, - "Now": time.Now, + "InputCSRF": InputCSRF(r), + "SincePrettyCoarse": SincePrettyCoarse(), + "FormatNumberShort": FormatNumberShort(), + "FormatNumberCommas": FormatNumberCommas(), + "ComputeAge": utility.Age, + "Split": strings.Split, + "ToMarkdown": ToMarkdown, + "ToJSON": ToJSON, + "ToHTML": ToHTML, + "PhotoURL": photo.URLPath, + "Now": time.Now, "PrettyTitle": func() template.HTML { return template.HTML(fmt.Sprintf( `non` + @@ -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)) diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 813b12f..8f80ecd 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -82,7 +82,7 @@ - Certified! + Certified! {{if .CurrentUser.IsAdmin}} @@ -448,6 +448,100 @@ +
+
+

+ + Activity +

+
+ +
+ Statistics + + + + + + + + + + + + + +
+ Photos shared: + + + + {{FormatNumberCommas .PhotoCount}} + +
+ Forum threads written: + + + + {{FormatNumberCommas .ForumThreadCount}} + +
+ Forum comments: + + + + {{FormatNumberCommas .ForumReplyCount}} + +
+ + Social + + + + + + + + + + + + + + + + + + + + + +
+ Friends: + + + {{FormatNumberCommas .FriendCount}} + +
+ Photo comments written: + + {{FormatNumberCommas .PhotoCommentCount}} +
+ Photo comments received: + + {{FormatNumberCommas .CommentsReceivedCount}} +
+ Likes given: + + {{FormatNumberCommas .LikesGivenCount}} +
+ Likes received: + + {{FormatNumberCommas .LikesReceivedCount}} +
+
+
+ {{if .CurrentUser.IsAdmin}}
diff --git a/web/templates/account/search.html b/web/templates/account/search.html index c0bdc32..3549eab 100644 --- a/web/templates/account/search.html +++ b/web/templates/account/search.html @@ -24,7 +24,7 @@
- Found {{.Pager.Total}} user{{Pluralize64 .Pager.Total}} + Found {{FormatNumberCommas .Pager.Total}} user{{Pluralize64 .Pager.Total}} (page {{.Pager.Page}} of {{.Pager.Pages}}).
@@ -210,7 +210,7 @@
-
+
+

+ Tip: you can "quote exact phrases" and + -exclude words (or + -"exclude phrases") from your search. +

+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+ + Reset + +
+
+
+
+ + + + + +
+ Found {{FormatNumberCommas .Pager.Total}} posts (page {{.Pager.Page}} of {{.Pager.Pages}}) +
+ +
+ {{SimplePager .Pager}} +
+ +
+ {{range .Comments}} + {{$Thread := $Root.ThreadMap.Get .ID}} + + {{end}} +
+ +
+ {{SimplePager .Pager}} +
+ +{{end}} diff --git a/web/templates/partials/themes.html b/web/templates/partials/themes.html index 8b866f8..0299c71 100644 --- a/web/templates/partials/themes.html +++ b/web/templates/partials/themes.html @@ -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; + } {{end}} diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 37cb219..df880c1 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -198,7 +198,7 @@
{{if .Pager.Total}} - Found {{.Pager.Total}} photo{{Pluralize64 .Pager.Total}} (page {{.Pager.Page}} of {{.Pager.Pages}}). + Found {{FormatNumberCommas .Pager.Total}} photo{{Pluralize64 .Pager.Total}} (page {{.Pager.Page}} of {{.Pager.Pages}}). {{if .ExplicitCount}} {{.ExplicitCount}} explicit photo{{Pluralize64 .ExplicitCount}} hidden per your settings. {{end}}