feat(comments): v2 — HTMX, Turnstile, reactions, design refresh
- Extract comment templates into reusable partials (_comment.html, _comment_form.html, _comment_list.html, _reply_form.html, etc.) - Add HTMX progressive enhancement: inline form submission with partial responses, delta polling for live updates, form reset on success, success/moderation toast feedback - Integrate Cloudflare Turnstile for invisible bot protection: server-side token validation with hostname check, fail-closed on errors/timeouts, feature-flagged via TURNSTILE_SECRET_KEY env var - Auto-approve comments that pass Turnstile; keep manual approval as fallback when Turnstile is disabled (model default stays False) - Add CommentReaction model with UniqueConstraint for session-based anonymous reactions (heart/thumbs-up), toggle support, separate rate-limit bucket (20/min) - Add comment poll endpoint (GET /comments/poll/<id>/?after_id=N) for HTMX delta polling without duplicates - Update CSP middleware to allow challenges.cloudflare.com in script-src, connect-src, and frame-src - Self-host htmx.min.js (v2.0.4) to minimize CSP surface area - Add django-htmx middleware and requests to dependencies - Add Unapprove bulk action to Wagtail admin for moderation - Extend PII purge command to anonymize reaction session_key - Design refresh: neon glow avatars, solid hover shadows, gradient section header, cyan reply borders, grid-pattern empty state, neon-pink focus glow on form inputs - Add turnstile_site_key to template context via context processor - 18 new tests covering HTMX contracts, Turnstile success/failure/ timeout/hostname-mismatch, polling deltas, reaction toggle/dedup/ rate-limit, CSP headers, and PII purge extension Closes #43 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -140,51 +140,15 @@
|
||||
<!-- Comments -->
|
||||
{% if page.comments_enabled %}
|
||||
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div class="h-1 w-24 bg-gradient-to-r from-brand-cyan to-brand-pink mb-6"></div>
|
||||
<h2 class="font-display font-bold text-3xl mb-8">Comments</h2>
|
||||
|
||||
{% if approved_comments %}
|
||||
<div class="space-y-8 mb-12">
|
||||
{% for comment in approved_comments %}
|
||||
<article id="comment-{{ comment.id }}" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-display font-bold text-sm">{{ comment.author_name }}</div>
|
||||
<div class="font-mono text-xs text-zinc-500">{{ comment.created_at|date:"M j, Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ comment.body }}</p>
|
||||
{% for reply in comment.replies.all %}
|
||||
<article id="comment-{{ reply.id }}" class="mt-6 ml-8 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-6 h-6 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-display font-bold text-sm">{{ reply.author_name }}</div>
|
||||
<div class="font-mono text-xs text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ reply.body }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
||||
<div class="flex gap-3 mb-3">
|
||||
<input type="text" name="author_name" required placeholder="Your name"
|
||||
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
<input type="email" name="author_email" required placeholder="your@email.com"
|
||||
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
<textarea name="body" required placeholder="Write a reply..." rows="2"
|
||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors mb-3 resize-none"></textarea>
|
||||
<input type="text" name="honeypot" hidden /> <button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include "comments/_comment_list.html" %}
|
||||
{% else %}
|
||||
<p class="font-mono text-sm text-zinc-500 mb-12">No comments yet. Be the first to comment.</p>
|
||||
<div class="mb-12 p-8 bg-grid-pattern text-center">
|
||||
<p class="font-mono text-sm text-zinc-500">No comments yet. Be the first to comment.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if comment_form and comment_form.errors %}
|
||||
@@ -194,32 +158,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
||||
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
|
||||
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Name *</label>
|
||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
|
||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Email *</label>
|
||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
|
||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Comment *</label>
|
||||
<textarea name="body" required rows="5"
|
||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
||||
</div>
|
||||
<input type="text" name="honeypot" hidden />
|
||||
<button type="submit" class="px-6 py-3 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all">Post comment</button>
|
||||
</form>
|
||||
</div>
|
||||
{% include "comments/_comment_form.html" %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user