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.
face-detect
Noah Petherbridge 2024-01-06 16:44:05 -08:00
parent 64ce5a9d7c
commit cca449090a
18 changed files with 949 additions and 38 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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)

View 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
}
})
}

View File

@ -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
View 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)
}

View 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)
}
}
}

View File

@ -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 (

View File

@ -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]
}

View File

@ -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()))

View File

@ -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(
`<strong style="color: #0077FF">non</strong>` +
@ -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))

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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">&nbsp;</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}}

View File

@ -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}}

View File

@ -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}}