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
+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}}