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:
24
templates/comments/_comment.html
Normal file
24
templates/comments/_comment.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<article id="comment-{{ comment.id }}" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0 shadow-neon-cyan"></div>
|
||||
<div>
|
||||
<div class="font-display font-bold text-sm hover:text-brand-cyan transition-colors">{{ 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>
|
||||
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
|
||||
{% 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 border-l-2 border-l-brand-cyan p-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-7 h-7 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 %}
|
||||
{% include "comments/_reply_form.html" with page=page comment=comment %}
|
||||
</article>
|
||||
32
templates/comments/_comment_form.html
Normal file
32
templates/comments/_comment_form.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% load static %}
|
||||
<div id="comment-form-container" 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"
|
||||
hx-post="{% url 'comment_post' %}" hx-target="#comments-list" hx-swap="beforeend"
|
||||
hx-on::after-request="if(event.detail.successful) { this.reset(); document.getElementById('comment-success')?.remove(); this.insertAdjacentHTML('beforebegin', '<div id="comment-success" class="mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">Comment posted!</div>'); }">
|
||||
{% 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 focus:shadow-neon-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 focus:shadow-neon-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 focus:shadow-neon-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
||||
</div>
|
||||
<input type="text" name="honeypot" hidden />
|
||||
{% if turnstile_site_key %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
|
||||
{% endif %}
|
||||
<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>
|
||||
6
templates/comments/_comment_list.html
Normal file
6
templates/comments/_comment_list.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div id="comments-list" class="space-y-8 mb-12"
|
||||
hx-get="{% url 'comment_poll' article_id=page.id %}" hx-trigger="every 30s" hx-swap="innerHTML">
|
||||
{% for comment in approved_comments %}
|
||||
{% include "comments/_comment.html" with comment=comment page=page %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
3
templates/comments/_comment_list_inner.html
Normal file
3
templates/comments/_comment_list_inner.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% for comment in approved_comments %}
|
||||
{% include "comments/_comment.html" with comment=comment page=page %}
|
||||
{% endfor %}
|
||||
3
templates/comments/_comment_success.html
Normal file
3
templates/comments/_comment_success.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id="comment-notice" class="mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">
|
||||
{{ message|default:"Your comment has been posted and is awaiting moderation." }}
|
||||
</div>
|
||||
12
templates/comments/_reactions.html
Normal file
12
templates/comments/_reactions.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="flex gap-3 mt-3 items-center" id="reactions-{{ comment.id }}">
|
||||
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
|
||||
hx-vals='{"reaction_type": "heart"}' class="flex items-center gap-1 font-mono text-xs {% if 'heart' in user_reacted %}text-brand-pink{% else %}text-zinc-400 hover:text-brand-pink{% endif %} transition-colors hover:scale-110 transition-transform">
|
||||
<svg class="w-4 h-4" fill="{% if 'heart' in user_reacted %}currentColor{% else %}none{% endif %}" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /></svg>
|
||||
<span>{{ counts.heart|default:"0" }}</span>
|
||||
</button>
|
||||
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
|
||||
hx-vals='{"reaction_type": "plus_one"}' class="flex items-center gap-1 font-mono text-xs {% if 'plus_one' in user_reacted %}text-brand-cyan{% else %}text-zinc-400 hover:text-brand-cyan{% endif %} transition-colors hover:scale-110 transition-transform">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
|
||||
<span>{{ counts.plus_one|default:"0" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
20
templates/comments/_reply_form.html
Normal file
20
templates/comments/_reply_form.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% load static %}
|
||||
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800"
|
||||
hx-post="{% url 'comment_post' %}" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||
{% 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 focus:shadow-neon-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 focus:shadow-neon-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 focus:shadow-neon-pink transition-colors mb-3 resize-none"></textarea>
|
||||
<input type="text" name="honeypot" hidden />
|
||||
{% if turnstile_site_key %}
|
||||
<div class="cf-turnstile mb-3" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="compact"></div>
|
||||
{% endif %}
|
||||
<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>
|
||||
53
templates/comments/confirm_bulk_unapprove.html
Normal file
53
templates/comments/confirm_bulk_unapprove.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
|
||||
{% load i18n wagtailusers_tags wagtailadmin_tags %}
|
||||
|
||||
{% block titletag %}
|
||||
{% if items|length == 1 %}
|
||||
{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove {{ snippet_type_name }}{% endblocktrans %} - {{ items.0.item }}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} comments{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Unapprove" as unapprove_str %}
|
||||
{% if items|length == 1 %}
|
||||
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=items.0.item icon=header_icon only %}
|
||||
{% else %}
|
||||
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=model_opts.verbose_name_plural|capfirst icon=header_icon only %}
|
||||
{% endif %}
|
||||
{% endblock header %}
|
||||
|
||||
{% block items_with_access %}
|
||||
{% if items %}
|
||||
{% if items|length == 1 %}
|
||||
<p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove this {{ snippet_type_name }}?{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p>{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} selected comments?{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for snippet in items %}
|
||||
<li><a href="{{ snippet.edit_url }}" target="_blank" rel="noreferrer">{{ snippet.item }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock items_with_access %}
|
||||
|
||||
{% block items_with_no_access %}
|
||||
{% if items_with_no_access|length == 1 %}
|
||||
{% trans "You don't have permission to unapprove this comment" as no_access_msg %}
|
||||
{% else %}
|
||||
{% trans "You don't have permission to unapprove these comments" as no_access_msg %}
|
||||
{% endif %}
|
||||
{% include 'wagtailsnippets/bulk_actions/list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
|
||||
{% endblock items_with_no_access %}
|
||||
|
||||
{% block form_section %}
|
||||
{% if items %}
|
||||
{% trans "Yes, unapprove" as action_button_text %}
|
||||
{% trans "No, go back" as no_action_button_text %}
|
||||
{% include 'wagtailadmin/bulk_actions/confirmation/form.html' %}
|
||||
{% else %}
|
||||
{% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
|
||||
{% endif %}
|
||||
{% endblock form_section %}
|
||||
Reference in New Issue
Block a user