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
|
||||
)
|
||||
|
||||
// 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.
|
||||
var (
|
||||
RuntimeVersion string
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
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.
|
||||
var (
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,21 @@ func GetForum(id uint64) (*Forum, 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.
|
||||
func ForumByFragment(fragment string) (*Forum, error) {
|
||||
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
|
||||
|
||||
import "code.nonshy.com/nonshy/website/pkg/log"
|
||||
import (
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
)
|
||||
|
||||
// ForumStatistics queries for forum-level statistics.
|
||||
type ForumStatistics struct {
|
||||
|
|
|
@ -4,14 +4,19 @@ import (
|
|||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
)
|
||||
|
||||
// Pagination result object.
|
||||
type Pagination struct {
|
||||
Page int
|
||||
Page int // provide <0 to mean "last page"
|
||||
PerPage int
|
||||
Total int64
|
||||
Sort string
|
||||
|
||||
// privates
|
||||
lastPage bool
|
||||
}
|
||||
|
||||
// Page for Iter.
|
||||
|
@ -26,6 +31,7 @@ func (p *Pagination) ParsePage(r *http.Request) {
|
|||
a, err := strconv.Atoi(raw)
|
||||
if err == nil {
|
||||
if a <= 0 {
|
||||
p.lastPage = true
|
||||
a = 1
|
||||
}
|
||||
p.Page = a
|
||||
|
@ -37,14 +43,43 @@ func (p *Pagination) ParsePage(r *http.Request) {
|
|||
// Iter the pages, for templates.
|
||||
func (p *Pagination) Iter() []Page {
|
||||
var (
|
||||
pages = []Page{}
|
||||
total = p.Pages()
|
||||
pages = []Page{}
|
||||
pageIdx int
|
||||
total = p.Pages()
|
||||
)
|
||||
for i := 1; i <= total; i++ {
|
||||
pages = append(pages, Page{
|
||||
Page: i,
|
||||
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
|
||||
}
|
||||
|
@ -54,6 +89,10 @@ func (p *Pagination) Pages() 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
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,9 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||
"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.
|
||||
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(
|
||||
"id = ?",
|
||||
t.ID,
|
||||
|
|
|
@ -75,6 +75,16 @@ func Get(key string, v any) error {
|
|||
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.
|
||||
func Delete(key string) error {
|
||||
return Client.Del(ctx, key).Err()
|
||||
|
|
|
@ -63,6 +63,7 @@ func New() http.Handler {
|
|||
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
|
||||
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("/f/", middleware.CertRequired(forum.Forum()))
|
||||
|
||||
// Admin endpoints.
|
||||
|
|
|
@ -42,6 +42,17 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
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 {
|
||||
if len(labels) < 2 {
|
||||
labels = []string{"", "s"}
|
||||
|
|
|
@ -28,6 +28,24 @@
|
|||
</div>
|
||||
|
||||
{{$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}}
|
||||
<div class="block p-4">
|
||||
<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>
|
||||
{{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>
|
||||
|
||||
<p class="block p-4 mb-0">
|
||||
|
@ -130,7 +137,7 @@
|
|||
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
|
||||
{{if .User.IsAdmin}}
|
||||
<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>Admin</span>
|
||||
</span>
|
||||
|
|
Loading…
Reference in New Issue
Block a user