feat: add article comment system with moderation
- Comment model with nested replies (ParentID), dual authorship (logged-in UserID / anonymous GuestToken cookie), email hash for Gravatar, private flag, and moderation status - CommentConfig singleton (enabled / allow guest / guest-require-approval / use Gravatar) cached like the other platform config - Markdown comments with built-in emoji picker, preview, and markdown help; rendered client-side via marked + DOMPurify, with server-side HTML/dangerous-scheme stripping as a first XSS defense - Private comments visible only to admin and the author; pending comments visible only to admin and the author - Admin moderation list (pending/approved/rejected/all tabs) with approve/reject/delete and a pending-count badge - Comment settings page with the four toggles - One-time session flash notice (auto-dismissed after 4s) so the "comment posted" banner no longer persists across refreshes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@ go_blog
|
|||||||
go_blog.exe
|
go_blog.exe
|
||||||
main.exe
|
main.exe
|
||||||
|
|
||||||
|
# fresh (live-reload) build output
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Auto-generated runtime data (config + database + uploads)
|
# Auto-generated runtime data (config + database + uploads)
|
||||||
win/
|
win/
|
||||||
mac/
|
mac/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
root: .
|
||||||
|
tmp_path: ./tmp
|
||||||
|
build_args: -o ./tmp/go_blog
|
||||||
|
build_log: ./tmp/build.log
|
||||||
|
valid_ext: .go, .html
|
||||||
|
build_delay: 300
|
||||||
|
command: ./tmp/go_blog
|
||||||
|
command_args:
|
||||||
|
kill_delay: 500
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go_blog/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminCommentPageSize bounds the number of comments shown per admin page.
|
||||||
|
const adminCommentPageSize = 30
|
||||||
|
|
||||||
|
// commentListView is a Comment plus derived display fields for the admin list.
|
||||||
|
type commentListView struct {
|
||||||
|
models.Comment
|
||||||
|
GravatarURL string
|
||||||
|
MaskedEmail string
|
||||||
|
StatusLabel string
|
||||||
|
StatusBadge string
|
||||||
|
ArticleTitle string
|
||||||
|
ArticleSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentListPage renders the admin comment moderation list, filtered by
|
||||||
|
// status via ?status=pending|approved|rejected|all.
|
||||||
|
func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tr := getTr(c)
|
||||||
|
status := strings.TrimSpace(c.Query("status"))
|
||||||
|
if status == "" {
|
||||||
|
status = "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
q := db.Model(&models.Comment{}).Order("created_at DESC")
|
||||||
|
switch status {
|
||||||
|
case "approved":
|
||||||
|
q = q.Where("status = ?", models.CommentApproved)
|
||||||
|
case "rejected":
|
||||||
|
q = q.Where("status = ?", models.CommentRejected)
|
||||||
|
case "all":
|
||||||
|
// no filter
|
||||||
|
default: // pending
|
||||||
|
status = "pending"
|
||||||
|
q = q.Where("status = ?", models.CommentPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
var comments []models.Comment
|
||||||
|
q.Limit(adminCommentPageSize).Find(&comments)
|
||||||
|
|
||||||
|
// Pull referenced articles in one query to avoid N+1.
|
||||||
|
ids := make(map[uint]struct{})
|
||||||
|
for _, cm := range comments {
|
||||||
|
ids[cm.ArticleID] = struct{}{}
|
||||||
|
}
|
||||||
|
articleMap := make(map[uint]models.Article)
|
||||||
|
if len(ids) > 0 {
|
||||||
|
idList := make([]uint, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
idList = append(idList, id)
|
||||||
|
}
|
||||||
|
var articles []models.Article
|
||||||
|
db.Unscoped().Where("id IN ?", idList).Find(&articles)
|
||||||
|
for _, a := range articles {
|
||||||
|
articleMap[a.ID] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]commentListView, 0, len(comments))
|
||||||
|
for _, cm := range comments {
|
||||||
|
v := commentListView{
|
||||||
|
Comment: cm,
|
||||||
|
GravatarURL: cm.GravatarURL(40),
|
||||||
|
MaskedEmail: cm.MaskedEmail(),
|
||||||
|
}
|
||||||
|
if a, ok := articleMap[cm.ArticleID]; ok {
|
||||||
|
v.ArticleTitle = a.Title
|
||||||
|
v.ArticleSlug = a.Slug
|
||||||
|
}
|
||||||
|
switch cm.Status {
|
||||||
|
case models.CommentPending:
|
||||||
|
v.StatusLabel = tr["comment_status_pending"]
|
||||||
|
v.StatusBadge = "bg-yellow-100 text-yellow-700"
|
||||||
|
case models.CommentApproved:
|
||||||
|
v.StatusLabel = tr["comment_status_approved"]
|
||||||
|
v.StatusBadge = "bg-green-100 text-green-700"
|
||||||
|
case models.CommentRejected:
|
||||||
|
v.StatusLabel = tr["comment_status_rejected"]
|
||||||
|
v.StatusBadge = "bg-red-100 text-red-700"
|
||||||
|
}
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending count for the tab badge.
|
||||||
|
var pendingCount int64
|
||||||
|
db.Model(&models.Comment{}).Where("status = ?", models.CommentPending).Count(&pendingCount)
|
||||||
|
|
||||||
|
data := DefaultData(c)
|
||||||
|
data["Title"] = tr["admin_comments_title"]
|
||||||
|
data["Comments"] = views
|
||||||
|
data["Status"] = status
|
||||||
|
data["PendingCount"] = pendingCount
|
||||||
|
if msg := c.Query("saved"); msg == "1" {
|
||||||
|
switch c.Query("msg") {
|
||||||
|
case "approved":
|
||||||
|
data["Success"] = tr["comment_approved"]
|
||||||
|
case "rejected":
|
||||||
|
data["Success"] = tr["comment_rejected"]
|
||||||
|
case "deleted":
|
||||||
|
data["Success"] = tr["comment_deleted"]
|
||||||
|
default:
|
||||||
|
data["Success"] = tr["settings_saved"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "comment_list", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentApprove marks a comment approved.
|
||||||
|
func CommentApprove(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if id := c.Param("id"); id != "" {
|
||||||
|
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentApproved)
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=approved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentReject marks a comment rejected (hidden from the front end, retained
|
||||||
|
// in the admin list).
|
||||||
|
func CommentReject(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if id := c.Param("id"); id != "" {
|
||||||
|
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentRejected)
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentDelete soft-deletes a comment.
|
||||||
|
func CommentDelete(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if id := c.Param("id"); id != "" {
|
||||||
|
db.Where("id = ?", id).Delete(&models.Comment{})
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/admin/comments?status=all&saved=1&msg=deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"go_blog/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxCommentLength bounds the size of a single comment body, in characters.
|
||||||
|
const MaxCommentLength = 4000
|
||||||
|
|
||||||
|
// guestCookieName is the long-lived cookie used to identify anonymous
|
||||||
|
// commenters so they can see their own pending/private comments.
|
||||||
|
const guestCookieName = "comment_uid"
|
||||||
|
const guestCookieMaxAge = 365 * 24 * 3600 // one year
|
||||||
|
|
||||||
|
// htmlTagPattern matches any HTML/XML tag so it can be stripped from comment
|
||||||
|
// markdown before storage. Markdown syntax itself contains no angle brackets
|
||||||
|
// in a form that would collide (the only such construct is autolinks like
|
||||||
|
// <http://…>, which are rare in comments and acceptable to lose).
|
||||||
|
var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||||
|
|
||||||
|
// dangerousSchemePattern matches dangerous URL schemes inside markdown link
|
||||||
|
// targets that could execute script when rendered to innerHTML.
|
||||||
|
var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`)
|
||||||
|
|
||||||
|
// commentForm holds the parsed values of a submitted comment form so the
|
||||||
|
// template can refill the inputs after a validation failure.
|
||||||
|
type commentForm struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Website string
|
||||||
|
Content string
|
||||||
|
IsPrivate bool
|
||||||
|
ParentID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeMarkdown strips HTML tags and dangerous URL schemes from a comment
|
||||||
|
// body before storage. Markdown syntax is preserved so the frontend can render
|
||||||
|
// it. This is the first of two XSS defenses; the frontend also runs the output
|
||||||
|
// through marked + DOMPurify.
|
||||||
|
func sanitizeMarkdown(s string) string {
|
||||||
|
s = htmlTagPattern.ReplaceAllString(s, "")
|
||||||
|
s = dangerousSchemePattern.ReplaceAllString(s, "#")
|
||||||
|
// Collapse runs of more than two newlines.
|
||||||
|
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGuestToken generates a random hex token for an anonymous commenter.
|
||||||
|
func newGuestToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// Extremely unlikely; fall back to a timestamp-based token.
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guestTokenFrom reads the anonymous commenter cookie, creating and setting a
|
||||||
|
// new one when absent. The token is returned for storage on the comment.
|
||||||
|
func guestTokenFrom(c *gin.Context) string {
|
||||||
|
token, _ := c.Cookie(guestCookieName)
|
||||||
|
if token == "" {
|
||||||
|
token = newGuestToken()
|
||||||
|
}
|
||||||
|
// (Re)set the cookie so returning visitors keep their identity. HttpOnly
|
||||||
|
// prevents JS access; SameSite=Lax is the gin default and is appropriate.
|
||||||
|
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", false, true)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailHash returns the md5 of a lowercased, trimmed email, per the Gravatar
|
||||||
|
// spec.
|
||||||
|
func emailHash(email string) string {
|
||||||
|
h := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostComment handles submission of a new comment (or reply) on an article.
|
||||||
|
func PostComment(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tr := getTr(c)
|
||||||
|
slug := c.Param("slug")
|
||||||
|
|
||||||
|
var article models.Article
|
||||||
|
if err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).First(&article).Error; err != nil {
|
||||||
|
data := DefaultData(c)
|
||||||
|
data["Title"] = tr["article_not_found"]
|
||||||
|
c.HTML(http.StatusNotFound, "article_not_found", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := models.GetCommentConfig()
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_disabled"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn, _ := c.Get("is_logged_in")
|
||||||
|
loggedIn, _ := isLoggedIn.(bool)
|
||||||
|
if !loggedIn && !cfg.AllowGuest {
|
||||||
|
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_guests_disabled"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := commentForm{
|
||||||
|
Name: strings.TrimSpace(c.PostForm("name")),
|
||||||
|
Email: strings.TrimSpace(c.PostForm("email")),
|
||||||
|
Website: strings.TrimSpace(c.PostForm("website")),
|
||||||
|
Content: strings.TrimSpace(c.PostForm("content")),
|
||||||
|
IsPrivate: c.PostForm("is_private") == "1",
|
||||||
|
ParentID: strings.TrimSpace(c.PostForm("parent_id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
if form.Name == "" || len(form.Name) > 64 {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if form.Email == "" {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_required_email"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(form.Email); err != nil {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_invalid_email"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if form.Website != "" {
|
||||||
|
u, err := url.Parse(form.Website)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_invalid_url"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if form.Content == "" {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len([]rune(form.Content)) > MaxCommentLength {
|
||||||
|
renderArticleDetail(c, db, &article, form, fmt.Sprintf(tr["comments_too_long"], MaxCommentLength), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parent validation ---
|
||||||
|
var parentID *uint
|
||||||
|
if form.ParentID != "" {
|
||||||
|
pid, err := strconv.ParseUint(form.ParentID, 10, 64)
|
||||||
|
if err != nil || pid == 0 {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parent models.Comment
|
||||||
|
if err := db.Where("id = ? AND article_id = ? AND status = ?", pid, article.ID, models.CommentApproved).First(&parent).Error; err != nil {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := uint(pid)
|
||||||
|
parentID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Build comment ---
|
||||||
|
comment := models.Comment{
|
||||||
|
ArticleID: article.ID,
|
||||||
|
ParentID: parentID,
|
||||||
|
AuthorName: form.Name,
|
||||||
|
Email: form.Email,
|
||||||
|
EmailHash: emailHash(form.Email),
|
||||||
|
Website: form.Website,
|
||||||
|
Content: sanitizeMarkdown(form.Content),
|
||||||
|
IsPrivate: form.IsPrivate,
|
||||||
|
Status: models.CommentApproved,
|
||||||
|
IPAddress: truncate(c.ClientIP(), 64),
|
||||||
|
UserAgent: truncate(c.GetHeader("User-Agent"), 512),
|
||||||
|
}
|
||||||
|
|
||||||
|
if loggedIn {
|
||||||
|
uid := userIDFromSession(c)
|
||||||
|
if uid != 0 {
|
||||||
|
comment.UserID = &uid
|
||||||
|
}
|
||||||
|
comment.Status = models.CommentApproved
|
||||||
|
} else {
|
||||||
|
comment.GuestToken = guestTokenFrom(c)
|
||||||
|
if cfg.GuestRequireApproval {
|
||||||
|
comment.Status = models.CommentPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&comment).Error; err != nil {
|
||||||
|
renderArticleDetail(c, db, &article, form, tr["article_error"], "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor := fmt.Sprintf("#comment-%d", comment.ID)
|
||||||
|
if comment.Status == models.CommentPending {
|
||||||
|
setCommentFlash(c, getTr(c)["comments_pending_notice"])
|
||||||
|
} else {
|
||||||
|
setCommentFlash(c, getTr(c)["comments_posted"])
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/article/"+slug+anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate clips s to at most n runes.
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentViewer describes the identity of the current request for the purposes
|
||||||
|
// of comment visibility.
|
||||||
|
type commentViewer struct {
|
||||||
|
userID *uint
|
||||||
|
isAdmin bool
|
||||||
|
guestToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewerFromContext builds a commentViewer from the request/session.
|
||||||
|
func viewerFromContext(c *gin.Context) commentViewer {
|
||||||
|
v := commentViewer{}
|
||||||
|
if uid := userIDFromSession(c); uid != 0 {
|
||||||
|
v.userID = &uid
|
||||||
|
}
|
||||||
|
if role, ok := c.Get("role"); ok {
|
||||||
|
if r, _ := role.(string); r == models.RoleAdmin {
|
||||||
|
v.isAdmin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.guestToken, _ = c.Cookie(guestCookieName)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// canSee reports whether the viewer is allowed to see one comment.
|
||||||
|
func (v commentViewer) canSee(c *models.Comment) bool {
|
||||||
|
switch c.Status {
|
||||||
|
case models.CommentApproved:
|
||||||
|
if !c.IsPrivate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Private: admin or author only.
|
||||||
|
return v.isAdmin || v.owns(c)
|
||||||
|
case models.CommentPending:
|
||||||
|
return v.isAdmin || v.owns(c)
|
||||||
|
case models.CommentRejected:
|
||||||
|
return v.isAdmin
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// owns reports whether the viewer is the author of the comment.
|
||||||
|
func (v commentViewer) owns(c *models.Comment) bool {
|
||||||
|
if v.userID != nil && c.UserID != nil && *v.userID == *c.UserID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if v.guestToken != "" && c.GuestToken != "" && v.guestToken == c.GuestToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentNode is a comment plus its rendered children, used by the template's
|
||||||
|
// recursive comment_node block. Tr/UseGravatar/AvatarColor are propagated to
|
||||||
|
// every node so the recursive template can render badges and avatars without
|
||||||
|
// reaching back to the page-level data (inside a {{template}} invocation, $
|
||||||
|
// binds to the node, not the page data).
|
||||||
|
type CommentNode struct {
|
||||||
|
Comment models.Comment
|
||||||
|
Children []CommentNode
|
||||||
|
Depth int
|
||||||
|
RelTime string
|
||||||
|
Tr map[string]string
|
||||||
|
UseGravatar bool
|
||||||
|
AvatarColor string
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarPalette is the set of background colors used for text-initial avatars
|
||||||
|
// when Gravatar is disabled.
|
||||||
|
var avatarPalette = []string{
|
||||||
|
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||||
|
"#8b5cf6", "#ec4899", "#14b8a6", "#6366f1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarColorFor returns a deterministic palette color for a comment ID.
|
||||||
|
func avatarColorFor(id uint) string {
|
||||||
|
if len(avatarPalette) == 0 {
|
||||||
|
return "#3b82f6"
|
||||||
|
}
|
||||||
|
return avatarPalette[int(id)%len(avatarPalette)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCommentTree filters comments by visibility and assembles them into a
|
||||||
|
// nested tree ordered by creation time. tr and useGravatar are propagated to
|
||||||
|
// every node for template rendering.
|
||||||
|
func buildCommentTree(comments []models.Comment, viewer commentViewer, tr map[string]string, useGravatar bool) []CommentNode {
|
||||||
|
visible := make([]models.Comment, 0, len(comments))
|
||||||
|
for i := range comments {
|
||||||
|
if viewer.canSee(&comments[i]) {
|
||||||
|
visible = append(visible, comments[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byParent := make(map[uint][]models.Comment)
|
||||||
|
var roots []models.Comment
|
||||||
|
for _, c := range visible {
|
||||||
|
if c.ParentID == nil {
|
||||||
|
roots = append(roots, c)
|
||||||
|
} else {
|
||||||
|
byParent[*c.ParentID] = append(byParent[*c.ParentID], c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes := make([]CommentNode, 0, len(roots))
|
||||||
|
for _, r := range roots {
|
||||||
|
nodes = append(nodes, buildCommentNode(r, byParent, 1, tr, useGravatar))
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCommentNode(c models.Comment, byParent map[uint][]models.Comment, depth int, tr map[string]string, useGravatar bool) CommentNode {
|
||||||
|
node := CommentNode{
|
||||||
|
Comment: c,
|
||||||
|
Depth: depth,
|
||||||
|
RelTime: relativeTime(c.CreatedAt),
|
||||||
|
Tr: tr,
|
||||||
|
UseGravatar: useGravatar,
|
||||||
|
AvatarColor: avatarColorFor(c.ID),
|
||||||
|
}
|
||||||
|
for _, child := range byParent[c.ID] {
|
||||||
|
node.Children = append(node.Children, buildCommentNode(child, byParent, depth+1, tr, useGravatar))
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// relativeTime returns a coarse human-readable age for a comment timestamp,
|
||||||
|
// falling back to an absolute date for anything older than a day.
|
||||||
|
func relativeTime(t time.Time) string {
|
||||||
|
d := time.Since(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return t.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go_blog/models"
|
"go_blog/models"
|
||||||
|
|
||||||
@@ -53,18 +54,44 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
|||||||
db.Model(&models.Article{}).Where("id = ?", article.ID).
|
db.Model(&models.Article{}).Where("id = ?", article.ID).
|
||||||
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||||
|
|
||||||
|
// One-time flash notice (set by PostComment on success/pending). Reading
|
||||||
|
// consumes the flash, so refreshing the page no longer re-shows it.
|
||||||
|
notice := readCommentFlash(c)
|
||||||
|
renderArticleDetail(c, db, &article, commentForm{}, "", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderArticleDetail renders the article page, including the comment section.
|
||||||
|
// formErr refills the form with an error banner; notice is a one-time
|
||||||
|
// success/pending banner (already consumed from the session by the caller).
|
||||||
|
func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, form commentForm, formErr, notice string) {
|
||||||
|
tr := getTr(c)
|
||||||
|
|
||||||
authorName := article.Author.Username
|
authorName := article.Author.Username
|
||||||
if article.Author.DisplayName != "" {
|
if article.Author.DisplayName != "" {
|
||||||
authorName = article.Author.DisplayName
|
authorName = article.Author.DisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewer := viewerFromContext(c)
|
||||||
|
|
||||||
|
var comments []models.Comment
|
||||||
|
db.Where("article_id = ?", article.ID).Order("created_at ASC").Find(&comments)
|
||||||
|
cfg := models.GetCommentConfig()
|
||||||
|
useGravatar := cfg != nil && cfg.UseGravatar
|
||||||
|
tree := buildCommentTree(comments, viewer, tr, useGravatar)
|
||||||
|
|
||||||
data := DefaultData(c)
|
data := DefaultData(c)
|
||||||
data["Title"] = article.Title
|
data["Title"] = article.Title
|
||||||
data["Article"] = article
|
data["Article"] = article
|
||||||
data["AuthorName"] = authorName
|
data["AuthorName"] = authorName
|
||||||
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
|
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
|
||||||
|
data["Comments"] = tree
|
||||||
|
data["CommentConfig"] = models.GetCommentConfig()
|
||||||
|
data["CommentForm"] = form
|
||||||
|
data["CommentError"] = formErr
|
||||||
|
data["CommentNotice"] = notice
|
||||||
|
data["MaxCommentLength"] = MaxCommentLength
|
||||||
c.HTML(http.StatusOK, "article", data)
|
c.HTML(http.StatusOK, "article", data)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatPublishTime returns the publication time as a readable string, falling
|
// formatPublishTime returns the publication time as a readable string, falling
|
||||||
@@ -75,3 +102,27 @@ func formatPublishTime(publishedAt *time.Time) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commentFlashKey is the session key for the one-time comment notice.
|
||||||
|
const commentFlashKey = "comment_flash"
|
||||||
|
|
||||||
|
// setCommentFlash stores a one-time comment notice in the session so the
|
||||||
|
// following GET /article/:slug (after the POST/redirect) can show it once and
|
||||||
|
// never again on refresh.
|
||||||
|
func setCommentFlash(c *gin.Context, value string) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(commentFlashKey, value)
|
||||||
|
session.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCommentFlash returns and clears the one-time comment notice, if any.
|
||||||
|
func readCommentFlash(c *gin.Context) string {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
v, ok := session.Get(commentFlashKey).(string)
|
||||||
|
if ok && v != "" {
|
||||||
|
session.Delete(commentFlashKey)
|
||||||
|
session.Save()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -334,3 +334,43 @@ func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
|||||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||||
db.Delete(&models.DownloadBaseURL{}, id)
|
db.Delete(&models.DownloadBaseURL{}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Comment settings ----------------
|
||||||
|
|
||||||
|
// CommentSettingsPage renders the comment policy form.
|
||||||
|
func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tr := getTr(c)
|
||||||
|
var cc models.CommentConfig
|
||||||
|
if err := db.First(&cc, 1).Error; err != nil {
|
||||||
|
cc = *models.GetCommentConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
data := DefaultData(c)
|
||||||
|
data["Title"] = tr["comment_settings_title"]
|
||||||
|
data["CommentConfig"] = cc
|
||||||
|
if msg := c.Query("saved"); msg == "1" {
|
||||||
|
data["Success"] = tr["settings_saved"]
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "settings_comment", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentSettingsSave persists the comment policy toggles and refreshes the
|
||||||
|
// in-memory cache so subsequent requests see the change.
|
||||||
|
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var cc models.CommentConfig
|
||||||
|
if err := db.First(&cc, 1).Error; err != nil {
|
||||||
|
cc = models.CommentConfig{ID: 1}
|
||||||
|
}
|
||||||
|
cc.Enabled = c.PostForm("enabled") == "1"
|
||||||
|
cc.AllowGuest = c.PostForm("allow_guest") == "1"
|
||||||
|
cc.GuestRequireApproval = c.PostForm("guest_require_approval") == "1"
|
||||||
|
cc.UseGravatar = c.PostForm("use_gravatar") == "1"
|
||||||
|
cc.UpdatedBy = userIDFromSession(c)
|
||||||
|
db.Save(&cc)
|
||||||
|
models.RefreshConfigCache(db)
|
||||||
|
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+142
@@ -198,6 +198,77 @@ var translations = map[Lang]map[string]string{
|
|||||||
"cat_archive": "Archive",
|
"cat_archive": "Archive",
|
||||||
"cat_video": "Video",
|
"cat_video": "Video",
|
||||||
"cat_other": "Other",
|
"cat_other": "Other",
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
"comments_title": "Comments",
|
||||||
|
"comments_count": "%d Comments",
|
||||||
|
"comments_empty": "No comments yet. Be the first to comment.",
|
||||||
|
"comments_reply": "Reply",
|
||||||
|
"comments_cancel_reply": "Cancel reply",
|
||||||
|
"comments_replying_to": "Replying to %s",
|
||||||
|
"comments_name": "Name",
|
||||||
|
"comments_email": "Email",
|
||||||
|
"comments_email_hint": "Used for your Gravatar avatar; never shown publicly.",
|
||||||
|
"comments_website": "Website (optional)",
|
||||||
|
"comments_content": "Comment",
|
||||||
|
"comments_content_ph": "Markdown is supported...",
|
||||||
|
"comments_private": "Private comment",
|
||||||
|
"comments_private_hint": "Only you and the administrator can see this.",
|
||||||
|
"comments_preview": "Preview",
|
||||||
|
"comments_edit": "Edit",
|
||||||
|
"comments_emoji": "Emoji",
|
||||||
|
"comments_markdown_help": "Markdown help",
|
||||||
|
"comments_submit": "Post Comment",
|
||||||
|
"comments_posted": "Comment posted.",
|
||||||
|
"comments_pending_notice": "Your comment is awaiting moderation.",
|
||||||
|
"comments_disabled": "Comments are disabled.",
|
||||||
|
"comments_guests_disabled": "You must sign in to comment.",
|
||||||
|
"comments_required_name": "Name is required.",
|
||||||
|
"comments_required_email": "Email is required.",
|
||||||
|
"comments_required_content":"Comment is required.",
|
||||||
|
"comments_invalid_email": "Please enter a valid email address.",
|
||||||
|
"comments_invalid_url": "Please enter a valid URL.",
|
||||||
|
"comments_too_long": "Comment is too long (max %d characters).",
|
||||||
|
"comments_pending": "Pending",
|
||||||
|
"comments_approved": "Approved",
|
||||||
|
"comments_rejected": "Rejected",
|
||||||
|
"comments_private_badge": "Private",
|
||||||
|
"comments_markdown_bold": "**bold**",
|
||||||
|
"comments_markdown_italic": "*italic*",
|
||||||
|
"comments_markdown_code": "`code`",
|
||||||
|
"comments_markdown_link": "[text](url)",
|
||||||
|
"comments_markdown_quote": "> quote",
|
||||||
|
|
||||||
|
// Admin: comments
|
||||||
|
"admin_comments": "Comments",
|
||||||
|
"admin_comments_title": "Comments",
|
||||||
|
"comment_status_pending": "Pending",
|
||||||
|
"comment_status_approved": "Approved",
|
||||||
|
"comment_status_rejected": "Rejected",
|
||||||
|
"comment_status_all": "All",
|
||||||
|
"comment_approve": "Approve",
|
||||||
|
"comment_reject": "Reject",
|
||||||
|
"comment_delete": "Delete",
|
||||||
|
"comment_delete_confirm": "Delete this comment?",
|
||||||
|
"comment_col_author": "Author",
|
||||||
|
"comment_col_article": "Article",
|
||||||
|
"comment_col_content": "Content",
|
||||||
|
"comment_col_status": "Status",
|
||||||
|
"comment_col_time": "Time",
|
||||||
|
"comment_col_actions": "Actions",
|
||||||
|
"comment_empty": "No comments.",
|
||||||
|
"comment_approved": "Comment approved.",
|
||||||
|
"comment_rejected": "Comment rejected.",
|
||||||
|
"comment_deleted": "Comment deleted.",
|
||||||
|
|
||||||
|
// Admin: comment settings
|
||||||
|
"comment_settings_title": "Comment Settings",
|
||||||
|
"comment_settings_desc": "Control whether comments are enabled and how they are moderated.",
|
||||||
|
"comment_settings_enabled": "Enable comments",
|
||||||
|
"comment_settings_allow_guest": "Allow anonymous comments",
|
||||||
|
"comment_settings_guest_approval": "Require approval for guest comments",
|
||||||
|
"comment_settings_use_gravatar": "Use Gravatar avatars",
|
||||||
|
"comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.",
|
||||||
},
|
},
|
||||||
ZH: {
|
ZH: {
|
||||||
// 导航
|
// 导航
|
||||||
@@ -381,6 +452,77 @@ var translations = map[Lang]map[string]string{
|
|||||||
"cat_archive": "压缩包",
|
"cat_archive": "压缩包",
|
||||||
"cat_video": "视频",
|
"cat_video": "视频",
|
||||||
"cat_other": "其他",
|
"cat_other": "其他",
|
||||||
|
|
||||||
|
// 评论
|
||||||
|
"comments_title": "评论",
|
||||||
|
"comments_count": "%d 条评论",
|
||||||
|
"comments_empty": "暂无评论,快来抢沙发吧。",
|
||||||
|
"comments_reply": "回复",
|
||||||
|
"comments_cancel_reply": "取消回复",
|
||||||
|
"comments_replying_to": "回复 %s",
|
||||||
|
"comments_name": "名称",
|
||||||
|
"comments_email": "邮箱",
|
||||||
|
"comments_email_hint": "用于显示 Gravatar 头像,不会公开。",
|
||||||
|
"comments_website": "网址(可选)",
|
||||||
|
"comments_content": "评论内容",
|
||||||
|
"comments_content_ph": "支持 Markdown 语法...",
|
||||||
|
"comments_private": "私密评论",
|
||||||
|
"comments_private_hint": "仅你和管理员可见。",
|
||||||
|
"comments_preview": "预览",
|
||||||
|
"comments_edit": "编辑",
|
||||||
|
"comments_emoji": "表情",
|
||||||
|
"comments_markdown_help": "Markdown 帮助",
|
||||||
|
"comments_submit": "发表评论",
|
||||||
|
"comments_posted": "评论已发表。",
|
||||||
|
"comments_pending_notice": "你的评论正在等待审核。",
|
||||||
|
"comments_disabled": "评论功能已关闭。",
|
||||||
|
"comments_guests_disabled": "请先登录后再评论。",
|
||||||
|
"comments_required_name": "请填写名称。",
|
||||||
|
"comments_required_email": "请填写邮箱。",
|
||||||
|
"comments_required_content":"请填写评论内容。",
|
||||||
|
"comments_invalid_email": "请输入有效的邮箱地址。",
|
||||||
|
"comments_invalid_url": "请输入有效的网址。",
|
||||||
|
"comments_too_long": "评论内容过长(最多 %d 字)。",
|
||||||
|
"comments_pending": "待审核",
|
||||||
|
"comments_approved": "已通过",
|
||||||
|
"comments_rejected": "已拒绝",
|
||||||
|
"comments_private_badge": "私密",
|
||||||
|
"comments_markdown_bold": "**粗体**",
|
||||||
|
"comments_markdown_italic": "*斜体*",
|
||||||
|
"comments_markdown_code": "`代码`",
|
||||||
|
"comments_markdown_link": "[文本](网址)",
|
||||||
|
"comments_markdown_quote": "> 引用",
|
||||||
|
|
||||||
|
// 后台:评论
|
||||||
|
"admin_comments": "评论管理",
|
||||||
|
"admin_comments_title": "评论管理",
|
||||||
|
"comment_status_pending": "待审核",
|
||||||
|
"comment_status_approved": "已通过",
|
||||||
|
"comment_status_rejected": "已拒绝",
|
||||||
|
"comment_status_all": "全部",
|
||||||
|
"comment_approve": "通过",
|
||||||
|
"comment_reject": "拒绝",
|
||||||
|
"comment_delete": "删除",
|
||||||
|
"comment_delete_confirm": "删除该评论?",
|
||||||
|
"comment_col_author": "评论者",
|
||||||
|
"comment_col_article": "文章",
|
||||||
|
"comment_col_content": "内容",
|
||||||
|
"comment_col_status": "状态",
|
||||||
|
"comment_col_time": "时间",
|
||||||
|
"comment_col_actions": "操作",
|
||||||
|
"comment_empty": "暂无评论。",
|
||||||
|
"comment_approved": "评论已通过。",
|
||||||
|
"comment_rejected": "评论已拒绝。",
|
||||||
|
"comment_deleted": "评论已删除。",
|
||||||
|
|
||||||
|
// 后台:评论设置
|
||||||
|
"comment_settings_title": "评论设置",
|
||||||
|
"comment_settings_desc": "控制是否启用评论以及评论的审核方式。",
|
||||||
|
"comment_settings_enabled": "启用评论",
|
||||||
|
"comment_settings_allow_guest": "允许匿名评论",
|
||||||
|
"comment_settings_guest_approval": "非登录用户评论需审核",
|
||||||
|
"comment_settings_use_gravatar": "使用 Gravatar 头像",
|
||||||
|
"comment_settings_use_gravatar_hint": "关闭后,头像将显示作者名称首字母的彩色占位。",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func main() {
|
|||||||
router.POST("/login", handlers.Login(db))
|
router.POST("/login", handlers.Login(db))
|
||||||
router.POST("/logout", handlers.Logout())
|
router.POST("/logout", handlers.Logout())
|
||||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||||
|
router.POST("/article/:slug/comments", handlers.PostComment(db))
|
||||||
|
|
||||||
// Protected admin routes.
|
// Protected admin routes.
|
||||||
admin := router.Group("/admin")
|
admin := router.Group("/admin")
|
||||||
@@ -66,6 +67,11 @@ func main() {
|
|||||||
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
||||||
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
||||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||||
|
|
||||||
|
admin.GET("/comments", handlers.CommentListPage(db))
|
||||||
|
admin.POST("/comments/:id/approve", handlers.CommentApprove(db))
|
||||||
|
admin.POST("/comments/:id/reject", handlers.CommentReject(db))
|
||||||
|
admin.POST("/comments/:id/delete", handlers.CommentDelete(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected article attachment routes (AJAX uploads / management).
|
// Protected article attachment routes (AJAX uploads / management).
|
||||||
@@ -87,6 +93,8 @@ func main() {
|
|||||||
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
||||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||||
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
||||||
|
settings.GET("/comments", handlers.CommentSettingsPage(db))
|
||||||
|
settings.POST("/comments", handlers.CommentSettingsSave(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected profile routes.
|
// Protected profile routes.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comment status constants.
|
||||||
|
const (
|
||||||
|
CommentPending = 0 // 待审核
|
||||||
|
CommentApproved = 1 // 已通过
|
||||||
|
CommentRejected = 2 // 已拒绝(软拒绝;后台仍可查看,但前台不再显示)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comment represents one reader-submitted comment on an article. Comments may
|
||||||
|
// be nested via ParentID and authored by either a logged-in user (UserID) or
|
||||||
|
// an anonymous visitor identified by a long-lived GuestToken cookie.
|
||||||
|
type Comment struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||||
|
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||||
|
|
||||||
|
// Authorship: logged-in users get UserID; anonymous visitors get a random
|
||||||
|
// GuestToken stored in a cookie so they can see their own pending/private
|
||||||
|
// comments on subsequent page loads.
|
||||||
|
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||||
|
GuestToken string `gorm:"size:64;index" json:"-"`
|
||||||
|
|
||||||
|
AuthorName string `gorm:"not null;size:64" json:"author_name"`
|
||||||
|
Email string `gorm:"not null;size:255" json:"-"`
|
||||||
|
EmailHash string `gorm:"size:64;index" json:"email_hash"`
|
||||||
|
Website string `gorm:"size:255" json:"website,omitempty"`
|
||||||
|
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
|
||||||
|
IsPrivate bool `gorm:"default:false;index" json:"is_private"`
|
||||||
|
Status int `gorm:"default:0;index" json:"status"`
|
||||||
|
|
||||||
|
IPAddress string `gorm:"size:64" json:"-"`
|
||||||
|
UserAgent string `gorm:"size:512" json:"-"`
|
||||||
|
|
||||||
|
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName overrides the default GORM table name.
|
||||||
|
func (Comment) TableName() string {
|
||||||
|
return "comments"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
|
||||||
|
// is the form expected by Gravatar.
|
||||||
|
func HashEmail(email string) string {
|
||||||
|
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// GravatarURL returns a Gravatar avatar URL for this comment's email hash.
|
||||||
|
// Falls back to the "identicon" default avatar when no Gravatar exists.
|
||||||
|
func (c *Comment) GravatarURL(size int) string {
|
||||||
|
if size <= 0 {
|
||||||
|
size = 64
|
||||||
|
}
|
||||||
|
hash := c.EmailHash
|
||||||
|
if hash == "" {
|
||||||
|
hash = HashEmail(c.Email)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?s=%d&d=identicon", hash, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskedEmail returns the email with the local part partially obscured, for
|
||||||
|
// admin-side listings that should hint at identity without exposing the
|
||||||
|
// full address.
|
||||||
|
func (c *Comment) MaskedEmail() string {
|
||||||
|
email := c.Email
|
||||||
|
at := strings.LastIndex(email, "@")
|
||||||
|
if at <= 0 {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
local := email[:at]
|
||||||
|
host := email[at:]
|
||||||
|
if len(local) <= 2 {
|
||||||
|
return string(local[0]) + "***" + host
|
||||||
|
}
|
||||||
|
return string(local[0]) + "***" + string(local[len(local)-1]) + host
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorInitial returns an uppercase first character of AuthorName for use as
|
||||||
|
// a text-based avatar placeholder when Gravatar is disabled. Returns "?" when
|
||||||
|
// the name is empty.
|
||||||
|
func (c *Comment) AuthorInitial() string {
|
||||||
|
name := strings.TrimSpace(c.AuthorName)
|
||||||
|
if name == "" {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
r := []rune(name)
|
||||||
|
return strings.ToUpper(string(r[0]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CommentConfig holds the singleton (id=1) global comment policy.
|
||||||
|
type CommentConfig struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system
|
||||||
|
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed
|
||||||
|
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue
|
||||||
|
UseGravatar bool `gorm:"default:true" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder
|
||||||
|
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName overrides the default GORM table name.
|
||||||
|
func (CommentConfig) TableName() string {
|
||||||
|
return "comment_configs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCommentConfig returns the in-memory fallback used before the DB row is
|
||||||
|
// seeded, matching the seed defaults.
|
||||||
|
func defaultCommentConfig() *CommentConfig {
|
||||||
|
return &CommentConfig{
|
||||||
|
ID: 1,
|
||||||
|
Enabled: true,
|
||||||
|
AllowGuest: true,
|
||||||
|
GuestRequireApproval: false,
|
||||||
|
UseGravatar: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ var configCache = struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
site *SiteSetting
|
site *SiteSetting
|
||||||
upload *UploadConfig
|
upload *UploadConfig
|
||||||
|
comment *CommentConfig
|
||||||
types []UploadFileType
|
types []UploadFileType
|
||||||
baseURLs []DownloadBaseURL
|
baseURLs []DownloadBaseURL
|
||||||
}{
|
}{
|
||||||
@@ -41,6 +42,13 @@ func LoadConfigCache(db *gorm.DB) {
|
|||||||
configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}
|
configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cc CommentConfig
|
||||||
|
if err := db.First(&cc, 1).Error; err == nil {
|
||||||
|
configCache.comment = &cc
|
||||||
|
} else {
|
||||||
|
configCache.comment = defaultCommentConfig()
|
||||||
|
}
|
||||||
|
|
||||||
var t []UploadFileType
|
var t []UploadFileType
|
||||||
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
|
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
|
||||||
configCache.types = t
|
configCache.types = t
|
||||||
@@ -73,6 +81,13 @@ func GetUploadConfig() *UploadConfig {
|
|||||||
return configCache.upload
|
return configCache.upload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCommentConfig returns the cached comment policy.
|
||||||
|
func GetCommentConfig() *CommentConfig {
|
||||||
|
configCache.mu.RLock()
|
||||||
|
defer configCache.mu.RUnlock()
|
||||||
|
return configCache.comment
|
||||||
|
}
|
||||||
|
|
||||||
// GetUploadFileTypes returns the cached list of permitted file types.
|
// GetUploadFileTypes returns the cached list of permitted file types.
|
||||||
func GetUploadFileTypes() []UploadFileType {
|
func GetUploadFileTypes() []UploadFileType {
|
||||||
configCache.mu.RLock()
|
configCache.mu.RLock()
|
||||||
|
|||||||
+2
-1
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate tables (idempotent).
|
// Auto-migrate tables (idempotent).
|
||||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}); err != nil {
|
||||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
|||||||
seedSiteSettings(db)
|
seedSiteSettings(db)
|
||||||
seedUploadConfig(db)
|
seedUploadConfig(db)
|
||||||
seedUploadFileTypes(db)
|
seedUploadFileTypes(db)
|
||||||
|
seedCommentConfig(db)
|
||||||
|
|
||||||
// First-run seed: create admin user if no users exist.
|
// First-run seed: create admin user if no users exist.
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ func seedUploadConfig(db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
|
||||||
|
func seedCommentConfig(db *gorm.DB) {
|
||||||
|
var count int64
|
||||||
|
db.Model(&CommentConfig{}).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := &CommentConfig{
|
||||||
|
ID: 1,
|
||||||
|
Enabled: true,
|
||||||
|
AllowGuest: true,
|
||||||
|
GuestRequireApproval: false,
|
||||||
|
UseGravatar: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(c).Error; err != nil {
|
||||||
|
log.Printf("Warning: failed to seed comment_configs: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
||||||
// seeded on first run.
|
// seeded on first run.
|
||||||
var defaultUploadFileTypes = []UploadFileType{
|
var defaultUploadFileTypes = []UploadFileType{
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{define "comment_list"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "admin_comments_title"}}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status tabs -->
|
||||||
|
<div class="flex gap-2 mb-6 text-sm">
|
||||||
|
<a href="/admin/comments?status=pending"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "pending"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||||
|
{{index .Tr "comment_status_pending"}}
|
||||||
|
{{if gt .PendingCount 0}}<span class="ml-1 inline-flex items-center justify-center bg-red-500 text-white text-xs rounded-full px-2">{{.PendingCount}}</span>{{end}}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/comments?status=approved"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "approved"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||||
|
{{index .Tr "comment_status_approved"}}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/comments?status=rejected"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "rejected"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||||
|
{{index .Tr "comment_status_rejected"}}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/comments?status=all"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "all"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||||
|
{{index .Tr "comment_status_all"}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Success}}
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{{range .Comments}}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<img src="{{.GravatarURL}}" alt="" class="w-10 h-10 rounded-full bg-gray-100">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="font-semibold text-gray-800">{{.AuthorName}}</span>
|
||||||
|
{{if .Website}}<a href="{{.Website}}" target="_blank" rel="nofollow ugc noopener" class="text-blue-500 hover:underline text-xs">{{.Website}}</a>{{end}}
|
||||||
|
<span class="text-gray-400 text-xs">{{.MaskedEmail}}</span>
|
||||||
|
<span class="text-gray-300">·</span>
|
||||||
|
<span class="text-gray-400 text-xs">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
|
{{if .IPAddress}}<span class="text-gray-400 text-xs">IP: {{.IPAddress}}</span>{{end}}
|
||||||
|
{{if .IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index $.Tr "comments_private_badge"}}</span>{{end}}
|
||||||
|
<span class="text-xs {{.StatusBadge}} px-2 py-0.5 rounded">{{.StatusLabel}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .ArticleTitle}}
|
||||||
|
<a href="/article/{{.ArticleSlug}}#comment-{{.ID}}" target="_blank" class="text-xs text-gray-500 hover:text-blue-600 mt-1 inline-block">
|
||||||
|
↗ {{.ArticleTitle}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
<div class="comment-body mt-2 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Content}}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 mt-3 text-sm">
|
||||||
|
{{if eq .Status 0}}
|
||||||
|
<form action="/admin/comments/{{.ID}}/approve" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .Status 2}}
|
||||||
|
<form action="/admin/comments/{{.ID}}/reject" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form action="/admin/comments/{{.ID}}/delete" method="post" class="inline"
|
||||||
|
onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center text-gray-500">
|
||||||
|
{{index .Tr "comment_empty"}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||||
|
var raw = el.getAttribute('data-md') || '';
|
||||||
|
var html = marked.parse(raw);
|
||||||
|
el.innerHTML = DOMPurify.sanitize(html);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{template "footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "settings_comment"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "comment_settings_title"}}</h2>
|
||||||
|
<p class="text-gray-500 mb-4">{{index .Tr "comment_settings_desc"}}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mb-8">
|
||||||
|
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
||||||
|
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
||||||
|
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||||
|
<a href="/admin/settings/comments" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "comment_settings_title"}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Success}}
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form action="/admin/settings/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}}
|
||||||
|
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
{{index .Tr "comment_settings_enabled"}}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="allow_guest" value="1" {{if .CommentConfig.AllowGuest}}checked{{end}}
|
||||||
|
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
{{index .Tr "comment_settings_allow_guest"}}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="guest_require_approval" value="1" {{if .CommentConfig.GuestRequireApproval}}checked{{end}}
|
||||||
|
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
{{index .Tr "comment_settings_guest_approval"}}
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="use_gravatar" value="1" {{if .CommentConfig.UseGravatar}}checked{{end}}
|
||||||
|
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
{{index .Tr "comment_settings_use_gravatar"}}
|
||||||
|
</label>
|
||||||
|
<p class="ml-7 text-xs text-gray-400">{{index .Tr "comment_settings_use_gravatar_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
{{index .Tr "settings_save"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{template "footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -47,6 +47,12 @@
|
|||||||
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||||
{{index .Tr "admin_panel"}}
|
{{index .Tr "admin_panel"}}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||||
|
{{index .Tr "article_manage"}}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/comments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||||
|
{{index .Tr "admin_comments"}}
|
||||||
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="border-t border-gray-100 my-1"></div>
|
<div class="border-t border-gray-100 my-1"></div>
|
||||||
<form action="/logout" method="post" class="m-0">
|
<form action="/logout" method="post" class="m-0">
|
||||||
|
|||||||
@@ -2,11 +2,24 @@
|
|||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||||
|
|
||||||
<section class="max-w-3xl mx-auto px-4 py-12">
|
<section class="max-w-3xl mx-auto px-4 py-12">
|
||||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors">
|
||||||
← {{index .Tr "article_back_home"}}
|
← {{index .Tr "article_back_home"}}
|
||||||
</a>
|
</a>
|
||||||
|
{{if eq .Role "admin"}}
|
||||||
|
<a href="/admin/articles/{{.Article.ID}}/edit"
|
||||||
|
class="inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-md border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 hover:border-blue-300 transition-colors"
|
||||||
|
title="{{index .Tr "article_edit"}}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
{{index .Tr "article_edit"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<h1 class="text-4xl font-extrabold text-gray-900 mb-3">{{.Article.Title}}</h1>
|
<h1 class="text-4xl font-extrabold text-gray-900 mb-3">{{.Article.Title}}</h1>
|
||||||
@@ -26,13 +39,210 @@
|
|||||||
|
|
||||||
<div id="articleBody" class="prose max-w-none text-gray-800"></div>
|
<div id="articleBody" class="prose max-w-none text-gray-800"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{{if .CommentError}}
|
||||||
|
<div class="max-w-3xl mx-auto mt-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{{.CommentError}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CommentConfig}}
|
||||||
|
{{if .CommentConfig.Enabled}}
|
||||||
|
<!-- Comments -->
|
||||||
|
<section id="comments" class="mt-16">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">{{index .Tr "comments_title"}}</h2>
|
||||||
|
|
||||||
|
{{if .CommentNotice}}
|
||||||
|
<div id="commentNotice" class="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded-lg mb-6">{{.CommentNotice}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Comment list -->
|
||||||
|
<div id="commentList" class="space-y-4 mb-10">
|
||||||
|
{{range .Comments}}
|
||||||
|
{{template "comment_node" .}}
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-400 text-sm py-6 text-center">{{index $.Tr "comments_empty"}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment form -->
|
||||||
|
<div id="commentFormWrap" class="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<div id="replyIndicator" class="hidden mb-4 text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 flex items-center justify-between">
|
||||||
|
<span id="replyTarget"></span>
|
||||||
|
<button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button>
|
||||||
|
</div>
|
||||||
|
<form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post">
|
||||||
|
<input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "comments_name"}} *</label>
|
||||||
|
<input type="text" name="name" maxlength="64" required value="{{.CommentForm.Name}}"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "comments_email"}} *</label>
|
||||||
|
<input type="email" name="email" maxlength="255" required value="{{.CommentForm.Email}}"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{index .Tr "comments_email_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "comments_website"}}</label>
|
||||||
|
<input type="url" name="website" maxlength="255" value="{{.CommentForm.Website}}"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center gap-2 mb-2 text-sm">
|
||||||
|
<button type="button" id="togglePreview" class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-50 bg-white cursor-pointer">{{index .Tr "comments_preview"}}</button>
|
||||||
|
<details class="relative">
|
||||||
|
<summary class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-50 bg-white cursor-pointer list-none">{{index .Tr "comments_emoji"}}</summary>
|
||||||
|
<div id="emojiPicker" class="absolute z-20 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-3 grid grid-cols-8 gap-1 w-72 text-xl"></div>
|
||||||
|
</details>
|
||||||
|
<details class="relative">
|
||||||
|
<summary class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-50 bg-white cursor-pointer list-none">{{index .Tr "comments_markdown_help"}}</summary>
|
||||||
|
<div class="absolute z-20 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-3 text-xs text-gray-600 w-56 space-y-1">
|
||||||
|
<div><code>{{index .Tr "comments_markdown_bold"}}</code></div>
|
||||||
|
<div><code>{{index .Tr "comments_markdown_italic"}}</code></div>
|
||||||
|
<div><code>{{index .Tr "comments_markdown_code"}}</code></div>
|
||||||
|
<div><code>{{index .Tr "comments_markdown_link"}}</code></div>
|
||||||
|
<div><code>{{index .Tr "comments_markdown_quote"}}</code></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<label class="ml-auto inline-flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
|
<input type="checkbox" name="is_private" value="1" {{if .CommentForm.IsPrivate}}checked{{end}} class="rounded border-gray-300 text-blue-600">
|
||||||
|
<span>{{index .Tr "comments_private"}}</span>
|
||||||
|
<span class="relative group">
|
||||||
|
<span class="text-gray-400 cursor-help">?</span>
|
||||||
|
<span class="absolute right-0 bottom-6 hidden group-hover:block bg-gray-800 text-white text-xs rounded px-2 py-1 w-48 z-30">{{index .Tr "comments_private_hint"}}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea name="content" id="commentContent" maxlength="{{.MaxCommentLength}}" required
|
||||||
|
placeholder="{{index .Tr "comments_content_ph"}}"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 min-h-32 font-mono text-sm">{{.CommentForm.Content}}</textarea>
|
||||||
|
<div id="previewBox" class="hidden w-full border border-gray-200 rounded-lg px-3 py-2 min-h-32 prose prose-sm max-w-none bg-gray-50"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<span id="charCount" class="text-xs text-gray-400"></span>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
{{index .Tr "comments_submit"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// {{.Article.Content}} is emitted by html/template as a JSON-encoded JS
|
// Article body
|
||||||
// string, safely handling quotes, newlines, and HTML special characters.
|
|
||||||
var md = {{.Article.Content}};
|
var md = {{.Article.Content}};
|
||||||
document.getElementById('articleBody').innerHTML = marked.parse(md);
|
document.getElementById('articleBody').innerHTML = DOMPurify.sanitize(marked.parse(md));
|
||||||
|
|
||||||
|
marked.setOptions({ mangle: false, headerIds: false });
|
||||||
|
|
||||||
|
function renderCommentBodies() {
|
||||||
|
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||||
|
if (el.dataset.rendered) return;
|
||||||
|
var raw = el.getAttribute('data-md') || '';
|
||||||
|
el.innerHTML = DOMPurify.sanitize(marked.parse(raw));
|
||||||
|
el.dataset.rendered = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderCommentBodies();
|
||||||
|
|
||||||
|
// ---- Comment form: preview / emoji / reply ----
|
||||||
|
// Auto-dismiss the one-time success/pending notice after a few seconds.
|
||||||
|
(function () {
|
||||||
|
var notice = document.getElementById('commentNotice');
|
||||||
|
if (notice) {
|
||||||
|
setTimeout(function () {
|
||||||
|
notice.style.transition = 'opacity 500ms ease';
|
||||||
|
notice.style.opacity = '0';
|
||||||
|
setTimeout(function () { notice.remove(); }, 500);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById('commentForm');
|
||||||
|
if (!form) return;
|
||||||
|
var textarea = document.getElementById('commentContent');
|
||||||
|
var previewBox = document.getElementById('previewBox');
|
||||||
|
var togglePreview = document.getElementById('togglePreview');
|
||||||
|
var parentIdInput = document.getElementById('parent_id');
|
||||||
|
var replyIndicator = document.getElementById('replyIndicator');
|
||||||
|
var replyTarget = document.getElementById('replyTarget');
|
||||||
|
var cancelReply = document.getElementById('cancelReply');
|
||||||
|
var charCount = document.getElementById('charCount');
|
||||||
|
var formWrap = document.getElementById('commentFormWrap');
|
||||||
|
var maxLen = {{.MaxCommentLength}};
|
||||||
|
|
||||||
|
function updateCharCount() {
|
||||||
|
charCount.textContent = (textarea.value.length + ' / ' + maxLen);
|
||||||
|
}
|
||||||
|
textarea.addEventListener('input', updateCharCount);
|
||||||
|
updateCharCount();
|
||||||
|
|
||||||
|
// Preview toggle
|
||||||
|
togglePreview.addEventListener('click', function () {
|
||||||
|
var previewing = previewBox.classList.toggle('hidden') === false;
|
||||||
|
textarea.classList.toggle('hidden', previewing);
|
||||||
|
if (previewing) {
|
||||||
|
previewBox.innerHTML = DOMPurify.sanitize(marked.parse(textarea.value));
|
||||||
|
togglePreview.textContent = '{{index .Tr "comments_edit"}}';
|
||||||
|
} else {
|
||||||
|
togglePreview.textContent = '{{index .Tr "comments_preview"}}';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emoji picker
|
||||||
|
var emojis = ["😀","😁","😂","🤣","😊","😍","😎","🤔","😢","😭","😡","😴","👍","👎","👏","🙏","💪","🎉","❤️","🔥","💯","✅","❌","⭐","✨","🌹","☕","🎁","💡","📌"];
|
||||||
|
var picker = document.getElementById('emojiPicker');
|
||||||
|
emojis.forEach(function (e) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = e;
|
||||||
|
btn.className = 'hover:bg-gray-100 rounded p-1 cursor-pointer bg-transparent border-none';
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
insertAtCursor(textarea, e);
|
||||||
|
textarea.focus();
|
||||||
|
updateCharCount();
|
||||||
|
});
|
||||||
|
picker.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
function insertAtCursor(field, text) {
|
||||||
|
var start = field.selectionStart, end = field.selectionEnd;
|
||||||
|
field.setRangeText(text, start, end, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply buttons (event delegation, works for dynamically nested nodes)
|
||||||
|
document.getElementById('commentList').addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.reply-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
var name = btn.getAttribute('data-name');
|
||||||
|
parentIdInput.value = id;
|
||||||
|
replyTarget.textContent = '{{index .Tr "comments_replying_to"}}'.replace('%s', name);
|
||||||
|
replyIndicator.classList.remove('hidden');
|
||||||
|
// Move the form under the replied comment for context.
|
||||||
|
var node = document.getElementById('comment-' + id);
|
||||||
|
if (node) {
|
||||||
|
node.appendChild(formWrap);
|
||||||
|
}
|
||||||
|
formWrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
textarea.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelReply.addEventListener('click', function () {
|
||||||
|
parentIdInput.value = '';
|
||||||
|
replyIndicator.classList.add('hidden');
|
||||||
|
document.getElementById('comments').insertBefore(formWrap, document.getElementById('commentList').nextSibling);
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "comment_node"}}
|
||||||
|
<div id="comment-{{.Comment.ID}}" class="comment-node">
|
||||||
|
<div class="flex items-start gap-3 py-3">
|
||||||
|
{{if .UseGravatar}}
|
||||||
|
<img src="{{.Comment.GravatarURL 48}}" alt="" class="w-10 h-10 rounded-full bg-gray-100 flex-shrink-0">
|
||||||
|
{{else}}
|
||||||
|
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0" style="background:{{.AvatarColor}}">{{.Comment.AuthorInitial}}</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{{if .Comment.Website}}
|
||||||
|
<a href="{{.Comment.Website}}" target="_blank" rel="nofollow ugc noopener" class="font-semibold text-gray-800 hover:text-blue-600">{{.Comment.AuthorName}}</a>
|
||||||
|
{{else}}
|
||||||
|
<span class="font-semibold text-gray-800">{{.Comment.AuthorName}}</span>
|
||||||
|
{{end}}
|
||||||
|
<span class="text-gray-400 text-xs">{{.RelTime}}</span>
|
||||||
|
{{if .Comment.IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index .Tr "comments_private_badge"}}</span>{{end}}
|
||||||
|
{{if eq .Comment.Status 0}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">{{index .Tr "comments_pending"}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="comment-body mt-1 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Comment.Content}}"></div>
|
||||||
|
<button type="button" class="reply-btn mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer"
|
||||||
|
data-id="{{.Comment.ID}}" data-name="{{.Comment.AuthorName}}">{{index .Tr "comments_reply"}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Children}}
|
||||||
|
<div class="ml-6 sm:ml-10 pl-4 border-l-2 border-gray-100">
|
||||||
|
{{range .Children}}
|
||||||
|
{{template "comment_node" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user