1101 lines
38 KiB
Go
1101 lines
38 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
||
"git.iioio.com/freefire/jiang13-forum/model"
|
||
"git.iioio.com/freefire/jiang13-forum/service"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
var firstImgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||
|
||
// homeBootPayload 与前端 __J13_HOME_BOOT__ 对齐,供冷启动灌缓存
|
||
type homeBootPayload struct {
|
||
BoardID uint `json:"board_id"`
|
||
Sort string `json:"sort"`
|
||
Keyword string `json:"keyword"`
|
||
Tag string `json:"tag"`
|
||
Author string `json:"author"`
|
||
TitleOnly bool `json:"title_only"`
|
||
Posts []service.PostListItem `json:"posts"`
|
||
PostTotal int64 `json:"post_total"`
|
||
Page int `json:"page"`
|
||
Boards []service.BoardWithStats `json:"boards"`
|
||
Stats homeBootStats `json:"stats"`
|
||
RecentComments []service.RecentCommentItem `json:"recent_comments"`
|
||
RecentUsers []service.RecentUserItem `json:"recent_users"`
|
||
Tags []service.TagCount `json:"tags"`
|
||
// omitempty:侧栏关闭或未拉取时不写字段,避免 SPA 把 [] 当成有效会话快照
|
||
Showcase []service.CommunityShowcaseItem `json:"showcase,omitempty"`
|
||
Pages []service.SitePageSummary `json:"pages"`
|
||
Limits service.ForumLimitsPublic `json:"limits"`
|
||
Branding service.SiteBranding `json:"branding"`
|
||
User *model.UserSelf `json:"user"`
|
||
UnreadMessages int `json:"unread_messages"`
|
||
CheckIn *service.CheckInStatus `json:"check_in"`
|
||
}
|
||
|
||
type homeBootStats struct {
|
||
Users int64 `json:"users"`
|
||
Posts int64 `json:"posts"`
|
||
Boards int64 `json:"boards"`
|
||
Comments int64 `json:"comments"`
|
||
}
|
||
|
||
type homeSSRData struct {
|
||
boot homeBootPayload
|
||
meta homeSSRMeta
|
||
}
|
||
|
||
type homeSSRMeta struct {
|
||
boardName string
|
||
permalink service.PermalinkConfig
|
||
listStyle string
|
||
defSort string
|
||
}
|
||
|
||
// serveFeedDocument 首页/板块:文档 SSR + boot;失败则回退空 root SPA
|
||
func (h *Handlers) serveFeedDocument(c *gin.Context, meta *embed_static.SPAPageMeta, boardID uint) {
|
||
data, err := h.gatherHomeSSR(c, boardID)
|
||
if err != nil {
|
||
embed_static.ServeSPAWithMeta(c, meta)
|
||
return
|
||
}
|
||
bootJSON, err := json.Marshal(data.boot)
|
||
if err != nil {
|
||
embed_static.ServeSPAWithMeta(c, meta)
|
||
return
|
||
}
|
||
meta.RootHTML = renderHomeSSRHTML(data)
|
||
meta.BootJSON = bootJSON
|
||
embed_static.ServeSPAWithMeta(c, meta)
|
||
}
|
||
|
||
func (h *Handlers) gatherHomeSSR(c *gin.Context, boardID uint) (*homeSSRData, error) {
|
||
limits := h.Settings.PublicLimits()
|
||
brand := h.Settings.SiteBranding()
|
||
brand.SiteURL = h.publicBaseURL(c)
|
||
permalink := h.Settings.Permalink()
|
||
defSort := h.Settings.DefaultFeedSort()
|
||
|
||
sort := strings.TrimSpace(c.Query("sort"))
|
||
if sort == "" {
|
||
sort = defSort
|
||
}
|
||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||
tag := strings.TrimSpace(c.Query("tag"))
|
||
author := strings.TrimSpace(c.Query("author"))
|
||
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
|
||
if tag != "" {
|
||
keyword = ""
|
||
author = ""
|
||
titleOnly = false
|
||
}
|
||
|
||
pageSize := h.Settings.PageSizeDefault()
|
||
listStyle := limits.FeedListStyle
|
||
if listStyle == "" {
|
||
listStyle = "title"
|
||
}
|
||
|
||
out := &homeSSRData{
|
||
boot: homeBootPayload{
|
||
BoardID: boardID,
|
||
Sort: sort,
|
||
Keyword: keyword,
|
||
Tag: tag,
|
||
Author: author,
|
||
TitleOnly: titleOnly,
|
||
Page: 1,
|
||
Limits: limits,
|
||
Branding: brand,
|
||
},
|
||
meta: homeSSRMeta{
|
||
permalink: permalink,
|
||
listStyle: listStyle,
|
||
defSort: defSort,
|
||
},
|
||
}
|
||
|
||
var (
|
||
postsErr, boardsErr error
|
||
wg sync.WaitGroup
|
||
)
|
||
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
q := service.PostListQuery{
|
||
BoardID: boardID,
|
||
Page: 1,
|
||
Size: pageSize,
|
||
Sort: sort,
|
||
Keyword: keyword,
|
||
Tag: tag,
|
||
Author: author,
|
||
TitleOnly: titleOnly,
|
||
ViewerID: h.currentUserID(c),
|
||
ViewerIsAdmin: h.isAdmin(c),
|
||
}
|
||
items, total, err := h.Post.ListItems(q)
|
||
if err != nil {
|
||
postsErr = err
|
||
return
|
||
}
|
||
if items == nil {
|
||
items = []service.PostListItem{}
|
||
}
|
||
if h.Badge != nil {
|
||
users := make([]*model.User, 0, len(items))
|
||
for i := range items {
|
||
if items[i].User.ID > 0 {
|
||
users = append(users, &items[i].User)
|
||
}
|
||
}
|
||
h.Badge.AttachBadgeSummaries(users, 2)
|
||
}
|
||
out.boot.Posts = items
|
||
out.boot.PostTotal = total
|
||
}()
|
||
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
boards, err := h.Board.ListWithStats()
|
||
if err != nil {
|
||
boardsErr = err
|
||
return
|
||
}
|
||
if boards == nil {
|
||
boards = []service.BoardWithStats{}
|
||
}
|
||
out.boot.Boards = boards
|
||
for _, board := range boards {
|
||
if board.ID == boardID {
|
||
out.meta.boardName = board.Name
|
||
break
|
||
}
|
||
}
|
||
}()
|
||
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
var users, posts, boardsN, comments int64
|
||
model.DB.Model(&model.User{}).Count(&users)
|
||
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts)
|
||
model.DB.Model(&model.Board{}).Count(&boardsN)
|
||
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&comments)
|
||
out.boot.Stats = homeBootStats{Users: users, Posts: posts, Boards: boardsN, Comments: comments}
|
||
}()
|
||
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
pages, err := h.SitePage.ListPublished()
|
||
if err != nil || pages == nil {
|
||
pages = []service.SitePageSummary{}
|
||
}
|
||
out.boot.Pages = pages
|
||
}()
|
||
|
||
widgetEnabled := map[string]bool{}
|
||
for _, w := range limits.AsideWidgets {
|
||
widgetEnabled[w.ID] = w.Enabled
|
||
}
|
||
|
||
if widgetEnabled[service.AsideWidgetRecentComments] {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
items, err := h.Comment.ListRecentPublic(8)
|
||
if err != nil || items == nil {
|
||
items = []service.RecentCommentItem{}
|
||
}
|
||
out.boot.RecentComments = items
|
||
}()
|
||
} else {
|
||
out.boot.RecentComments = []service.RecentCommentItem{}
|
||
}
|
||
|
||
if widgetEnabled[service.AsideWidgetRecentUsers] {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
items, err := h.User.ListRecentRegistered(8)
|
||
if err != nil || items == nil {
|
||
items = []service.RecentUserItem{}
|
||
}
|
||
out.boot.RecentUsers = items
|
||
}()
|
||
} else {
|
||
out.boot.RecentUsers = []service.RecentUserItem{}
|
||
}
|
||
|
||
if widgetEnabled[service.AsideWidgetTagCloud] {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
tags, err := h.Post.PopularTags(40)
|
||
if err != nil || tags == nil {
|
||
tags = []service.TagCount{}
|
||
}
|
||
out.boot.Tags = tags
|
||
}()
|
||
} else {
|
||
out.boot.Tags = []service.TagCount{}
|
||
}
|
||
|
||
if widgetEnabled[service.AsideWidgetShowcase] && h.Community != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
items, err := h.Community.ListShowcase(c.Request.Host)
|
||
if err != nil {
|
||
// 失败不写 showcase,让前端自行请求,避免假空粘死
|
||
return
|
||
}
|
||
if items == nil {
|
||
items = []service.CommunityShowcaseItem{}
|
||
}
|
||
out.boot.Showcase = items
|
||
}()
|
||
}
|
||
|
||
uid := h.currentUserID(c)
|
||
if uid > 0 {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
user, err := h.User.GetByID(uid)
|
||
if err != nil || user == nil {
|
||
return
|
||
}
|
||
view := user.ToSelf()
|
||
if h.Badge != nil {
|
||
_ = h.Badge.EvaluateAuto(user.ID)
|
||
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
|
||
view.Badges = service.BadgeViews(badges, 0)
|
||
}
|
||
}
|
||
out.boot.User = &view
|
||
}()
|
||
if h.Message != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
total, _, _, err := h.Message.UnreadCounts(uid)
|
||
if err == nil {
|
||
out.boot.UnreadMessages = int(total)
|
||
}
|
||
}()
|
||
}
|
||
if h.Points != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
st, err := h.Points.GetCheckInStatus(uid)
|
||
if err == nil {
|
||
out.boot.CheckIn = &st
|
||
}
|
||
}()
|
||
}
|
||
}
|
||
|
||
wg.Wait()
|
||
if postsErr != nil {
|
||
return nil, postsErr
|
||
}
|
||
if boardsErr != nil {
|
||
return nil, boardsErr
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func renderHomeSSRHTML(data *homeSSRData) string {
|
||
b := &strings.Builder{}
|
||
b.Grow(64 * 1024)
|
||
boot := data.boot
|
||
meta := data.meta
|
||
brandName := strings.TrimSpace(boot.Branding.Name)
|
||
if brandName == "" {
|
||
brandName = "姜十三论坛"
|
||
}
|
||
logoMark := strings.TrimSpace(boot.Branding.LogoMark)
|
||
if logoMark == "" {
|
||
if r, _ := utf8.DecodeRuneInString(brandName); r != utf8.RuneError {
|
||
logoMark = string(r)
|
||
} else {
|
||
logoMark = "姜"
|
||
}
|
||
}
|
||
loggedIn := boot.User != nil && boot.User.ID > 0
|
||
|
||
b.WriteString(`<div class="app-shell ssr-home"><div class="app-frame">`)
|
||
|
||
b.WriteString(`<header class="app-header"><div class="header-inner">`)
|
||
// 手机端汉堡:桌面 CSS 隐藏;hydrate 后由 React 接管点击
|
||
b.WriteString(`<button type="button" class="header-icon-btn header-menu-btn" aria-label="打开导航菜单" title="导航">`)
|
||
b.WriteString(ssrIconMenu())
|
||
b.WriteString(`</button>`)
|
||
b.WriteString(`<a class="header-brand" href="/">`)
|
||
if logo := strings.TrimSpace(boot.Branding.Logo); logo != "" {
|
||
b.WriteString(`<img class="header-logo-mark" src="` + html.EscapeString(logo) + `" alt=""/>`)
|
||
} else {
|
||
b.WriteString(`<span class="header-logo-mark">` + html.EscapeString(logoMark) + `</span>`)
|
||
}
|
||
b.WriteString(`<span class="header-logo-text">` + html.EscapeString(brandName) + `</span></a>`)
|
||
|
||
b.WriteString(`<form class="header-search-wrap" role="search" action="/" method="get"><div class="header-search-row">`)
|
||
b.WriteString(ssrIconSearch())
|
||
b.WriteString(`<input class="header-search-input" type="search" name="keyword" placeholder="搜索帖子…" aria-label="搜索帖子" value="` + html.EscapeString(boot.Keyword) + `"/>`)
|
||
b.WriteString(`<button type="submit" class="header-search-filter-btn" aria-label="搜索筛选" title="筛选">`)
|
||
b.WriteString(ssrIconSliders())
|
||
b.WriteString(`</button>`)
|
||
b.WriteString(`<kbd class="header-search-kbd" aria-hidden="true">Ctrl K</kbd>`)
|
||
b.WriteString(`</div></form>`)
|
||
|
||
b.WriteString(`<div class="header-actions">`)
|
||
composeHref := "/login?from=%2Fcompose"
|
||
if loggedIn {
|
||
composeHref = "/compose"
|
||
}
|
||
b.WriteString(`<a class="header-compose-btn" href="` + composeHref + `" aria-label="发帖">`)
|
||
b.WriteString(ssrIconPlus())
|
||
b.WriteString(`<span class="header-compose-btn__label">发帖</span></a>`)
|
||
|
||
b.WriteString(`<div class="header-action-group">`)
|
||
// 主题:双图标 + CSS,配合 documentElement.dark;手机顶栏 CSS 隐藏
|
||
b.WriteString(`<button type="button" class="header-icon-btn header-theme-btn" aria-label="切换主题" title="切换主题">`)
|
||
b.WriteString(`<span class="ssr-theme-icon ssr-theme-icon--moon">` + ssrIconMoon() + `</span>`)
|
||
b.WriteString(`<span class="ssr-theme-icon ssr-theme-icon--sun">` + ssrIconSun() + `</span>`)
|
||
b.WriteString(`</button>`)
|
||
|
||
if loggedIn {
|
||
u := boot.User
|
||
msgTitle := "站内消息"
|
||
if boot.UnreadMessages > 0 {
|
||
msgTitle = strconv.Itoa(boot.UnreadMessages) + " 条未读消息"
|
||
}
|
||
b.WriteString(`<a class="header-icon-btn header-msg-btn" href="/messages" title="` + html.EscapeString(msgTitle) + `" aria-label="` + html.EscapeString(msgTitle) + `">`)
|
||
b.WriteString(ssrIconMail())
|
||
if boot.UnreadMessages > 0 {
|
||
badge := strconv.Itoa(boot.UnreadMessages)
|
||
if boot.UnreadMessages > 99 {
|
||
badge = "99+"
|
||
}
|
||
b.WriteString(`<span class="header-msg-badge">` + badge + `</span>`)
|
||
}
|
||
b.WriteString(`</a>`)
|
||
nick := strings.TrimSpace(u.Nickname)
|
||
if nick == "" {
|
||
nick = u.Username
|
||
}
|
||
initial := "?"
|
||
if r, _ := utf8.DecodeRuneInString(nick); r != utf8.RuneError {
|
||
initial = string(r)
|
||
}
|
||
b.WriteString(`<a class="header-user-btn" href="/profile" title="` + html.EscapeString(nick) + `" aria-label="用户菜单:` + html.EscapeString(nick) + `">`)
|
||
if u.Avatar != "" {
|
||
b.WriteString(`<img src="` + html.EscapeString(u.Avatar) + `" alt="" class="header-user-avatar" loading="lazy" decoding="async"/>`)
|
||
} else {
|
||
b.WriteString(`<span class="header-user-initial">` + html.EscapeString(initial) + `</span>`)
|
||
}
|
||
b.WriteString(`</a>`)
|
||
} else {
|
||
b.WriteString(`<a class="header-login-btn" href="/login">登录</a>`)
|
||
}
|
||
b.WriteString(`</div></div></div></header>`)
|
||
|
||
b.WriteString(`<div class="app-body">`)
|
||
writeSSRSidebar(b, boot, meta)
|
||
b.WriteString(`<div class="content-workspace"><main class="main-content">`)
|
||
writeSSRFeed(b, boot, meta)
|
||
b.WriteString(`</main>`)
|
||
writeSSRAside(b, boot)
|
||
b.WriteString(`</div></div>`)
|
||
writeSSRFooter(b, boot, meta)
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
func ssrFeedURL(boardID uint, sort, defSort string, pl service.PermalinkConfig, extra url.Values) string {
|
||
var path string
|
||
if boardID > 0 {
|
||
path = pl.BoardPath(boardID)
|
||
} else {
|
||
path = "/"
|
||
}
|
||
q := url.Values{}
|
||
for k, vs := range extra {
|
||
for _, v := range vs {
|
||
if strings.TrimSpace(v) != "" {
|
||
q.Add(k, v)
|
||
}
|
||
}
|
||
}
|
||
if sort != "" && sort != defSort {
|
||
q.Set("sort", sort)
|
||
}
|
||
enc := q.Encode()
|
||
if enc == "" {
|
||
return path
|
||
}
|
||
return path + "?" + enc
|
||
}
|
||
|
||
func writeSSRSidebar(b *strings.Builder, boot homeBootPayload, meta homeSSRMeta) {
|
||
loggedIn := boot.User != nil && boot.User.ID > 0
|
||
isAdmin := loggedIn && boot.User.Role == model.RoleAdmin
|
||
|
||
b.WriteString(`<aside class="sidebar"><div class="sidebar-section">浏览</div><nav class="sidebar-nav">`)
|
||
allActive := ""
|
||
if boot.BoardID == 0 && boot.Keyword == "" && boot.Tag == "" && boot.Author == "" {
|
||
allActive = " active"
|
||
}
|
||
b.WriteString(`<a class="sidebar-nav-item` + allActive + `" href="` + html.EscapeString(ssrFeedURL(0, boot.Sort, meta.defSort, meta.permalink, nil)) + `">`)
|
||
b.WriteString(ssrIconHome())
|
||
b.WriteString(`<span class="flex-1 truncate">全部帖子</span></a>`)
|
||
if loggedIn {
|
||
b.WriteString(`<a class="sidebar-nav-item" href="/favorites">`)
|
||
b.WriteString(ssrIconStar())
|
||
b.WriteString(`<span class="flex-1 truncate">我的收藏</span></a>`)
|
||
}
|
||
b.WriteString(`<a class="sidebar-nav-item" href="/projects">`)
|
||
b.WriteString(ssrIconFolderGit())
|
||
b.WriteString(`<span class="flex-1 truncate">开源码桶</span></a></nav>`)
|
||
|
||
if len(boot.Boards) > 0 {
|
||
b.WriteString(`<div class="sidebar-section sidebar-section--boards">板块</div><nav class="sidebar-nav">`)
|
||
for _, board := range boot.Boards {
|
||
theme := boardThemeIndex(board.ColorIndex, board.ID)
|
||
active := ""
|
||
extraClass := ""
|
||
if boot.BoardID == board.ID {
|
||
active = " active"
|
||
extraClass = fmt.Sprintf(" sidebar-nav-item--board-%d", theme)
|
||
}
|
||
href := ssrFeedURL(board.ID, boot.Sort, meta.defSort, meta.permalink, nil)
|
||
b.WriteString(`<a class="sidebar-nav-item sidebar-nav-item--board` + active + extraClass + `" href="` + html.EscapeString(href) + `">`)
|
||
b.WriteString(ssrBoardIconSVG(board.Icon, theme, `sidebar-board-icon sidebar-board-icon--`+strconv.Itoa(theme)))
|
||
b.WriteString(`<span class="flex-1 truncate">` + html.EscapeString(board.Name) + `</span>`)
|
||
if board.PostCount > 0 {
|
||
b.WriteString(`<span class="sidebar-nav-item__meta">` + strconv.Itoa(board.PostCount) + ` 帖</span>`)
|
||
}
|
||
b.WriteString(`</a>`)
|
||
}
|
||
b.WriteString(`</nav>`)
|
||
}
|
||
|
||
navPages := make([]service.SitePageSummary, 0)
|
||
for _, p := range boot.Pages {
|
||
if p.ShowInNav {
|
||
navPages = append(navPages, p)
|
||
}
|
||
}
|
||
if boot.Limits.NavShowFriendLinks || boot.Limits.NavShowShowcase || len(navPages) > 0 {
|
||
b.WriteString(`<div class="sidebar-section sidebar-section--spaced">站点</div><nav class="sidebar-nav">`)
|
||
if boot.Limits.NavShowFriendLinks {
|
||
b.WriteString(`<a class="sidebar-nav-item" href="/links">`)
|
||
b.WriteString(ssrIconLink2())
|
||
b.WriteString(`<span class="flex-1 truncate">友情链接</span></a>`)
|
||
}
|
||
if boot.Limits.NavShowShowcase {
|
||
b.WriteString(`<a class="sidebar-nav-item" href="/showcase">`)
|
||
b.WriteString(ssrIconEarth())
|
||
b.WriteString(`<span class="flex-1 truncate">开源展柜</span></a>`)
|
||
}
|
||
for _, p := range navPages {
|
||
b.WriteString(`<a class="sidebar-nav-item" href="` + html.EscapeString(meta.permalink.PagePath(p.Slug)) + `">`)
|
||
b.WriteString(ssrIconFileText())
|
||
b.WriteString(`<span class="flex-1 truncate">` + html.EscapeString(p.Title) + `</span></a>`)
|
||
}
|
||
b.WriteString(`</nav>`)
|
||
}
|
||
|
||
if isAdmin {
|
||
b.WriteString(`<div class="sidebar-section sidebar-section--spaced">管理</div><nav class="sidebar-nav">`)
|
||
b.WriteString(`<a class="sidebar-nav-item" href="/admin/dashboard">`)
|
||
b.WriteString(ssrIconLayoutDashboard())
|
||
b.WriteString(`<span class="flex-1 truncate">管理后台</span></a></nav>`)
|
||
}
|
||
b.WriteString(`</aside>`)
|
||
}
|
||
|
||
func writeSSRFeed(b *strings.Builder, boot homeBootPayload, meta homeSSRMeta) {
|
||
b.WriteString(`<div class="page-wrap page-wrap--feed"><div class="feed-panel">`)
|
||
b.WriteString(`<div class="feed-top"><div class="feed-top__bar">`)
|
||
|
||
// 板块首页不输出 h1(与 React FeedHeader 一致);仅搜索/标签/作者保留标题
|
||
if boot.Keyword != "" || boot.Tag != "" || boot.Author != "" {
|
||
title := "帖子列表"
|
||
switch {
|
||
case boot.Tag != "":
|
||
title = "#" + boot.Tag
|
||
case boot.Keyword != "":
|
||
title = "搜索:" + boot.Keyword
|
||
case boot.Author != "":
|
||
title = "作者:" + boot.Author
|
||
}
|
||
b.WriteString(`<h1 class="feed-header-title">` + html.EscapeString(title) + `</h1>`)
|
||
}
|
||
|
||
showSort := boot.Keyword == "" && boot.Tag == "" && boot.Author == ""
|
||
if showSort {
|
||
tabs := boot.Limits.FeedSortTabs
|
||
if len(tabs) == 0 {
|
||
tabs = []service.FeedSortTab{
|
||
{ID: service.FeedSortReply, Label: "最新", Enabled: true},
|
||
{ID: service.FeedSortLatest, Label: "新帖子", Enabled: true},
|
||
{ID: service.FeedSortHot, Label: "推荐帖", Enabled: true},
|
||
}
|
||
}
|
||
b.WriteString(`<div class="feed-toolbar"><div class="feed-sort-bar" role="tablist">`)
|
||
for _, tab := range tabs {
|
||
if !tab.Enabled {
|
||
continue
|
||
}
|
||
active := ""
|
||
if tab.ID == boot.Sort {
|
||
active = " active"
|
||
}
|
||
href := ssrFeedURL(boot.BoardID, tab.ID, meta.defSort, meta.permalink, ssrFilterQuery(boot))
|
||
b.WriteString(`<a class="feed-sort-tab` + active + `" href="` + html.EscapeString(href) + `" role="tab">`)
|
||
switch tab.ID {
|
||
case service.FeedSortReply:
|
||
b.WriteString(ssrIconMessageCircle())
|
||
case service.FeedSortHot:
|
||
b.WriteString(ssrIconBadgeCheck())
|
||
default:
|
||
b.WriteString(ssrIconClock())
|
||
}
|
||
b.WriteString(`<span class="feed-sort-tab__label">` + html.EscapeString(tab.Label) + `</span></a>`)
|
||
}
|
||
b.WriteString(`</div>`)
|
||
b.WriteString(`<span class="feed-toolbar__count">共 ` + strconv.FormatInt(boot.PostTotal, 10) + ` 条</span>`)
|
||
b.WriteString(`</div>`)
|
||
}
|
||
|
||
b.WriteString(`</div></div>`)
|
||
|
||
b.WriteString(`<div class="post-list-scroll post-list-scroll--ssr"><div class="content-surface content-surface--ssr">`)
|
||
if len(boot.Posts) == 0 {
|
||
b.WriteString(`<div class="feed-empty">暂无帖子</div>`)
|
||
} else {
|
||
titleOnly := meta.listStyle == "title"
|
||
needExcerpt := meta.listStyle == "excerpt" || meta.listStyle == "thumbnail"
|
||
needThumb := meta.listStyle == "thumbnail"
|
||
for i := range boot.Posts {
|
||
writeSSRPostRow(b, &boot.Posts[i], boot.BoardID, meta.permalink, boot.Sort, titleOnly, needExcerpt, needThumb)
|
||
}
|
||
}
|
||
b.WriteString(`</div>`)
|
||
// 手机端流入页脚(桌面 CSS 隐藏);壳层贴底页脚见 writeSSRFooter
|
||
writeSSRFooter(b, boot, meta)
|
||
b.WriteString(`</div></div></div>`)
|
||
}
|
||
|
||
func ssrFilterQuery(boot homeBootPayload) url.Values {
|
||
q := url.Values{}
|
||
if boot.Tag != "" {
|
||
q.Set("tag", boot.Tag)
|
||
return q
|
||
}
|
||
if boot.Keyword != "" {
|
||
q.Set("keyword", boot.Keyword)
|
||
if boot.TitleOnly {
|
||
q.Set("title_only", "1")
|
||
}
|
||
}
|
||
if boot.Author != "" {
|
||
q.Set("author", boot.Author)
|
||
}
|
||
return q
|
||
}
|
||
|
||
func writeSSRPostRow(
|
||
b *strings.Builder,
|
||
post *service.PostListItem,
|
||
currentBoard uint,
|
||
pl service.PermalinkConfig,
|
||
sort string,
|
||
titleOnly, needExcerpt, needThumb bool,
|
||
) {
|
||
href := pl.PostPath(post.ID)
|
||
author := service.DisplayName(&post.User)
|
||
if author == "" {
|
||
author = "用户"
|
||
}
|
||
initial := "?"
|
||
if r, _ := utf8.DecodeRuneInString(author); r != utf8.RuneError {
|
||
initial = string(r)
|
||
}
|
||
|
||
var excerpt, thumb string
|
||
if needExcerpt || needThumb {
|
||
plain := service.StripHTMLForSearch(service.RedactGatedPostHTML(post.Content))
|
||
excerpt = service.TruncateRunes(plain, 120)
|
||
}
|
||
if needThumb {
|
||
thumb = firstImageSrc(post.Content)
|
||
}
|
||
|
||
rowClass := "post-row post-row--v2"
|
||
if titleOnly {
|
||
rowClass += " post-row--title-only"
|
||
}
|
||
if thumb != "" {
|
||
rowClass += " post-row--has-thumb"
|
||
}
|
||
|
||
b.WriteString(`<a class="` + rowClass + `" href="` + html.EscapeString(href) + `">`)
|
||
if post.User.Avatar != "" {
|
||
b.WriteString(`<span class="post-avatar user-link--avatar-only"><img src="` + html.EscapeString(post.User.Avatar) + `" alt="" loading="lazy" decoding="async"/></span>`)
|
||
} else {
|
||
b.WriteString(`<span class="post-avatar user-link--avatar-only">` + html.EscapeString(initial) + `</span>`)
|
||
}
|
||
|
||
if thumb != "" {
|
||
b.WriteString(`<div class="post-main post-main--with-thumb"><div class="post-content">`)
|
||
} else {
|
||
b.WriteString(`<div class="post-main"><div class="post-text">`)
|
||
}
|
||
|
||
b.WriteString(`<div class="post-title-row">`)
|
||
if post.Pinned {
|
||
b.WriteString(`<span class="post-pin-badge" title="全局置顶">全局置顶</span>`)
|
||
}
|
||
if post.BoardPinned {
|
||
b.WriteString(`<span class="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>`)
|
||
}
|
||
if post.Featured {
|
||
b.WriteString(`<span class="post-feature-badge" title="推荐">推荐</span>`)
|
||
}
|
||
if post.Status == model.ContentStatusPending {
|
||
b.WriteString(`<span class="post-status-badge post-status-badge--pending" title="审核中">审核中</span>`)
|
||
}
|
||
if post.Status == model.ContentStatusRejected {
|
||
b.WriteString(`<span class="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>`)
|
||
}
|
||
b.WriteString(`<span class="post-title">` + html.EscapeString(post.Title) + `</span>`)
|
||
hasTypeBadge := post.PostType == model.PostTypeQuestion ||
|
||
post.PostType == model.PostTypePoll ||
|
||
post.PostType == model.PostTypeLottery ||
|
||
(post.PostType == model.PostTypeBounty && ((post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0) ||
|
||
post.BountyStatus == model.BountyStatusAwarded))
|
||
if hasTypeBadge {
|
||
b.WriteString(`<span class="post-title-type-badges">`)
|
||
switch post.PostType {
|
||
case model.PostTypeQuestion:
|
||
if post.QuestionResolved {
|
||
b.WriteString(`<span class="post-qa-badge post-qa-badge--resolved" title="已解决">已解决</span>`)
|
||
} else {
|
||
b.WriteString(`<span class="post-qa-badge post-qa-badge--open" title="未解决">未解决</span>`)
|
||
}
|
||
case model.PostTypePoll:
|
||
b.WriteString(`<span class="post-type-badge post-type-badge--poll" title="投票">投票</span>`)
|
||
case model.PostTypeBounty:
|
||
if post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0 {
|
||
b.WriteString(`<span class="post-bounty-badge post-bounty-badge--open" title="悬赏">悬赏 ` + strconv.Itoa(post.BountyPoints) + `</span>`)
|
||
} else if post.BountyStatus == model.BountyStatusAwarded {
|
||
b.WriteString(`<span class="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>`)
|
||
}
|
||
case model.PostTypeLottery:
|
||
if post.LotteryStatus == model.PostLotteryStatusDrawn {
|
||
b.WriteString(`<span class="post-type-badge post-type-badge--lottery" title="抽奖">已开奖</span>`)
|
||
} else {
|
||
b.WriteString(`<span class="post-type-badge post-type-badge--lottery" title="抽奖">抽奖</span>`)
|
||
}
|
||
}
|
||
b.WriteString(`</span>`)
|
||
}
|
||
b.WriteString(`</div>`)
|
||
if excerpt != "" {
|
||
b.WriteString(`<p class="post-excerpt">` + html.EscapeString(excerpt) + `</p>`)
|
||
}
|
||
|
||
if thumb != "" {
|
||
b.WriteString(`<div class="post-meta post-meta--inline">`)
|
||
} else {
|
||
b.WriteString(`</div><div class="post-meta">`)
|
||
}
|
||
|
||
b.WriteString(`<div class="post-meta-left">`)
|
||
if currentBoard == 0 && post.Board.ID > 0 {
|
||
theme := boardThemeIndex(post.Board.ColorIndex, post.Board.ID)
|
||
// span 壳对齐 .post-list-board-btn 的 margin(外层已是 a,不能嵌 button)
|
||
b.WriteString(`<span class="post-list-board-btn"><span class="post-list-board-badge board-badge board-badge--` + strconv.Itoa(theme) + `">` + html.EscapeString(post.Board.Name) + `</span></span>`)
|
||
}
|
||
b.WriteString(`<span class="post-meta-author">` + html.EscapeString(author) + `</span>`)
|
||
b.WriteString(`<span class="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>`)
|
||
timeLabel := formatSSRRelativeTime(post.CreatedAt)
|
||
if sort == service.FeedSortReply && post.LastReplyAt == nil {
|
||
timeLabel = "暂无回复"
|
||
}
|
||
b.WriteString(`<span class="post-meta-time post-meta-time--created">` + html.EscapeString(timeLabel) + `</span>`)
|
||
lastName := ""
|
||
if post.LastReplyUser != nil {
|
||
lastName = service.DisplayName(post.LastReplyUser)
|
||
}
|
||
if lastName == "" {
|
||
lastName = strings.TrimSpace(post.LastReplyGuestNick)
|
||
}
|
||
if post.LastReplyAt != nil && lastName != "" {
|
||
// 外层已是 <a class="post-row">,回复人名只能用 span,禁止嵌套 a
|
||
b.WriteString(`<span class="post-meta-last-reply">`)
|
||
b.WriteString(`<span class="post-meta-last-reply-arrow" aria-hidden>←</span>`)
|
||
b.WriteString(`<span class="post-meta-last-reply-user">` + html.EscapeString(lastName) + `</span>`)
|
||
b.WriteString(`<span class="post-meta-last-reply-time">` + html.EscapeString(formatSSRRelativeTime(*post.LastReplyAt)) + `</span>`)
|
||
b.WriteString(`</span>`)
|
||
}
|
||
b.WriteString(`</div>`)
|
||
|
||
zero := ""
|
||
if post.CommentCount == 0 {
|
||
zero = " post-stat--zero"
|
||
}
|
||
b.WriteString(`<div class="post-stats"><span class="post-stat` + zero + `" title="评论">` + ssrIconMessageCircle() + strconv.Itoa(post.CommentCount) + `</span></div>`)
|
||
|
||
if thumb != "" {
|
||
b.WriteString(`</div></div><div class="post-aside"><div class="post-thumb" aria-hidden><img src="` + html.EscapeString(thumb) + `" alt="" loading="lazy" decoding="async"/></div></div>`)
|
||
} else {
|
||
b.WriteString(`</div>`)
|
||
}
|
||
b.WriteString(`</div></a>`)
|
||
}
|
||
|
||
func writeSSRAside(b *strings.Builder, boot homeBootPayload) {
|
||
b.WriteString(`<aside class="aside-panel"><div class="aside-panel-inner">`)
|
||
brandName := strings.TrimSpace(boot.Branding.Name)
|
||
if brandName == "" {
|
||
brandName = "姜十三论坛"
|
||
}
|
||
intro := strings.TrimSpace(boot.Branding.Description)
|
||
if intro == "" {
|
||
intro = strings.TrimSpace(boot.Branding.Slogan)
|
||
}
|
||
isSiteHome := boot.BoardID == 0 && boot.Keyword == "" && boot.Tag == "" && boot.Author == ""
|
||
|
||
b.WriteString(`<div class="widget-card widget-card--about"><div class="widget-card-body"><div class="widget-about-text">`)
|
||
if isSiteHome {
|
||
b.WriteString(`<h1 class="widget-about-title">` + html.EscapeString(brandName) + `</h1>`)
|
||
} else {
|
||
b.WriteString(`<p class="widget-about-title">` + html.EscapeString(brandName) + `</p>`)
|
||
}
|
||
if intro != "" {
|
||
b.WriteString(`<p class="widget-about-desc">` + html.EscapeString(intro) + `</p>`)
|
||
}
|
||
b.WriteString(`</div><div class="widget-stats" aria-label="论坛统计">`)
|
||
b.WriteString(`<div class="widget-stat"><span class="widget-stat-value">` + strconv.FormatInt(boot.Stats.Posts, 10) + `</span><span class="widget-stat-label">帖子</span></div>`)
|
||
b.WriteString(`<div class="widget-stat"><span class="widget-stat-value">` + strconv.FormatInt(boot.Stats.Comments, 10) + `</span><span class="widget-stat-label">回复</span></div>`)
|
||
b.WriteString(`<div class="widget-stat"><span class="widget-stat-value">` + strconv.FormatInt(boot.Stats.Users, 10) + `</span><span class="widget-stat-label">用户</span></div>`)
|
||
b.WriteString(`</div>`)
|
||
writeSSRCheckIn(b, boot)
|
||
b.WriteString(`</div></div>`)
|
||
|
||
for _, w := range boot.Limits.AsideWidgets {
|
||
if !w.Enabled {
|
||
continue
|
||
}
|
||
switch w.ID {
|
||
case service.AsideWidgetFriendLinks:
|
||
writeSSRFriendLinks(b, boot)
|
||
case service.AsideWidgetTagCloud:
|
||
writeSSRTagCloud(b, boot)
|
||
case service.AsideWidgetRecentComments:
|
||
writeSSRRecentComments(b, boot)
|
||
case service.AsideWidgetRecentUsers:
|
||
writeSSRRecentUsers(b, boot)
|
||
case service.AsideWidgetShowcase:
|
||
writeSSRShowcase(b, boot)
|
||
}
|
||
}
|
||
b.WriteString(`</div></aside>`)
|
||
}
|
||
|
||
func writeSSRCheckIn(b *strings.Builder, boot homeBootPayload) {
|
||
loggedIn := boot.User != nil && boot.User.ID > 0
|
||
if !loggedIn {
|
||
b.WriteString(`<div class="widget-checkin"><div class="widget-checkin-panel widget-checkin-panel--guest">`)
|
||
b.WriteString(`<div class="widget-checkin-main"><div class="widget-checkin-icon" aria-hidden="true">` + ssrIconCalendarCheck() + `</div>`)
|
||
b.WriteString(`<div class="widget-checkin-info"><span class="widget-checkin-title">每日签到</span>`)
|
||
b.WriteString(`<span class="widget-checkin-meta">登录后每日可得 5–15 积分</span></div></div>`)
|
||
b.WriteString(`<a class="widget-checkin-action" href="/login">` + ssrIconGift() + `登录签到</a>`)
|
||
b.WriteString(`</div></div>`)
|
||
return
|
||
}
|
||
if boot.CheckIn == nil {
|
||
return
|
||
}
|
||
st := boot.CheckIn
|
||
checkedIn := st.CheckedIn
|
||
panelClass := "widget-checkin-panel"
|
||
if checkedIn {
|
||
panelClass += " widget-checkin-panel--done"
|
||
}
|
||
b.WriteString(`<div class="widget-checkin"><div class="` + panelClass + `">`)
|
||
b.WriteString(`<div class="widget-checkin-main"><div class="widget-checkin-icon" aria-hidden="true">`)
|
||
if checkedIn {
|
||
b.WriteString(ssrIconCheck())
|
||
} else {
|
||
b.WriteString(ssrIconCalendarCheck())
|
||
}
|
||
b.WriteString(`</div><div class="widget-checkin-info">`)
|
||
title := "每日签到"
|
||
if checkedIn {
|
||
title = "今日已签到"
|
||
}
|
||
b.WriteString(`<span class="widget-checkin-title">` + title + `</span>`)
|
||
var meta string
|
||
if checkedIn {
|
||
if st.Streak > 0 {
|
||
meta = fmt.Sprintf("连续 %d 天 · 今日已获得 %d 积分", st.Streak, st.TodayPoints)
|
||
} else {
|
||
meta = fmt.Sprintf("今日已获得 %d 积分", st.TodayPoints)
|
||
}
|
||
} else if st.Streak > 0 {
|
||
meta = fmt.Sprintf("连续 %d 天 · 今日可得 %d 积分", st.Streak, st.TodayPoints)
|
||
} else {
|
||
meta = fmt.Sprintf("今日签到可得 %d 积分", st.TodayPoints)
|
||
}
|
||
b.WriteString(`<span class="widget-checkin-meta">` + html.EscapeString(meta) + `</span></div>`)
|
||
if !checkedIn {
|
||
b.WriteString(`<span class="widget-checkin-reward" aria-hidden="true">` + strconv.Itoa(st.TodayPoints) + `</span>`)
|
||
}
|
||
b.WriteString(`</div>`)
|
||
if !checkedIn {
|
||
b.WriteString(`<button type="button" class="widget-checkin-action" disabled>` + ssrIconGift() + `立即签到</button>`)
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
}
|
||
|
||
func writeSSRFriendLinks(b *strings.Builder, boot homeBootPayload) {
|
||
b.WriteString(`<div class="widget-card widget-card--friend-links">`)
|
||
b.WriteString(`<div class="widget-card-head widget-card-head--split"><span class="widget-card-head-main">`)
|
||
b.WriteString(ssrWidgetIconLink2())
|
||
b.WriteString(`<a class="widget-friend-links-title" href="/links">友情链接</a></span>`)
|
||
b.WriteString(`<a class="widget-friend-links-apply" href="/links?apply=1">申请</a></div>`)
|
||
b.WriteString(`<div class="widget-card-body widget-card-body--friend-links">`)
|
||
links := boot.Branding.FriendLinks
|
||
shown := 0
|
||
if len(links) == 0 {
|
||
b.WriteString(`<div class="widget-empty">暂无友情链接</div>`)
|
||
} else {
|
||
b.WriteString(`<ul class="widget-friend-links-list">`)
|
||
for _, link := range links {
|
||
name := strings.TrimSpace(link.Name)
|
||
u := strings.TrimSpace(link.URL)
|
||
if name == "" || u == "" {
|
||
continue
|
||
}
|
||
b.WriteString(`<li><a href="` + html.EscapeString(u) + `" target="_blank" rel="noopener noreferrer">` + html.EscapeString(name) + `</a></li>`)
|
||
shown++
|
||
if shown >= 8 {
|
||
break
|
||
}
|
||
}
|
||
b.WriteString(`</ul>`)
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
}
|
||
|
||
func writeSSRTagCloud(b *strings.Builder, boot homeBootPayload) {
|
||
b.WriteString(`<div class="widget-card widget-card--tags"><div class="widget-card-head">`)
|
||
b.WriteString(ssrWidgetIconTags())
|
||
b.WriteString(`标签云</div>`)
|
||
b.WriteString(`<div class="widget-card-body widget-card-body--tags">`)
|
||
if len(boot.Tags) == 0 {
|
||
b.WriteString(`<div class="widget-empty">暂无标签</div>`)
|
||
} else {
|
||
b.WriteString(`<div class="tag-cloud">`)
|
||
for _, t := range boot.Tags {
|
||
href := "/?tag=" + url.QueryEscape(t.Name)
|
||
b.WriteString(`<a class="tag-cloud-item" href="` + html.EscapeString(href) + `">` + html.EscapeString(t.Name) + `</a>`)
|
||
}
|
||
b.WriteString(`</div>`)
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
}
|
||
|
||
func writeSSRRecentComments(b *strings.Builder, boot homeBootPayload) {
|
||
b.WriteString(`<div class="widget-card"><div class="widget-card-head">`)
|
||
b.WriteString(ssrWidgetIconMessageCircle())
|
||
b.WriteString(`最新评论</div><div class="widget-card-body">`)
|
||
list := boot.RecentComments
|
||
if len(list) > 6 {
|
||
list = list[:6]
|
||
}
|
||
if len(list) == 0 {
|
||
b.WriteString(`<div class="widget-empty">暂无评论</div>`)
|
||
} else {
|
||
pl := boot.Limits
|
||
permalink := service.PermalinkConfig{Enabled: pl.PermalinkEnabled, Ext: pl.PermalinkExt}
|
||
for _, item := range list {
|
||
href := permalink.PostPath(item.PostID)
|
||
if item.Floor > 0 {
|
||
href += "#floor-" + strconv.Itoa(item.Floor)
|
||
}
|
||
initial := "?"
|
||
if r, _ := utf8.DecodeRuneInString(item.Author); r != utf8.RuneError {
|
||
initial = string(r)
|
||
}
|
||
b.WriteString(`<div class="widget-item widget-item--comment">`)
|
||
if item.Avatar != "" {
|
||
b.WriteString(`<span class="widget-item-avatar"><img src="` + html.EscapeString(item.Avatar) + `" alt="" loading="lazy"/></span>`)
|
||
} else {
|
||
b.WriteString(`<span class="widget-item-avatar">` + html.EscapeString(initial) + `</span>`)
|
||
}
|
||
b.WriteString(`<a class="widget-item-comment-main" href="` + html.EscapeString(href) + `">`)
|
||
b.WriteString(`<span class="widget-item-title">` + html.EscapeString(item.Excerpt) + `</span>`)
|
||
b.WriteString(`<span class="widget-item-meta">`)
|
||
if createdAt, err := time.Parse(time.RFC3339, item.CreatedAt); err == nil {
|
||
b.WriteString(`<span class="widget-item-time" title="` + html.EscapeString(formatSSRShortDateTime(createdAt)) + `">` + html.EscapeString(formatSSRRelativeTime(createdAt)) + `</span>`)
|
||
}
|
||
if item.PostTitle != "" {
|
||
b.WriteString(`<span class="widget-item-post-title">` + html.EscapeString(item.PostTitle) + `</span>`)
|
||
}
|
||
b.WriteString(`</span></a></div>`)
|
||
}
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
}
|
||
|
||
func writeSSRRecentUsers(b *strings.Builder, boot homeBootPayload) {
|
||
b.WriteString(`<div class="widget-card widget-card--users"><div class="widget-card-head">`)
|
||
b.WriteString(ssrWidgetIconUserPlus())
|
||
b.WriteString(`最新注册</div>`)
|
||
b.WriteString(`<div class="widget-card-body widget-card-body--users">`)
|
||
if len(boot.RecentUsers) == 0 {
|
||
b.WriteString(`<div class="widget-empty">暂无用户</div>`)
|
||
} else {
|
||
pl := service.PermalinkConfig{Enabled: boot.Limits.PermalinkEnabled, Ext: boot.Limits.PermalinkExt}
|
||
b.WriteString(`<div class="widget-recent-users-grid">`)
|
||
for _, u := range boot.RecentUsers {
|
||
initial := "?"
|
||
if r, _ := utf8.DecodeRuneInString(u.Nickname); r != utf8.RuneError {
|
||
initial = string(r)
|
||
}
|
||
b.WriteString(`<a class="widget-recent-user-cell" href="` + html.EscapeString(pl.UserPath(u.ID)) + `">`)
|
||
b.WriteString(`<span class="widget-recent-user-avatar" aria-hidden>`)
|
||
if u.Avatar != "" {
|
||
b.WriteString(`<img src="` + html.EscapeString(u.Avatar) + `" alt="" loading="lazy"/>`)
|
||
} else {
|
||
b.WriteString(html.EscapeString(initial))
|
||
}
|
||
b.WriteString(`</span><span class="widget-recent-user-name">` + html.EscapeString(u.Nickname) + `</span></a>`)
|
||
}
|
||
b.WriteString(`</div>`)
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
}
|
||
|
||
func writeSSRShowcase(b *strings.Builder, boot homeBootPayload) {
|
||
b.WriteString(`<div class="widget-card widget-card--showcase">`)
|
||
b.WriteString(`<div class="widget-card-head widget-card-head--split"><span class="widget-card-head-main">`)
|
||
b.WriteString(ssrWidgetIconEarth())
|
||
b.WriteString(`<a class="widget-friend-links-title" href="/showcase">开源展柜</a></span>`)
|
||
b.WriteString(`<a class="widget-friend-links-more" href="/showcase">全部</a></div>`)
|
||
b.WriteString(`<div class="widget-card-body">`)
|
||
if len(boot.Showcase) == 0 {
|
||
b.WriteString(`<div class="widget-empty">暂无展柜站点</div>`)
|
||
} else {
|
||
n := len(boot.Showcase)
|
||
if n > 6 {
|
||
n = 6
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
item := boot.Showcase[i]
|
||
b.WriteString(`<a class="widget-item" href="` + html.EscapeString(item.SiteURL) + `" target="_blank" rel="noopener noreferrer">`)
|
||
b.WriteString(`<span class="widget-item-title">` + html.EscapeString(item.SiteName) + `</span>`)
|
||
if item.Version != "" {
|
||
b.WriteString(`<span class="widget-item-meta">` + html.EscapeString(item.Version) + `</span>`)
|
||
}
|
||
b.WriteString(`</a>`)
|
||
}
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
}
|
||
|
||
func writeSSRFooter(b *strings.Builder, boot homeBootPayload, meta homeSSRMeta) {
|
||
brandName := strings.TrimSpace(boot.Branding.Name)
|
||
if brandName == "" {
|
||
brandName = "姜十三论坛"
|
||
}
|
||
year := time.Now().Year()
|
||
b.WriteString(`<footer class="site-footer"><div class="site-footer__inner">`)
|
||
b.WriteString(`<div class="site-footer__meta"><span class="site-footer__copy">© ` + strconv.Itoa(year) + ` ` + html.EscapeString(brandName) + `</span>`)
|
||
if s := strings.TrimSpace(boot.Branding.Slogan); s != "" {
|
||
b.WriteString(`<span class="site-footer__sep" aria-hidden>·</span><span class="site-footer__slogan">` + html.EscapeString(s) + `</span>`)
|
||
}
|
||
b.WriteString(`</div><nav class="site-footer__nav" aria-label="站点链接">`)
|
||
first := true
|
||
writeSep := func() {
|
||
if !first {
|
||
b.WriteString(`<span class="site-footer__sep" aria-hidden>·</span>`)
|
||
}
|
||
first = false
|
||
}
|
||
if boot.Limits.FooterShowFriendLinks {
|
||
writeSep()
|
||
b.WriteString(`<span class="site-footer__friend"><a href="/links">友情链接</a></span>`)
|
||
}
|
||
if boot.Limits.FooterShowShowcase {
|
||
writeSep()
|
||
b.WriteString(`<span class="site-footer__friend"><a href="/showcase">开源展柜</a></span>`)
|
||
}
|
||
for _, p := range boot.Pages {
|
||
if !p.ShowInFooter {
|
||
continue
|
||
}
|
||
writeSep()
|
||
b.WriteString(`<span class="site-footer__friend"><a href="` + html.EscapeString(meta.permalink.PagePath(p.Slug)) + `">` + html.EscapeString(p.Title) + `</a></span>`)
|
||
}
|
||
if icp := strings.TrimSpace(boot.Branding.ICPBeian); icp != "" {
|
||
writeSep()
|
||
icpURL := strings.TrimSpace(boot.Branding.ICPBeianURL)
|
||
if icpURL == "" {
|
||
icpURL = "https://beian.miit.gov.cn/"
|
||
}
|
||
b.WriteString(`<a class="site-footer__icp" href="` + html.EscapeString(icpURL) + `" target="_blank" rel="noopener noreferrer">` + html.EscapeString(icp) + `</a>`)
|
||
}
|
||
b.WriteString(`</nav></div></footer>`)
|
||
}
|
||
|
||
func boardThemeIndex(colorIndex int, id uint) int {
|
||
const n = 8
|
||
if colorIndex >= 0 {
|
||
return colorIndex % n
|
||
}
|
||
return int(id % uint(n))
|
||
}
|
||
|
||
func firstImageSrc(htmlContent string) string {
|
||
m := firstImgSrcRe.FindStringSubmatch(htmlContent)
|
||
if len(m) < 2 {
|
||
return ""
|
||
}
|
||
src := strings.TrimSpace(m[1])
|
||
if src == "" || strings.HasPrefix(src, "data:") {
|
||
return ""
|
||
}
|
||
return src
|
||
}
|
||
|
||
func formatSSRTime(t time.Time) string {
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
return t.Local().Format("01-02 15:04")
|
||
}
|