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:
2026-06-22 17:21:54 +08:00
co-authored by Claude Fable 5
parent 1f1123acf8
commit 6139b3ef2c
18 changed files with 1351 additions and 18 deletions
+3
View File
@@ -3,6 +3,9 @@ go_blog
go_blog.exe
main.exe
# fresh (live-reload) build output
tmp/
# Auto-generated runtime data (config + database + uploads)
win/
mac/
+9
View File
@@ -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
+150
View File
@@ -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")
}
}
+366
View File
@@ -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
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go_blog/models"
@@ -53,19 +54,45 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
db.Model(&models.Article{}).Where("id = ?", article.ID).
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
if 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["Title"] = article.Title
data["Article"] = article
data["AuthorName"] = authorName
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)
}
}
// formatPublishTime returns the publication time as a readable string, falling
// back to the creation time when the publish timestamp is unset.
@@ -75,3 +102,27 @@ func formatPublishTime(publishedAt *time.Time) string {
}
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 ""
}
+40
View File
@@ -334,3 +334,43 @@ func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("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
View File
@@ -198,6 +198,77 @@ var translations = map[Lang]map[string]string{
"cat_archive": "Archive",
"cat_video": "Video",
"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: {
// 导航
@@ -381,6 +452,77 @@ var translations = map[Lang]map[string]string{
"cat_archive": "压缩包",
"cat_video": "视频",
"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": "关闭后,头像将显示作者名称首字母的彩色占位。",
},
}
+8
View File
@@ -54,6 +54,7 @@ func main() {
router.POST("/login", handlers.Login(db))
router.POST("/logout", handlers.Logout())
router.GET("/article/:slug", handlers.ArticleDetail(db))
router.POST("/article/:slug/comments", handlers.PostComment(db))
// Protected admin routes.
admin := router.Group("/admin")
@@ -66,6 +67,11 @@ func main() {
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(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).
@@ -87,6 +93,8 @@ func main() {
settings.POST("/upload", handlers.UploadSettingsSave(db))
settings.GET("/download", handlers.DownloadSettingsPage(db))
settings.POST("/download", handlers.DownloadSettingsSave(db))
settings.GET("/comments", handlers.CommentSettingsPage(db))
settings.POST("/comments", handlers.CommentSettingsSave(db))
}
// Protected profile routes.
+106
View File
@@ -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]))
}
+31
View File
@@ -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,
}
}
+15
View File
@@ -14,6 +14,7 @@ var configCache = struct {
mu sync.RWMutex
site *SiteSetting
upload *UploadConfig
comment *CommentConfig
types []UploadFileType
baseURLs []DownloadBaseURL
}{
@@ -41,6 +42,13 @@ func LoadConfigCache(db *gorm.DB) {
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
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
configCache.types = t
@@ -73,6 +81,13 @@ func GetUploadConfig() *UploadConfig {
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.
func GetUploadFileTypes() []UploadFileType {
configCache.mu.RLock()
+2 -1
View File
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
}
// 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)
}
@@ -53,6 +53,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
seedSiteSettings(db)
seedUploadConfig(db)
seedUploadFileTypes(db)
seedCommentConfig(db)
// First-run seed: create admin user if no users exist.
var count int64
+19
View File
@@ -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
// seeded on first run.
var defaultUploadFileTypes = []UploadFileType{
+94
View File
@@ -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}}
+49
View File
@@ -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}}
+6
View File
@@ -47,6 +47,12 @@
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "admin_panel"}}
</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}}
<div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0">
+214 -4
View File
@@ -2,11 +2,24 @@
{{template "header" .}}
<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">
<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"}}
</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>
<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>
</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>
<script>
// {{.Article.Content}} is emitted by html/template as a JSON-encoded JS
// string, safely handling quotes, newlines, and HTML special characters.
// Article body
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>
{{template "footer" .}}
+33
View File
@@ -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}}