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
|
@ -114,16 +114,22 @@ func Profile() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := map[string]interface{}{
|
vars := map[string]interface{}{
|
||||||
"User": user,
|
"User": user,
|
||||||
"LikeMap": likeMap,
|
"LikeMap": likeMap,
|
||||||
"IsFriend": isFriend,
|
"IsFriend": isFriend,
|
||||||
"IsPrivate": isPrivate,
|
"IsPrivate": isPrivate,
|
||||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||||
"FriendCount": models.CountFriends(user.ID),
|
"FriendCount": models.CountFriends(user.ID),
|
||||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
"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,
|
"LikeExample": likeExample,
|
||||||
"LikeRemainder": likeRemainder,
|
"LikeRemainder": likeRemainder,
|
||||||
"LikeTableName": "users",
|
"LikeTableName": "users",
|
||||||
|
|
|
@ -61,6 +61,9 @@ func Likes() http.HandlerFunc {
|
||||||
req.Referrer = ""
|
req.Referrer = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The link to attach to the notification.
|
||||||
|
var linkTo = req.Referrer
|
||||||
|
|
||||||
// Is the ID an integer?
|
// Is the ID an integer?
|
||||||
var tableID uint64
|
var tableID uint64
|
||||||
if v, err := strconv.Atoi(req.TableID); err != nil {
|
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 {
|
if comment, err := models.GetComment(tableID); err == nil {
|
||||||
targetUser = &comment.User
|
targetUser = &comment.User
|
||||||
notificationMessage = comment.Message
|
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.
|
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||||
if models.IsBlocking(currentUser.ID, targetUser.ID) {
|
if models.IsBlocking(currentUser.ID, targetUser.ID) {
|
||||||
|
@ -189,7 +194,7 @@ func Likes() http.HandlerFunc {
|
||||||
TableName: req.TableName,
|
TableName: req.TableName,
|
||||||
TableID: tableID,
|
TableID: tableID,
|
||||||
Message: notificationMessage,
|
Message: notificationMessage,
|
||||||
Link: req.Referrer,
|
Link: linkTo,
|
||||||
}
|
}
|
||||||
if err := models.CreateNotification(notif); err != nil {
|
if err := models.CreateNotification(notif); err != nil {
|
||||||
log.Error("Couldn't create Likes notification: %s", err)
|
log.Error("Couldn't create Likes notification: %s", err)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"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/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -35,9 +36,20 @@ func Newest() http.HandlerFunc {
|
||||||
return
|
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{}{
|
var vars = map[string]interface{}{
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"RecentPosts": posts,
|
"RecentPosts": posts,
|
||||||
|
"PhotoMap": photos,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
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
|
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.
|
// PaginateComments provides a page of comments on something.
|
||||||
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
|
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
|
||||||
var (
|
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
|
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.
|
// WhoLikes something. Returns the first couple users and a count of the remainder.
|
||||||
func WhoLikes(currentUser *User, tableName string, tableID uint64) ([]*User, int64, error) {
|
func WhoLikes(currentUser *User, tableName string, tableID uint64) ([]*User, int64, error) {
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -208,6 +208,21 @@ func PinnedThreads(forum *Forum) ([]*Thread, error) {
|
||||||
return ts, result.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.
|
// PaginateThreads provides a forum index view of posts, minus pinned posts.
|
||||||
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
|
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
|
||||||
var (
|
var (
|
||||||
|
@ -369,3 +384,41 @@ func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics {
|
||||||
}
|
}
|
||||||
return nil
|
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/post", middleware.CertRequired(forum.NewPost()))
|
||||||
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
||||||
mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest()))
|
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("/f/", middleware.CertRequired(forum.Forum()))
|
||||||
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
|
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/photo"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generics
|
// Generics
|
||||||
|
@ -27,16 +29,17 @@ type Number interface {
|
||||||
// TemplateFuncs available to all pages.
|
// TemplateFuncs available to all pages.
|
||||||
func TemplateFuncs(r *http.Request) template.FuncMap {
|
func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"InputCSRF": InputCSRF(r),
|
"InputCSRF": InputCSRF(r),
|
||||||
"SincePrettyCoarse": SincePrettyCoarse(),
|
"SincePrettyCoarse": SincePrettyCoarse(),
|
||||||
"FormatNumberShort": FormatNumberShort(),
|
"FormatNumberShort": FormatNumberShort(),
|
||||||
"ComputeAge": utility.Age,
|
"FormatNumberCommas": FormatNumberCommas(),
|
||||||
"Split": strings.Split,
|
"ComputeAge": utility.Age,
|
||||||
"ToMarkdown": ToMarkdown,
|
"Split": strings.Split,
|
||||||
"ToJSON": ToJSON,
|
"ToMarkdown": ToMarkdown,
|
||||||
"ToHTML": ToHTML,
|
"ToJSON": ToJSON,
|
||||||
"PhotoURL": photo.URLPath,
|
"ToHTML": ToHTML,
|
||||||
"Now": time.Now,
|
"PhotoURL": photo.URLPath,
|
||||||
|
"Now": time.Now,
|
||||||
"PrettyTitle": func() template.HTML {
|
"PrettyTitle": func() template.HTML {
|
||||||
return template.HTML(fmt.Sprintf(
|
return template.HTML(fmt.Sprintf(
|
||||||
`<strong style="color: #0077FF">non</strong>` +
|
`<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.
|
// ToMarkdown renders input text as Markdown.
|
||||||
func ToMarkdown(input string) template.HTML {
|
func ToMarkdown(input string) template.HTML {
|
||||||
return template.HTML(markdown.Render(input))
|
return template.HTML(markdown.Render(input))
|
||||||
|
|
|
@ -82,7 +82,7 @@
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-certificate has-text-success"></i>
|
<i class="fa-solid fa-certificate has-text-success"></i>
|
||||||
</span>
|
</span>
|
||||||
<strong class="has-text-success">Certified!</strong>
|
<strong class="has-text-info">Certified!</strong>
|
||||||
|
|
||||||
<!-- Admin link to see it -->
|
<!-- Admin link to see it -->
|
||||||
{{if .CurrentUser.IsAdmin}}
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
@ -448,6 +448,100 @@
|
||||||
</div>
|
</div>
|
||||||
</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-->
|
<!-- Admin Actions-->
|
||||||
{{if .CurrentUser.IsAdmin}}
|
{{if .CurrentUser.IsAdmin}}
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
Found {{.Pager.Total}} user{{Pluralize64 .Pager.Total}}
|
Found {{FormatNumberCommas .Pager.Total}} user{{Pluralize64 .Pager.Total}}
|
||||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -210,7 +210,7 @@
|
||||||
<div class="column px-1">
|
<div class="column px-1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="sort">Sort by:</label>
|
<label class="label" for="sort">Sort by:</label>
|
||||||
<div class="select is-full-width">
|
<div class="select is-fullwidth">
|
||||||
<select id="sort" name="sort">
|
<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="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>
|
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Signup date</option>
|
||||||
|
|
|
@ -42,6 +42,11 @@
|
||||||
Newest
|
Newest
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/forum/search">
|
||||||
|
<i class="fa fa-search mr-2"></i> Search
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -29,12 +29,17 @@
|
||||||
Newest
|
Newest
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/forum/search">
|
||||||
|
<i class="fa fa-search mr-2"></i> Search
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<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>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
|
@ -82,6 +87,14 @@
|
||||||
{{TrimEllipses .Thread.Comment.Message 256}}
|
{{TrimEllipses .Thread.Comment.Message 256}}
|
||||||
</a>
|
</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">
|
<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}}
|
{{if eq .Comment.ID .Thread.CommentID}}new {{end}}thread by {{.Thread.Comment.User.Username}}
|
||||||
in <a href="/f/{{.Forum.Fragment}}">
|
in <a href="/f/{{.Forum.Fragment}}">
|
||||||
|
@ -135,6 +148,14 @@
|
||||||
{{TrimEllipses .Comment.Message 256}}
|
{{TrimEllipses .Comment.Message 256}}
|
||||||
</a>
|
</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">
|
<div class="is-size-7 is-italic is-grey mt-1">
|
||||||
{{SincePrettyCoarse .Comment.UpdatedAt}} ago
|
{{SincePrettyCoarse .Comment.UpdatedAt}} ago
|
||||||
</div>
|
</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;
|
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"}}
|
{{if eq $cardLightness "light"}}
|
||||||
div.box, .container div.card-content, table.table, table.table strong {
|
div.box, .container div.card-content, table.table, table.table strong {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
@ -55,7 +45,7 @@ section.hero {
|
||||||
|
|
||||||
/* More text color overrides (h1's etc. look light on prefers-dark color schemes) */
|
/* More text color overrides (h1's etc. look light on prefers-dark color schemes) */
|
||||||
.container div.card-content .content * {
|
.container div.card-content .content * {
|
||||||
color: #4a4a4a !important;
|
color: #4a4a4a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slightly less light on dark theme devices */
|
/* 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) */
|
/* More text color overrides (h1's etc. look dark on prefers-light color schemes) */
|
||||||
.container div.card-content .content * {
|
.container div.card-content .content * {
|
||||||
color: #f5f5f5 !important;
|
color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Even darker on dark theme devices */
|
/* Even darker on dark theme devices */
|
||||||
|
@ -88,6 +78,21 @@ section.hero {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{{end}}
|
{{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>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
|
@ -198,7 +198,7 @@
|
||||||
<div class="level-item">
|
<div class="level-item">
|
||||||
{{if .Pager.Total}}
|
{{if .Pager.Total}}
|
||||||
<span>
|
<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}}
|
{{if .ExplicitCount}}
|
||||||
{{.ExplicitCount}} explicit photo{{Pluralize64 .ExplicitCount}} hidden per your <a href="/settings#prefs">settings</a>.
|
{{.ExplicitCount}} explicit photo{{Pluralize64 .ExplicitCount}} hidden per your <a href="/settings#prefs">settings</a>.
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user