- 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>
250 lines
13 KiB
HTML
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}}
|