Files
go_blog/templates/pages/article.html
T
kevinandClaude Fable 5 6139b3ef2c 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>
2026-06-22 17:21:54 +08:00

250 lines
13 KiB
HTML

{{define "article"}}
{{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">
<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>
<div class="flex items-center gap-3 text-sm text-gray-500 mb-8">
{{if .Article.Author.Avatar}}
<img src="/uploads/avatars/{{.Article.Author.Avatar}}" alt="avatar" class="w-7 h-7 rounded-full object-cover">
{{end}}
<span class="font-medium text-gray-700">{{.AuthorName}}</span>
{{if .PublishedAt}}<span>·</span><span>{{.PublishedAt}}</span>{{end}}
</div>
{{if .Article.Cover}}
<div class="rounded-xl overflow-hidden mb-8">
<img src="{{.Article.Cover}}" alt="{{.Article.Title}}" class="w-full max-h-96 object-cover">
</div>
{{end}}
<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 body
var md = {{.Article.Content}};
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" .}}
{{end}}