Recent Forum Posts
* Add a "Newest" tab to the Forums landing page to order ALL forum posts (comments) by most recent, paginated. * Add a "Views" cooldown in Redis: viewing the same post multiple times within 1 hour doesn't ++ the view count with every page load, per user per thread ID. * Update the paginators to handle unlimited numbers of pages: shows max 7 page buttons with your current page towards the middle. * General ability to jump to the "last page" of anything: use a negative page size like ?page=-1 and it acts like the last page.
This commit is contained in:
parent
abb6c1c3b1
commit
500456c05e
|
@ -81,6 +81,15 @@ const (
|
||||||
PhotoQuotaCertified = 24
|
PhotoQuotaCertified = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Forum settings
|
||||||
|
const (
|
||||||
|
// Only ++ the Views count per user per thread within a small
|
||||||
|
// window of time - if a user keeps reloading the same thread
|
||||||
|
// rapidly it does not increment the view counter more.
|
||||||
|
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
|
||||||
|
ThreadViewDebounceCooldown = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// Variables set by main.go to make them readily available.
|
// Variables set by main.go to make them readily available.
|
||||||
var (
|
var (
|
||||||
RuntimeVersion string
|
RuntimeVersion string
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
// Number of page buttons to show on a pager. Default shows page buttons
|
||||||
|
// 1 thru N (e.g., 1 thru 8) or w/ your page number in the middle surrounded
|
||||||
|
// by its neighboring pages.
|
||||||
|
const (
|
||||||
|
PagerButtonLimit = 6 // only even numbers make a difference
|
||||||
|
)
|
||||||
|
|
||||||
// Pagination sizes per page.
|
// Pagination sizes per page.
|
||||||
var (
|
var (
|
||||||
PageSizeMemberSearch = 60
|
PageSizeMemberSearch = 60
|
||||||
|
|
47
pkg/controller/forum/newest.go
Normal file
47
pkg/controller/forum/newest.go
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Newest posts across all of the (official) forums.
|
||||||
|
func Newest() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/newest.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get current user: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all the categorized index forums.
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeThreadList,
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Pager": pager,
|
||||||
|
"RecentPosts": posts,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -55,7 +55,7 @@ func Thread() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping the view count on this thread.
|
// Ping the view count on this thread.
|
||||||
if err := thread.View(); err != nil {
|
if err := thread.View(currentUser.ID); err != nil {
|
||||||
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,21 @@ func GetForum(id uint64) (*Forum, error) {
|
||||||
return forum, result.Error
|
return forum, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetForums queries a set of thread IDs and returns them mapped.
|
||||||
|
func GetForums(IDs []uint64) (map[uint64]*Forum, error) {
|
||||||
|
var (
|
||||||
|
mt = map[uint64]*Forum{}
|
||||||
|
fs = []*Forum{}
|
||||||
|
)
|
||||||
|
|
||||||
|
result := (&Forum{}).Preload().Where("id IN ?", IDs).Find(&fs)
|
||||||
|
for _, row := range fs {
|
||||||
|
mt[row.ID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return mt, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
// ForumByFragment looks up a forum by its URL fragment.
|
// ForumByFragment looks up a forum by its URL fragment.
|
||||||
func ForumByFragment(fragment string) (*Forum, error) {
|
func ForumByFragment(fragment string) (*Forum, error) {
|
||||||
if fragment == "" {
|
if fragment == "" {
|
||||||
|
|
152
pkg/models/forum_recent.go
Normal file
152
pkg/models/forum_recent.go
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecentPost drives the "Forums / Newest" page - carrying all forum comments
|
||||||
|
// on all threads sorted by date.
|
||||||
|
type RecentPost struct {
|
||||||
|
CommentID uint64
|
||||||
|
ThreadID uint64
|
||||||
|
ForumID uint64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Thread *Thread
|
||||||
|
Comment *Comment
|
||||||
|
Forum *Forum
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
||||||
|
func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]*RecentPost, error) {
|
||||||
|
var (
|
||||||
|
result = []*RecentPost{}
|
||||||
|
query = (&Comment{}).Preload()
|
||||||
|
wheres = []string{"table_name = 'threads'"}
|
||||||
|
placeholders = []interface{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
if categories != nil && len(categories) > 0 {
|
||||||
|
wheres = append(wheres, "forums.category IN ?")
|
||||||
|
placeholders = append(placeholders, categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide explicit forum if user hasn't opted into it.
|
||||||
|
if !user.Explicit && !user.IsAdmin {
|
||||||
|
wheres = append(wheres, "explicit = false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the page of recent forum comment IDs of all time.
|
||||||
|
type scanner struct {
|
||||||
|
CommentID uint64
|
||||||
|
ThreadID *uint64
|
||||||
|
ForumID *uint64
|
||||||
|
}
|
||||||
|
var scan []scanner
|
||||||
|
query = DB.Table("comments").Select(
|
||||||
|
`comments.id AS comment_id,
|
||||||
|
threads.id AS thread_id,
|
||||||
|
forums.id AS forum_id`,
|
||||||
|
).Joins(
|
||||||
|
"LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||||
|
).Joins(
|
||||||
|
"LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)",
|
||||||
|
).Where(
|
||||||
|
strings.Join(wheres, " AND "),
|
||||||
|
placeholders...,
|
||||||
|
).Order("comments.updated_at desc")
|
||||||
|
|
||||||
|
// Get the total for the pager and scan the page of ID sets.
|
||||||
|
query.Model(&Comment{}).Count(&pager.Total)
|
||||||
|
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
|
||||||
|
if query.Error != nil {
|
||||||
|
return nil, query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest the results.
|
||||||
|
var (
|
||||||
|
commentIDs = []uint64{} // collect distinct IDs
|
||||||
|
threadIDs = []uint64{}
|
||||||
|
forumIDs = []uint64{}
|
||||||
|
seenComments = map[uint64]interface{}{} // deduplication
|
||||||
|
seenThreads = map[uint64]interface{}{}
|
||||||
|
seenForums = map[uint64]interface{}{}
|
||||||
|
mapCommentRC = map[uint64]*RecentPost{} // map commentID to result
|
||||||
|
)
|
||||||
|
for _, row := range scan {
|
||||||
|
// Upsert the result set.
|
||||||
|
var rp *RecentPost
|
||||||
|
if existing, ok := mapCommentRC[row.CommentID]; ok {
|
||||||
|
rp = existing
|
||||||
|
} else {
|
||||||
|
rp = &RecentPost{
|
||||||
|
CommentID: row.CommentID,
|
||||||
|
}
|
||||||
|
mapCommentRC[row.CommentID] = rp
|
||||||
|
result = append(result, rp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Got a thread ID?
|
||||||
|
if row.ThreadID != nil {
|
||||||
|
rp.ThreadID = *row.ThreadID
|
||||||
|
if _, ok := seenThreads[rp.ThreadID]; !ok {
|
||||||
|
seenThreads[rp.ThreadID] = nil
|
||||||
|
threadIDs = append(threadIDs, rp.ThreadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Got a forum ID?
|
||||||
|
if row.ForumID != nil {
|
||||||
|
rp.ForumID = *row.ForumID
|
||||||
|
if _, ok := seenForums[rp.ForumID]; !ok {
|
||||||
|
seenForums[rp.ForumID] = nil
|
||||||
|
forumIDs = append(forumIDs, rp.ForumID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect distinct comment IDs.
|
||||||
|
if _, ok := seenComments[rp.CommentID]; !ok {
|
||||||
|
seenComments[rp.CommentID] = nil
|
||||||
|
commentIDs = append(commentIDs, rp.CommentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all of the distinct comments, threads and forums.
|
||||||
|
var (
|
||||||
|
comments = map[uint64]*Comment{}
|
||||||
|
threads = map[uint64]*Thread{}
|
||||||
|
forums = map[uint64]*Forum{}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(commentIDs) > 0 {
|
||||||
|
comments, _ = GetComments(commentIDs)
|
||||||
|
}
|
||||||
|
if len(threadIDs) > 0 {
|
||||||
|
threads, _ = GetThreads(threadIDs)
|
||||||
|
}
|
||||||
|
if len(forumIDs) > 0 {
|
||||||
|
forums, _ = GetForums(forumIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all the objects back in.
|
||||||
|
for _, rc := range result {
|
||||||
|
if com, ok := comments[rc.CommentID]; ok {
|
||||||
|
rc.Comment = com
|
||||||
|
}
|
||||||
|
|
||||||
|
if thr, ok := threads[rc.ThreadID]; ok {
|
||||||
|
rc.Thread = thr
|
||||||
|
} else {
|
||||||
|
log.Error("RecentPosts: didn't find thread ID %d in map!")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f, ok := forums[rc.ForumID]; ok {
|
||||||
|
rc.Forum = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import "code.nonshy.com/nonshy/website/pkg/log"
|
import (
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
// ForumStatistics queries for forum-level statistics.
|
// ForumStatistics queries for forum-level statistics.
|
||||||
type ForumStatistics struct {
|
type ForumStatistics struct {
|
||||||
|
|
|
@ -4,14 +4,19 @@ import (
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pagination result object.
|
// Pagination result object.
|
||||||
type Pagination struct {
|
type Pagination struct {
|
||||||
Page int
|
Page int // provide <0 to mean "last page"
|
||||||
PerPage int
|
PerPage int
|
||||||
Total int64
|
Total int64
|
||||||
Sort string
|
Sort string
|
||||||
|
|
||||||
|
// privates
|
||||||
|
lastPage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page for Iter.
|
// Page for Iter.
|
||||||
|
@ -26,6 +31,7 @@ func (p *Pagination) ParsePage(r *http.Request) {
|
||||||
a, err := strconv.Atoi(raw)
|
a, err := strconv.Atoi(raw)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if a <= 0 {
|
if a <= 0 {
|
||||||
|
p.lastPage = true
|
||||||
a = 1
|
a = 1
|
||||||
}
|
}
|
||||||
p.Page = a
|
p.Page = a
|
||||||
|
@ -37,14 +43,43 @@ func (p *Pagination) ParsePage(r *http.Request) {
|
||||||
// Iter the pages, for templates.
|
// Iter the pages, for templates.
|
||||||
func (p *Pagination) Iter() []Page {
|
func (p *Pagination) Iter() []Page {
|
||||||
var (
|
var (
|
||||||
pages = []Page{}
|
pages = []Page{}
|
||||||
total = p.Pages()
|
pageIdx int
|
||||||
|
total = p.Pages()
|
||||||
)
|
)
|
||||||
for i := 1; i <= total; i++ {
|
for i := 1; i <= total; i++ {
|
||||||
pages = append(pages, Page{
|
pages = append(pages, Page{
|
||||||
Page: i,
|
Page: i,
|
||||||
IsCurrent: i == p.Page,
|
IsCurrent: i == p.Page,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if i == p.Page {
|
||||||
|
pageIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do we have A LOT of pages?
|
||||||
|
if len(pages) > config.PagerButtonLimit {
|
||||||
|
// We return a slide only N pages long. Where is our current page in the offset?
|
||||||
|
if pageIdx <= config.PagerButtonLimit/2 {
|
||||||
|
// We are near the front, return the first N pages.
|
||||||
|
return pages[:config.PagerButtonLimit+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we near the end?
|
||||||
|
if pageIdx > len(pages)-(config.PagerButtonLimit/2) {
|
||||||
|
// We are near the end, return the last N pages.
|
||||||
|
return pages[len(pages)-config.PagerButtonLimit-1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are somewhere in the middle.
|
||||||
|
var result = []Page{}
|
||||||
|
for i := pageIdx - (config.PagerButtonLimit / 2) - 1; i < pageIdx+(config.PagerButtonLimit/2); i++ {
|
||||||
|
if i >= 0 && i < len(pages) {
|
||||||
|
result = append(result, pages[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
@ -54,6 +89,10 @@ func (p *Pagination) Pages() int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pagination) GetOffset() int {
|
func (p *Pagination) GetOffset() int {
|
||||||
|
// Are we looking for the FINAL page?
|
||||||
|
if p.lastPage && p.Pages() >= 1 {
|
||||||
|
p.Page = p.Pages()
|
||||||
|
}
|
||||||
return (p.Page - 1) * p.PerPage
|
return (p.Page - 1) * p.PerPage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,9 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -153,7 +155,15 @@ func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, er
|
||||||
}
|
}
|
||||||
|
|
||||||
// View a thread, incrementing its View count but not its UpdatedAt.
|
// View a thread, incrementing its View count but not its UpdatedAt.
|
||||||
func (t *Thread) View() error {
|
// Debounced with a Redis key.
|
||||||
|
func (t *Thread) View(userID uint64) error {
|
||||||
|
// Debounce this.
|
||||||
|
var redisKey = fmt.Sprintf(config.ThreadViewDebounceRedisKey, userID, t.ID)
|
||||||
|
if redis.Exists(redisKey) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
redis.Set(redisKey, nil, config.ThreadViewDebounceCooldown)
|
||||||
|
|
||||||
return DB.Model(&Thread{}).Where(
|
return DB.Model(&Thread{}).Where(
|
||||||
"id = ?",
|
"id = ?",
|
||||||
t.ID,
|
t.ID,
|
||||||
|
|
|
@ -75,6 +75,16 @@ func Get(key string, v any) error {
|
||||||
return json.Unmarshal([]byte(val), v)
|
return json.Unmarshal([]byte(val), v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exists checks if a Redis key existed.
|
||||||
|
func Exists(key string) bool {
|
||||||
|
val, err := Client.Exists(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Debug("redis.Exists(%s): %s", key, val)
|
||||||
|
return val == 1
|
||||||
|
}
|
||||||
|
|
||||||
// Delete a key from Redis.
|
// Delete a key from Redis.
|
||||||
func Delete(key string) error {
|
func Delete(key string) error {
|
||||||
return Client.Del(ctx, key).Err()
|
return Client.Del(ctx, key).Err()
|
||||||
|
|
|
@ -63,6 +63,7 @@ func New() http.Handler {
|
||||||
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
|
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
|
||||||
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("/f/", middleware.CertRequired(forum.Forum()))
|
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
|
||||||
|
|
||||||
// Admin endpoints.
|
// Admin endpoints.
|
||||||
|
|
|
@ -42,6 +42,17 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
return labels[1]
|
return labels[1]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PluralizeU64": func(count uint64, labels ...string) string {
|
||||||
|
if len(labels) < 2 {
|
||||||
|
labels = []string{"", "s"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
return labels[0]
|
||||||
|
} else {
|
||||||
|
return labels[1]
|
||||||
|
}
|
||||||
|
},
|
||||||
"Pluralize": func(count int, labels ...string) string {
|
"Pluralize": func(count int, labels ...string) string {
|
||||||
if len(labels) < 2 {
|
if len(labels) < 2 {
|
||||||
labels = []string{"", "s"}
|
labels = []string{"", "s"}
|
||||||
|
|
|
@ -28,6 +28,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{$Root := .}}
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block p-4 mb-0">
|
||||||
|
<div class="tabs is-boxed">
|
||||||
|
<ul>
|
||||||
|
<li class="is-active">
|
||||||
|
<a href="/forum">
|
||||||
|
Categories
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/forum/newest">
|
||||||
|
Newest
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{range .Categories}}
|
{{range .Categories}}
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
<h1 class="title">{{.Category}}</h1>
|
<h1 class="title">{{.Category}}</h1>
|
||||||
|
|
232
web/templates/forum/newest.html
Normal file
232
web/templates/forum/newest.html
Normal file
|
@ -0,0 +1,232 @@
|
||||||
|
{{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">
|
||||||
|
Newest Posts
|
||||||
|
</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 class="is-active">
|
||||||
|
<a href="/forum/newest">
|
||||||
|
Newest
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
Found {{.Pager.Total}} posts (page {{.Pager.Page}} of {{.Pager.Pages}})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||||
|
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
|
||||||
|
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
|
||||||
|
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||||
|
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{{$Root := .}}
|
||||||
|
{{range .Pager.Iter}}
|
||||||
|
<li>
|
||||||
|
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||||
|
aria-label="Page {{.Page}}"
|
||||||
|
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||||
|
{{.Page}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
{{range .RecentPosts}}
|
||||||
|
{{$User := .Thread.Comment.User}}
|
||||||
|
<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}}">
|
||||||
|
<figure class="image is-96x96 is-inline-block">
|
||||||
|
{{if $User.ProfilePhoto.ID}}
|
||||||
|
<img src="{{PhotoURL $User.ProfilePhoto.CroppedFilename}}">
|
||||||
|
{{else}}
|
||||||
|
<img src="/static/img/shy.png">
|
||||||
|
{{end}}
|
||||||
|
</figure>
|
||||||
|
<div>
|
||||||
|
<a href="/u/{{$User.Username}}" class="is-size-7">{{$User.Username}}</a>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="column py-0">
|
||||||
|
<h2 class="is-size-4 pt-0">
|
||||||
|
<a href="/forum/thread/{{.ThreadID}}" class="has-text-dark has-text-weight-bold">
|
||||||
|
{{.Thread.Title}}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<a href="/forum/thread/{{.ThreadID}}" class="has-text-dark">
|
||||||
|
{{TrimEllipses .Thread.Comment.Message 256}}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="is-size-7 is-italic is-grey">
|
||||||
|
by {{.Thread.Comment.User.Username}}
|
||||||
|
–
|
||||||
|
{{SincePrettyCoarse .Thread.Comment.UpdatedAt}} ago
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="has-background-grey my-2">
|
||||||
|
|
||||||
|
<h2 class="is-size-5 pt-0">
|
||||||
|
<a href="/forum/thread/{{.ThreadID}}?page=-1" class="has-text-dark">Latest Post</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{{if ne .Comment.ID .Thread.CommentID}}
|
||||||
|
<a href="/forum/thread/{{.ThreadID}}?page=-1" class="is-size-7 has-text-dark">
|
||||||
|
{{TrimEllipses .Comment.Message 256}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="is-size-7 is-italic is-grey">
|
||||||
|
by {{.Comment.User.Username}}
|
||||||
|
–
|
||||||
|
{{SincePrettyCoarse .Comment.UpdatedAt}} ago
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{range .Categories}}
|
||||||
|
<div class="block p-4">
|
||||||
|
<h1 class="title">{{.Category}}</h1>
|
||||||
|
|
||||||
|
{{if eq (len .Forums) 0}}
|
||||||
|
<em>
|
||||||
|
There are no forums under this category.
|
||||||
|
{{if not $Root.CurrentUser.Explicit}}Your content filters (non-explicit) may be hiding some forums.{{end}}
|
||||||
|
</em>
|
||||||
|
{{else}}
|
||||||
|
{{range .Forums}}
|
||||||
|
{{$Stats := $Root.ForumMap.Get .ID}}
|
||||||
|
<div class="card block has-background-primary-light">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3 pt-0 pb-1">
|
||||||
|
|
||||||
|
<h2 class="is-size-4">
|
||||||
|
<strong><a href="/f/{{.Fragment}}">{{.Title}}</a></strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="content mb-1">
|
||||||
|
{{if .Description}}
|
||||||
|
{{ToMarkdown .Description}}
|
||||||
|
{{else}}
|
||||||
|
<em>No description</em>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{if .Explicit}}
|
||||||
|
<span class="tag is-danger is-light">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
<span>Explicit</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Privileged}}
|
||||||
|
<span class="tag is-warning is-light">
|
||||||
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
<span>Privileged</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="column py-1">
|
||||||
|
<div class="box has-background-success-light">
|
||||||
|
<h2 class="subtitle mb-1">Latest Post</h2>
|
||||||
|
{{if $Stats.RecentThread}}
|
||||||
|
<a href="/forum/thread/{{$Stats.RecentThread.ID}}">
|
||||||
|
<strong>{{$Stats.RecentThread.Title}}</strong>
|
||||||
|
</a>
|
||||||
|
<em>by {{$Stats.RecentThread.Comment.User.Username}}</em>
|
||||||
|
<div>
|
||||||
|
<em>
|
||||||
|
{{if and $Stats.RecentPost (not (eq $Stats.RecentPost.ID $Stats.RecentThread.CommentID))}}
|
||||||
|
<small>Last comment by {{$Stats.RecentPost.User.Username}}</small>
|
||||||
|
{{end}}
|
||||||
|
<small title="{{$Stats.RecentThread.UpdatedAt.Format "2006-01-02 15:04:05"}}">{{SincePrettyCoarse $Stats.RecentThread.UpdatedAt}} ago</small>
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<em>No posts found.</em>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-3 py-1">
|
||||||
|
<div class="columns is-mobile is-gapless">
|
||||||
|
<div class="column has-text-centered mr-1">
|
||||||
|
<div class="box has-background-warning-light p-2">
|
||||||
|
<p class="is-size-7">Topics</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Threads}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column has-text-centered mx-1">
|
||||||
|
<div class="box has-background-warning-light p-2">
|
||||||
|
<p class="is-size-7">Posts</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Posts}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column has-text-centered ml-1">
|
||||||
|
<div class="box has-background-warning-light p-2">
|
||||||
|
<p class="is-size-7">Users</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Users}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}<!-- range .Categories -->
|
||||||
|
|
||||||
|
{{end}}
|
|
@ -68,7 +68,14 @@
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago</em>
|
<span class="tag is-grey is-light mr-2" title="This thread can not be replied to.">
|
||||||
|
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||||
|
<span>{{.Thread.Views}} View{{PluralizeU64 .Thread.Views}}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">
|
||||||
|
Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago
|
||||||
|
</em>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="block p-4 mb-0">
|
<p class="block p-4 mb-0">
|
||||||
|
@ -130,7 +137,7 @@
|
||||||
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
|
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
|
||||||
{{if .User.IsAdmin}}
|
{{if .User.IsAdmin}}
|
||||||
<div class="is-size-7 mt-1">
|
<div class="is-size-7 mt-1">
|
||||||
<span class="tag is-danger">
|
<span class="tag is-danger is-light">
|
||||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user