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
|
||||
main.exe
|
||||
|
||||
# fresh (live-reload) build output
|
||||
tmp/
|
||||
|
||||
# Auto-generated runtime data (config + database + uploads)
|
||||
win/
|
||||
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"
|
||||
"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 ""
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "关闭后,头像将显示作者名称首字母的彩色占位。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
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
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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">
|
||||
{{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">
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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