Compare commits
1 Commits
c8e01f5201
...
c47a62df5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47a62df5c |
@@ -303,20 +303,12 @@ class ArticlePage(SeoMixin, Page):
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
ctx["related_articles"] = self.get_related_articles()
|
||||
from django.conf import settings
|
||||
|
||||
from apps.comments.models import Comment
|
||||
from apps.comments.views import _annotate_reaction_counts, _get_session_key
|
||||
|
||||
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
||||
comments = list(
|
||||
self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
||||
Prefetch("replies", queryset=approved_replies)
|
||||
)
|
||||
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
||||
Prefetch("replies", queryset=approved_replies)
|
||||
)
|
||||
_annotate_reaction_counts(comments, _get_session_key(request))
|
||||
ctx["approved_comments"] = comments
|
||||
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.comments.models import Comment, CommentReaction
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -29,10 +29,3 @@ class Command(BaseCommand):
|
||||
.update(author_email="", ip_address=None)
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s)."))
|
||||
|
||||
reactions_purged = (
|
||||
CommentReaction.objects.filter(created_at__lt=cutoff)
|
||||
.exclude(session_key="")
|
||||
.update(session_key="")
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Purged session keys for {reactions_purged} reaction(s)."))
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-03 22:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comments', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CommentReaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('reaction_type', models.CharField(choices=[('heart', '❤️'), ('plus_one', '👍')], max_length=20)),
|
||||
('session_key', models.CharField(max_length=64)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='comments.comment')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('comment', 'reaction_type', 'session_key'), name='unique_comment_reaction_per_session')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -23,21 +23,3 @@ class Comment(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Comment by {self.author_name}"
|
||||
|
||||
|
||||
class CommentReaction(models.Model):
|
||||
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="reactions")
|
||||
reaction_type = models.CharField(max_length=20, choices=[("heart", "❤️"), ("plus_one", "👍")])
|
||||
session_key = models.CharField(max_length=64)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["comment", "reaction_type", "session_key"],
|
||||
name="unique_comment_reaction_per_session",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.reaction_type} on comment {self.comment_id}"
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"""Tests for Comments v2: HTMX, Turnstile, reactions, polling, CSP."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.core.management import call_command
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
from apps.comments.models import Comment, CommentReaction
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _article(home_page):
|
||||
"""Create a published article with comments enabled."""
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="Test Article",
|
||||
slug="test-article",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
return article
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approved_comment(_article):
|
||||
return Comment.objects.create(
|
||||
article=_article,
|
||||
author_name="Alice",
|
||||
author_email="alice@example.com",
|
||||
body="Great article!",
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
|
||||
def _post_comment(client, article, extra=None, htmx=False):
|
||||
cache.clear()
|
||||
payload = {
|
||||
"article_id": article.id,
|
||||
"author_name": "Test",
|
||||
"author_email": "test@example.com",
|
||||
"body": "Hello world",
|
||||
"honeypot": "",
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
headers = {}
|
||||
if htmx:
|
||||
headers["HTTP_HX_REQUEST"] = "true"
|
||||
return client.post("/comments/post/", payload, **headers)
|
||||
|
||||
|
||||
# ── HTMX Response Contracts ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_htmx_post_returns_form_with_moderation_on_success(client, _article):
|
||||
"""HTMX POST with Turnstile disabled returns fresh form + moderation message."""
|
||||
resp = _post_comment(client, _article, htmx=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"awaiting moderation" in resp.content
|
||||
# Response swaps the form container (contains form + success message)
|
||||
assert b"comment-form-container" in resp.content
|
||||
assert "HX-Request" in resp["Vary"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||
def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article):
|
||||
"""HTMX POST with successful Turnstile returns fresh form + OOB comment."""
|
||||
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||
resp = _post_comment(client, _article, extra={"cf-turnstile-response": "tok"}, htmx=True)
|
||||
assert resp.status_code == 200
|
||||
content = resp.content.decode()
|
||||
# Fresh form container is the primary response
|
||||
assert "comment-form-container" in content
|
||||
assert "Comment posted!" in content
|
||||
# OOB swap appends the comment to #comments-list
|
||||
assert "hx-swap-oob" in content
|
||||
assert "Hello world" in content
|
||||
comment = Comment.objects.get()
|
||||
assert comment.is_approved is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
|
||||
"""HTMX POST with invalid data returns form with errors (HTTP 200)."""
|
||||
cache.clear()
|
||||
resp = client.post(
|
||||
"/comments/post/",
|
||||
{"article_id": _article.id, "author_name": "T", "author_email": "t@t.com", "body": " ", "honeypot": ""},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"comment-form-container" in resp.content
|
||||
assert b"Comment form errors" in resp.content
|
||||
assert "HX-Request" in resp["Vary"]
|
||||
assert Comment.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||
def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_comment):
|
||||
"""Approved reply via HTMX returns compact reply partial via OOB swap."""
|
||||
cache.clear()
|
||||
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||
resp = client.post(
|
||||
"/comments/post/",
|
||||
{
|
||||
"article_id": _article.id,
|
||||
"parent_id": approved_comment.id,
|
||||
"author_name": "Replier",
|
||||
"author_email": "r@r.com",
|
||||
"body": "Nice reply",
|
||||
"honeypot": "",
|
||||
"cf-turnstile-response": "tok",
|
||||
},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
content = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
# OOB targets the parent's replies-container
|
||||
assert f"#comment-{approved_comment.id}" in content
|
||||
assert "hx-swap-oob" in content
|
||||
# Reply uses compact markup (no nested reply form)
|
||||
assert "Reply posted!" in content
|
||||
reply = Comment.objects.exclude(pk=approved_comment.pk).get()
|
||||
assert reply.parent_id == approved_comment.id
|
||||
assert reply.is_approved is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_htmx_post_still_redirects(client, _article):
|
||||
"""Non-HTMX POST continues to redirect (progressive enhancement)."""
|
||||
resp = _post_comment(client, _article)
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"].endswith("?commented=1")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_htmx_error_with_tampered_parent_id_falls_back_to_main_form(client, _article):
|
||||
"""Tampered/non-numeric parent_id falls back to main form error response."""
|
||||
cache.clear()
|
||||
resp = client.post(
|
||||
"/comments/post/",
|
||||
{"article_id": _article.id, "parent_id": "not-a-number", "author_name": "T",
|
||||
"author_email": "t@t.com", "body": " ", "honeypot": ""},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"comment-form-container" in resp.content
|
||||
|
||||
|
||||
# ── Turnstile Integration ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||
def test_turnstile_failure_keeps_comment_unapproved(client, _article):
|
||||
"""When Turnstile verification fails, comment stays unapproved."""
|
||||
with patch("apps.comments.views._verify_turnstile", return_value=False):
|
||||
_post_comment(client, _article, extra={"cf-turnstile-response": "bad-tok"})
|
||||
comment = Comment.objects.get()
|
||||
assert comment.is_approved is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_turnstile_disabled_keeps_comment_unapproved(client, _article):
|
||||
"""When TURNSTILE_SECRET_KEY is empty, comment stays unapproved."""
|
||||
_post_comment(client, _article)
|
||||
comment = Comment.objects.get()
|
||||
assert comment.is_approved is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret", TURNSTILE_EXPECTED_HOSTNAME="nohypeai.com")
|
||||
def test_turnstile_hostname_mismatch_rejects(client, _article):
|
||||
"""Turnstile hostname mismatch keeps comment unapproved."""
|
||||
mock_resp = type("R", (), {"json": lambda self: {"success": True, "hostname": "evil.com"}})()
|
||||
with patch("apps.comments.views.http_requests.post", return_value=mock_resp):
|
||||
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
|
||||
comment = Comment.objects.get()
|
||||
assert comment.is_approved is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||
def test_turnstile_timeout_fails_closed(client, _article):
|
||||
"""Network error during Turnstile verification fails closed."""
|
||||
with patch("apps.comments.views.http_requests.post", side_effect=Exception("timeout")):
|
||||
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
|
||||
comment = Comment.objects.get()
|
||||
assert comment.is_approved is False
|
||||
|
||||
|
||||
# ── Polling ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_poll_returns_new_comments(_article, client, approved_comment):
|
||||
"""Poll endpoint returns only comments after the given ID."""
|
||||
resp = client.get(f"/comments/poll/{_article.id}/?after_id=0")
|
||||
assert resp.status_code == 200
|
||||
assert b"Alice" in resp.content
|
||||
|
||||
resp2 = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
|
||||
assert resp2.status_code == 200
|
||||
assert b"Alice" not in resp2.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_poll_no_duplicates(_article, client, approved_comment):
|
||||
"""Polling with current latest ID returns empty."""
|
||||
resp = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
|
||||
assert b"comment-" not in resp.content
|
||||
|
||||
|
||||
# ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_react_creates_reaction(client, approved_comment):
|
||||
cache.clear()
|
||||
resp = client.post(
|
||||
f"/comments/{approved_comment.id}/react/",
|
||||
{"reaction_type": "heart"},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert CommentReaction.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_react_toggle_removes_reaction(client, approved_comment):
|
||||
"""Second reaction of same type removes it (toggle)."""
|
||||
cache.clear()
|
||||
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||
assert CommentReaction.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_react_different_types_coexist(client, approved_comment):
|
||||
cache.clear()
|
||||
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
|
||||
assert CommentReaction.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_react_invalid_type_returns_400(client, approved_comment):
|
||||
cache.clear()
|
||||
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "invalid"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_react_on_unapproved_comment_returns_404(client, _article):
|
||||
cache.clear()
|
||||
comment = Comment.objects.create(
|
||||
article=_article, author_name="B", author_email="b@b.com", body="x", is_approved=False,
|
||||
)
|
||||
resp = client.post(f"/comments/{comment.id}/react/", {"reaction_type": "heart"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(REACTION_RATE_LIMIT_PER_MINUTE=2)
|
||||
def test_react_rate_limit(client, approved_comment):
|
||||
cache.clear()
|
||||
for _ in range(2):
|
||||
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
# ── CSP ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_csp_allows_turnstile(client, _article):
|
||||
"""CSP header includes Cloudflare Turnstile domains."""
|
||||
resp = client.get(_article.url)
|
||||
csp = resp.get("Content-Security-Policy", "")
|
||||
assert "challenges.cloudflare.com" in csp
|
||||
assert "frame-src" in csp
|
||||
|
||||
|
||||
# ── Purge Command Extension ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_purge_clears_reaction_session_keys(home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>b</p>")])
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
comment = Comment.objects.create(
|
||||
article=article, author_name="X", author_email="x@x.com", body="y", is_approved=True,
|
||||
)
|
||||
reaction = CommentReaction.objects.create(
|
||||
comment=comment, reaction_type="heart", session_key="abc123",
|
||||
)
|
||||
CommentReaction.objects.filter(pk=reaction.pk).update(created_at=timezone.now() - timedelta(days=800))
|
||||
|
||||
call_command("purge_old_comment_data")
|
||||
reaction.refresh_from_db()
|
||||
assert reaction.session_key == ""
|
||||
@@ -1,9 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.comments.views import CommentCreateView, comment_poll, comment_react
|
||||
from apps.comments.views import CommentCreateView
|
||||
|
||||
urlpatterns = [
|
||||
path("post/", CommentCreateView.as_view(), name="comment_post"),
|
||||
path("poll/<int:article_id>/", comment_poll, name="comment_poll"),
|
||||
path("<int:comment_id>/react/", comment_react, name="comment_react"),
|
||||
]
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests as http_requests
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from apps.blog.models import ArticlePage
|
||||
from apps.comments.forms import CommentForm
|
||||
from apps.comments.models import Comment, CommentReaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
def client_ip_from_request(request) -> str:
|
||||
@@ -32,152 +22,12 @@ def client_ip_from_request(request) -> str:
|
||||
return remote_addr
|
||||
|
||||
|
||||
def _is_htmx(request) -> bool:
|
||||
return request.headers.get("HX-Request") == "true"
|
||||
|
||||
|
||||
def _add_vary_header(response):
|
||||
patch_vary_headers(response, ["HX-Request"])
|
||||
return response
|
||||
|
||||
|
||||
def _verify_turnstile(token: str, ip: str) -> bool:
|
||||
secret = getattr(settings, "TURNSTILE_SECRET_KEY", "")
|
||||
if not secret:
|
||||
return False
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
data={"secret": secret, "response": token, "remoteip": ip},
|
||||
timeout=5,
|
||||
)
|
||||
result = resp.json()
|
||||
if not result.get("success"):
|
||||
return False
|
||||
expected_hostname = getattr(settings, "TURNSTILE_EXPECTED_HOSTNAME", "")
|
||||
if expected_hostname and result.get("hostname") != expected_hostname:
|
||||
logger.warning("Turnstile hostname mismatch: %s", result.get("hostname"))
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Turnstile verification failed")
|
||||
return False
|
||||
|
||||
|
||||
def _turnstile_enabled() -> bool:
|
||||
return bool(getattr(settings, "TURNSTILE_SECRET_KEY", ""))
|
||||
|
||||
|
||||
def _get_session_key(request) -> str:
|
||||
session = getattr(request, "session", None)
|
||||
return (session.session_key or "") if session else ""
|
||||
|
||||
|
||||
def _turnstile_site_key():
|
||||
return getattr(settings, "TURNSTILE_SITE_KEY", "")
|
||||
|
||||
|
||||
def _annotate_reaction_counts(comments, session_key=""):
|
||||
"""Hydrate each comment with reaction_counts dict and user_reacted set."""
|
||||
comment_ids = [c.id for c in comments]
|
||||
if not comment_ids:
|
||||
return comments
|
||||
|
||||
counts_qs = (
|
||||
CommentReaction.objects.filter(comment_id__in=comment_ids)
|
||||
.values("comment_id", "reaction_type")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
counts_map = {}
|
||||
for row in counts_qs:
|
||||
counts_map.setdefault(row["comment_id"], {"heart": 0, "plus_one": 0})
|
||||
counts_map[row["comment_id"]][row["reaction_type"]] = row["count"]
|
||||
|
||||
user_map = {}
|
||||
if session_key:
|
||||
user_qs = CommentReaction.objects.filter(
|
||||
comment_id__in=comment_ids, session_key=session_key
|
||||
).values_list("comment_id", "reaction_type")
|
||||
for cid, rtype in user_qs:
|
||||
user_map.setdefault(cid, set()).add(rtype)
|
||||
|
||||
for comment in comments:
|
||||
comment.reaction_counts = counts_map.get(comment.id, {"heart": 0, "plus_one": 0})
|
||||
comment.user_reacted = user_map.get(comment.id, set())
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
def _comment_template_context(comment, article, request):
|
||||
"""Build template context for a single comment partial."""
|
||||
_annotate_reaction_counts([comment], _get_session_key(request))
|
||||
return {
|
||||
"comment": comment,
|
||||
"page": article,
|
||||
"turnstile_site_key": _turnstile_site_key(),
|
||||
}
|
||||
|
||||
|
||||
class CommentCreateView(View):
|
||||
def _render_htmx_error(self, request, article, form):
|
||||
"""Return error form partial for HTMX — swaps the form container itself."""
|
||||
raw_parent_id = request.POST.get("parent_id")
|
||||
if raw_parent_id:
|
||||
try:
|
||||
parent_id = int(raw_parent_id)
|
||||
except (ValueError, TypeError):
|
||||
parent_id = None
|
||||
parent = Comment.objects.filter(pk=parent_id, article=article).first() if parent_id else None
|
||||
if parent:
|
||||
ctx = {
|
||||
"comment": parent, "page": article,
|
||||
"turnstile_site_key": _turnstile_site_key(),
|
||||
"reply_form_errors": form.errors,
|
||||
}
|
||||
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
|
||||
ctx = {
|
||||
"comment_form": form, "page": article,
|
||||
"turnstile_site_key": _turnstile_site_key(),
|
||||
}
|
||||
return _add_vary_header(render(request, "comments/_comment_form.html", ctx))
|
||||
|
||||
def _render_htmx_success(self, request, article, comment):
|
||||
"""Return fresh form + OOB-appended comment (if approved)."""
|
||||
tsk = _turnstile_site_key()
|
||||
oob_html = ""
|
||||
if comment.is_approved:
|
||||
ctx = _comment_template_context(comment, article, request)
|
||||
if comment.parent_id:
|
||||
comment_html = render_to_string("comments/_reply.html", ctx, request)
|
||||
oob_html = (
|
||||
f'<div hx-swap-oob="beforeend:#comment-{comment.parent_id} '
|
||||
f'.replies-container">{comment_html}</div>'
|
||||
)
|
||||
else:
|
||||
comment_html = render_to_string("comments/_comment.html", ctx, request)
|
||||
oob_html = (
|
||||
f'<div hx-swap-oob="beforeend:#comments-list">'
|
||||
f"{comment_html}</div>"
|
||||
)
|
||||
|
||||
if comment.parent_id:
|
||||
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
|
||||
msg = "Reply posted!" if comment.is_approved else "Your reply is awaiting moderation."
|
||||
form_html = render_to_string("comments/_reply_form.html", {
|
||||
"comment": parent, "page": article,
|
||||
"turnstile_site_key": tsk, "reply_success_message": msg,
|
||||
}, request)
|
||||
else:
|
||||
msg = (
|
||||
"Comment posted!" if comment.is_approved
|
||||
else "Your comment has been posted and is awaiting moderation."
|
||||
)
|
||||
form_html = render_to_string("comments/_comment_form.html", {
|
||||
"page": article, "turnstile_site_key": tsk, "success_message": msg,
|
||||
}, request)
|
||||
|
||||
resp = HttpResponse(form_html + oob_html)
|
||||
return _add_vary_header(resp)
|
||||
def _render_article_with_errors(self, request, article, form):
|
||||
context = article.get_context(request)
|
||||
context["page"] = article
|
||||
context["comment_form"] = form
|
||||
return render(request, "blog/article_page.html", context, status=200)
|
||||
|
||||
def post(self, request):
|
||||
ip = client_ip_from_request(request)
|
||||
@@ -195,21 +45,9 @@ class CommentCreateView(View):
|
||||
|
||||
if form.is_valid():
|
||||
if form.cleaned_data.get("honeypot"):
|
||||
if _is_htmx(request):
|
||||
return _add_vary_header(
|
||||
render(request, "comments/_comment_success.html", {"message": "Comment posted!"})
|
||||
)
|
||||
return redirect(f"{article.url}?commented=1")
|
||||
|
||||
# Turnstile verification
|
||||
turnstile_ok = False
|
||||
if _turnstile_enabled():
|
||||
token = request.POST.get("cf-turnstile-response", "")
|
||||
turnstile_ok = _verify_turnstile(token, ip)
|
||||
|
||||
comment = form.save(commit=False)
|
||||
comment.article = article
|
||||
comment.is_approved = turnstile_ok
|
||||
parent_id = form.cleaned_data.get("parent_id")
|
||||
if parent_id:
|
||||
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
|
||||
@@ -218,100 +56,9 @@ class CommentCreateView(View):
|
||||
comment.full_clean()
|
||||
except ValidationError:
|
||||
form.add_error(None, "Reply depth exceeds the allowed limit")
|
||||
if _is_htmx(request):
|
||||
return self._render_htmx_error(request, article, form)
|
||||
context = article.get_context(request)
|
||||
context.update({"page": article, "comment_form": form})
|
||||
return render(request, "blog/article_page.html", context, status=200)
|
||||
return self._render_article_with_errors(request, article, form)
|
||||
comment.save()
|
||||
|
||||
if _is_htmx(request):
|
||||
return self._render_htmx_success(request, article, comment)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
"Comment posted!" if comment.is_approved else "Your comment is awaiting moderation",
|
||||
)
|
||||
messages.success(request, "Your comment is awaiting moderation")
|
||||
return redirect(f"{article.url}?commented=1")
|
||||
|
||||
if _is_htmx(request):
|
||||
return self._render_htmx_error(request, article, form)
|
||||
context = article.get_context(request)
|
||||
context.update({"page": article, "comment_form": form})
|
||||
return render(request, "blog/article_page.html", context, status=200)
|
||||
|
||||
|
||||
@require_GET
|
||||
def comment_poll(request, article_id):
|
||||
"""Return comments newer than after_id for HTMX polling."""
|
||||
article = get_object_or_404(ArticlePage, pk=article_id)
|
||||
after_id = request.GET.get("after_id", "0")
|
||||
try:
|
||||
after_id = int(after_id)
|
||||
except (ValueError, TypeError):
|
||||
after_id = 0
|
||||
|
||||
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
||||
comments = list(
|
||||
article.comments.filter(is_approved=True, parent__isnull=True, id__gt=after_id)
|
||||
.prefetch_related(Prefetch("replies", queryset=approved_replies))
|
||||
.order_by("created_at", "id")
|
||||
)
|
||||
|
||||
_annotate_reaction_counts(comments, _get_session_key(request))
|
||||
|
||||
resp = render(request, "comments/_comment_list_inner.html", {
|
||||
"approved_comments": comments,
|
||||
"page": article,
|
||||
"turnstile_site_key": _turnstile_site_key(),
|
||||
})
|
||||
return _add_vary_header(resp)
|
||||
|
||||
|
||||
@require_POST
|
||||
def comment_react(request, comment_id):
|
||||
"""Toggle a reaction on a comment."""
|
||||
ip = client_ip_from_request(request)
|
||||
key = f"reaction-rate:{ip}"
|
||||
count = cache.get(key, 0)
|
||||
rate_limit = getattr(settings, "REACTION_RATE_LIMIT_PER_MINUTE", 20)
|
||||
if count >= rate_limit:
|
||||
return HttpResponse(status=429)
|
||||
cache.set(key, count + 1, timeout=60)
|
||||
|
||||
comment = get_object_or_404(Comment, pk=comment_id, is_approved=True)
|
||||
reaction_type = request.POST.get("reaction_type", "heart")
|
||||
if reaction_type not in ("heart", "plus_one"):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if not request.session.session_key:
|
||||
request.session.create()
|
||||
session_key = request.session.session_key
|
||||
|
||||
try:
|
||||
existing = CommentReaction.objects.filter(
|
||||
comment=comment, reaction_type=reaction_type, session_key=session_key
|
||||
).first()
|
||||
if existing:
|
||||
existing.delete()
|
||||
else:
|
||||
CommentReaction.objects.create(
|
||||
comment=comment, reaction_type=reaction_type, session_key=session_key
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
counts = {}
|
||||
for rt in ("heart", "plus_one"):
|
||||
counts[rt] = comment.reactions.filter(reaction_type=rt).count()
|
||||
user_reacted = set(
|
||||
comment.reactions.filter(session_key=session_key).values_list("reaction_type", flat=True)
|
||||
)
|
||||
|
||||
if _is_htmx(request):
|
||||
resp = render(request, "comments/_reactions.html", {
|
||||
"comment": comment, "counts": counts, "user_reacted": user_reacted,
|
||||
})
|
||||
return _add_vary_header(resp)
|
||||
|
||||
return JsonResponse({"counts": counts, "user_reacted": list(user_reacted)})
|
||||
return self._render_article_with_errors(request, article, form)
|
||||
|
||||
@@ -41,34 +41,6 @@ class ApproveCommentBulkAction(SnippetBulkAction):
|
||||
) % {"count": num_parent_objects}
|
||||
|
||||
|
||||
class UnapproveCommentBulkAction(SnippetBulkAction):
|
||||
display_name = _("Unapprove")
|
||||
action_type = "unapprove"
|
||||
aria_label = _("Unapprove selected comments")
|
||||
template_name = "comments/confirm_bulk_unapprove.html"
|
||||
action_priority = 30
|
||||
models = [Comment]
|
||||
|
||||
def check_perm(self, snippet):
|
||||
if getattr(self, "can_change_items", None) is None:
|
||||
self.can_change_items = self.request.user.has_perm(get_permission_name("change", self.model))
|
||||
return self.can_change_items
|
||||
|
||||
@classmethod
|
||||
def execute_action(cls, objects, **kwargs):
|
||||
updated = kwargs["self"].model.objects.filter(pk__in=[obj.pk for obj in objects], is_approved=True).update(
|
||||
is_approved=False
|
||||
)
|
||||
return updated, 0
|
||||
|
||||
def get_success_message(self, num_parent_objects, num_child_objects):
|
||||
return ngettext(
|
||||
"%(count)d comment unapproved.",
|
||||
"%(count)d comments unapproved.",
|
||||
num_parent_objects,
|
||||
) % {"count": num_parent_objects}
|
||||
|
||||
|
||||
class CommentViewSet(SnippetViewSet):
|
||||
model = Comment
|
||||
queryset = Comment.objects.all()
|
||||
@@ -98,4 +70,3 @@ class CommentViewSet(SnippetViewSet):
|
||||
|
||||
register_snippet(CommentViewSet)
|
||||
hooks.register("register_bulk_action", ApproveCommentBulkAction)
|
||||
hooks.register("register_bulk_action", UnapproveCommentBulkAction)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.conf import settings as django_settings
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.core.models import SiteSettings
|
||||
@@ -7,7 +6,4 @@ from apps.core.models import SiteSettings
|
||||
def site_settings(request):
|
||||
site = Site.find_for_request(request)
|
||||
settings_obj = SiteSettings.for_site(site) if site else None
|
||||
return {
|
||||
"site_settings": settings_obj,
|
||||
"turnstile_site_key": getattr(django_settings, "TURNSTILE_SITE_KEY", ""),
|
||||
}
|
||||
return {"site_settings": settings_obj}
|
||||
|
||||
@@ -28,12 +28,11 @@ class SecurityHeadersMiddleware:
|
||||
return response
|
||||
response["Content-Security-Policy"] = (
|
||||
f"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{nonce}' https://challenges.cloudflare.com; "
|
||||
f"script-src 'self' 'nonce-{nonce}'; "
|
||||
"style-src 'self' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https://challenges.cloudflare.com; "
|
||||
"frame-src https://challenges.cloudflare.com; "
|
||||
"connect-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"frame-ancestors 'self'"
|
||||
|
||||
@@ -48,7 +48,6 @@ INSTALLED_APPS = [
|
||||
"wagtailseo",
|
||||
"tailwind",
|
||||
"theme",
|
||||
"django_htmx",
|
||||
"apps.core",
|
||||
"apps.blog",
|
||||
"apps.authors",
|
||||
@@ -67,7 +66,6 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
||||
"apps.core.middleware.ConsentMiddleware",
|
||||
]
|
||||
@@ -156,11 +154,6 @@ STORAGES = {
|
||||
|
||||
TAILWIND_APP_NAME = "theme"
|
||||
|
||||
# Cloudflare Turnstile (comment spam protection)
|
||||
TURNSTILE_SITE_KEY = os.getenv("TURNSTILE_SITE_KEY", "")
|
||||
TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", "")
|
||||
TURNSTILE_EXPECTED_HOSTNAME = os.getenv("TURNSTILE_EXPECTED_HOSTNAME", "")
|
||||
|
||||
WAGTAILSEARCH_BACKENDS = {
|
||||
"default": {
|
||||
"BACKEND": "wagtail.search.backends.database",
|
||||
|
||||
@@ -23,12 +23,12 @@ def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@e
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None:
|
||||
"""Successful comment submission must show the awaiting-moderation message."""
|
||||
"""Successful comment submission must show the awaiting-moderation banner."""
|
||||
_go_to_article(page, base_url)
|
||||
_submit_comment(page, body="This is a test comment from Playwright.")
|
||||
|
||||
# HTMX swaps the form container inline — wait for the moderation message
|
||||
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -38,8 +38,7 @@ def test_valid_comment_not_immediately_visible(page: Page, base_url: str) -> Non
|
||||
unique_body = "Unique unmoderated comment body xq7z"
|
||||
_submit_comment(page, body=unique_body)
|
||||
|
||||
# Wait for HTMX response to settle
|
||||
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
expect(page.get_by_text(unique_body)).not_to_be_visible()
|
||||
|
||||
|
||||
@@ -49,7 +48,7 @@ def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
|
||||
_submit_comment(page, body=" ") # whitespace-only body
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible(timeout=10_000)
|
||||
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
|
||||
assert "commented=1" not in page.url
|
||||
|
||||
|
||||
@@ -79,8 +78,8 @@ def test_reply_form_visible_on_approved_comment(page: Page, base_url: str) -> No
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_reply_submission_shows_moderation_message(page: Page, base_url: str) -> None:
|
||||
"""Submitting a reply to an approved comment should show moderation message."""
|
||||
def test_reply_submission_redirects(page: Page, base_url: str) -> None:
|
||||
"""Submitting a reply to an approved comment should redirect with commented=1."""
|
||||
_go_to_article(page, base_url)
|
||||
|
||||
# Click the Reply toggle (summary element)
|
||||
@@ -98,8 +97,8 @@ def test_reply_submission_shows_moderation_message(page: Page, base_url: str) ->
|
||||
reply_container.locator('textarea[name="body"]').fill("This is a test reply.")
|
||||
post_reply_btn.click()
|
||||
|
||||
# HTMX swaps the reply form container inline
|
||||
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
|
||||
@@ -28,10 +28,6 @@ ignore_missing_imports = true
|
||||
module = ["apps.authors.models"]
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["apps.comments.views"]
|
||||
ignore_errors = true
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "config.settings.development"
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ python-dotenv~=1.0.0
|
||||
dj-database-url~=2.2.0
|
||||
django-tailwind~=3.8.0
|
||||
django-csp~=3.8.0
|
||||
django-htmx~=1.21.0
|
||||
requests~=2.32.0
|
||||
pytest~=8.3.0
|
||||
pytest-django~=4.9.0
|
||||
pytest-cov~=5.0.0
|
||||
|
||||
1
static/js/htmx.min.js
vendored
1
static/js/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -18,10 +18,8 @@
|
||||
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
||||
<script src="{% static 'js/htmx.min.js' %}" nonce="{{ request.csp_nonce|default:'' }}" defer></script>
|
||||
{% if turnstile_site_key %}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer nonce="{{ request.csp_nonce|default:'' }}"></script>{% endif %}
|
||||
</head>
|
||||
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative">
|
||||
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
|
||||
{% include 'components/nav.html' %}
|
||||
{% include 'components/cookie_banner.html' %}
|
||||
|
||||
@@ -139,20 +139,148 @@
|
||||
|
||||
<!-- 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>
|
||||
<section class="mt-20 pt-16 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div class="flex items-baseline justify-between mb-10">
|
||||
<h2 class="font-display font-bold text-4xl tracking-tight">Comments</h2>
|
||||
<span class="font-mono text-sm text-zinc-500 uppercase tracking-widest">{{ approved_comments|length }} response{{ approved_comments|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
{% if approved_comments %}
|
||||
{% include "comments/_comment_list.html" %}
|
||||
<div class="space-y-12 mb-16">
|
||||
{% for comment in approved_comments %}
|
||||
<div class="group">
|
||||
<!-- Top-level Comment -->
|
||||
<article id="comment-{{ comment.id }}" class="relative bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 sm:p-8 hover:border-brand-pink/30 transition-colors">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0 rounded-sm shadow-solid-dark/10 dark:shadow-solid-light/5"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="font-display font-bold text-base text-zinc-900 dark:text-zinc-100">{{ comment.author_name }}</span>
|
||||
<time datetime="{{ comment.created_at|date:'c' }}" class="font-mono text-xs text-zinc-500 uppercase tracking-wider">{{ comment.created_at|date:"M j, Y" }}</time>
|
||||
</div>
|
||||
<div class="mt-3 prose prose-sm dark:prose-invert max-w-none text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
{{ comment.body|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="group/details mt-6">
|
||||
<summary class="list-none cursor-pointer flex items-center gap-2 font-mono text-xs font-bold uppercase tracking-widest text-zinc-500 hover:text-brand-pink transition-colors [&::-webkit-details-marker]:hidden">
|
||||
<svg class="w-4 h-4 transition-transform group-open/details:-translate-y-0.5 group-open/details:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
<span class="group-open/details:hidden">Reply</span>
|
||||
<span class="hidden group-open/details:inline text-brand-pink">Cancel Reply</span>
|
||||
</summary>
|
||||
|
||||
<!-- Inline Reply Form -->
|
||||
<div class="mt-8 pt-8 border-t border-zinc-100 dark:border-zinc-800 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<h4 class="font-display font-bold text-sm mb-4 uppercase tracking-wider">Reply to {{ comment.author_name }}</h4>
|
||||
<form method="post" action="{% url 'comment_post' %}" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<input type="text" name="author_name" required placeholder="Name *"
|
||||
class="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all" />
|
||||
<input type="email" name="author_email" required placeholder="Email *"
|
||||
class="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all" />
|
||||
</div>
|
||||
<textarea name="body" required placeholder="Your reply..." rows="3"
|
||||
class="w-full bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all resize-none"></textarea>
|
||||
<input type="text" name="honeypot" hidden />
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="submit" data-testid="post-reply-btn" class="px-6 py-2 bg-brand-pink text-white font-display font-bold text-sm shadow-solid-dark hover:-translate-y-0.5 hover:shadow-solid-dark/80 transition-all active:translate-y-0">Post Reply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
|
||||
<!-- Nested Replies -->
|
||||
{% if comment.replies.all %}
|
||||
<div class="relative ml-6 sm:ml-12 mt-4 space-y-4 pl-6 sm:pl-8 border-l-2 border-zinc-100 dark:border-zinc-800">
|
||||
{% for reply in comment.replies.all %}
|
||||
<article id="comment-{{ reply.id }}" class="bg-zinc-50/50 dark:bg-zinc-900/30 border border-zinc-100 dark:border-zinc-800 p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0 rounded-sm"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||
<span class="font-display font-bold text-sm text-zinc-900 dark:text-zinc-100">{{ reply.author_name }}</span>
|
||||
<time datetime="{{ reply.created_at|date:'c' }}" class="font-mono text-[10px] text-zinc-400 uppercase tracking-wider">{{ reply.created_at|date:"M j, Y" }}</time>
|
||||
</div>
|
||||
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none text-zinc-600 dark:text-zinc-400 leading-relaxed text-sm">
|
||||
{{ reply.body|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="comments-list" class="space-y-8 mb-12"></div>
|
||||
<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 class="bg-zinc-50 dark:bg-zinc-900/50 border-2 border-dashed border-zinc-200 dark:border-zinc-800 p-12 text-center mb-16">
|
||||
<p class="font-mono text-sm text-zinc-500 uppercase tracking-widest">No comments yet. Be the first to join the conversation.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "comments/_comment_form.html" %}
|
||||
<!-- New Comment Form Section -->
|
||||
<div class="relative bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 p-8 sm:p-12 shadow-solid-pink">
|
||||
<div class="max-w-2xl">
|
||||
<h3 class="font-display font-bold text-3xl mb-2">Join the conversation</h3>
|
||||
<p class="font-mono text-sm text-zinc-400 dark:text-zinc-500 mb-10 uppercase tracking-widest">Add your fresh comment below</p>
|
||||
|
||||
{% if comment_form and comment_form.errors %}
|
||||
<div aria-label="Comment form errors" class="mb-8 p-4 bg-red-500/10 border border-red-500/20 font-mono text-sm text-red-400">
|
||||
<div class="font-bold mb-2 uppercase tracking-widest text-xs">There were some errors:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{% if comment_form.non_field_errors %}
|
||||
{% for error in comment_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
{% endif %}
|
||||
{% for field in comment_form %}
|
||||
{% if field.errors %}
|
||||
{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-6">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Full Name</label>
|
||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
|
||||
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Email Address</label>
|
||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
|
||||
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Your Thoughts</label>
|
||||
<textarea name="body" required rows="5"
|
||||
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 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 />
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="group relative inline-flex items-center gap-3 px-8 py-4 bg-brand-pink text-white font-display font-bold uppercase tracking-widest text-sm hover:-translate-y-1 transition-all active:translate-y-0">
|
||||
<span>Post comment</span>
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<div class="group">
|
||||
<!-- Top-level Comment -->
|
||||
<article id="comment-{{ comment.id }}" class="relative bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 sm:p-8 hover:border-brand-pink/30 transition-colors">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0 rounded-sm shadow-solid-dark/10 dark:shadow-solid-light/5"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="font-display font-bold text-base text-zinc-900 dark:text-zinc-100">{{ comment.author_name }}</span>
|
||||
<time datetime="{{ comment.created_at|date:'c' }}" class="font-mono text-xs text-zinc-500 uppercase tracking-wider">{{ comment.created_at|date:"M j, Y" }}</time>
|
||||
</div>
|
||||
<div class="mt-3 prose prose-sm dark:prose-invert max-w-none text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
{{ comment.body|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
|
||||
|
||||
<details class="group/details">
|
||||
<summary class="list-none cursor-pointer flex items-center gap-2 font-mono text-xs font-bold uppercase tracking-widest text-zinc-500 hover:text-brand-pink transition-colors [&::-webkit-details-marker]:hidden">
|
||||
<svg class="w-4 h-4 transition-transform group-open/details:-translate-y-0.5 group-open/details:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
<span class="group-open/details:hidden">Reply</span>
|
||||
<span class="hidden group-open/details:inline text-brand-pink">Cancel Reply</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-zinc-100 dark:border-zinc-800 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
{% include "comments/_reply_form.html" with page=page comment=comment %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Nested Replies -->
|
||||
<div class="replies-container relative ml-6 sm:ml-12 mt-4 space-y-4 pl-6 sm:pl-8 border-l-2 border-zinc-100 dark:border-zinc-800">
|
||||
{% for reply in comment.replies.all %}
|
||||
{% include "comments/_reply.html" with reply=reply %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,66 +0,0 @@
|
||||
{% load static %}
|
||||
<div id="comment-form-container" class="relative bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 p-8 sm:p-12 shadow-solid-pink">
|
||||
<div class="max-w-2xl">
|
||||
<h3 class="font-display font-bold text-3xl mb-2">Join the conversation</h3>
|
||||
<p class="font-mono text-sm text-zinc-400 dark:text-zinc-500 mb-10 uppercase tracking-widest">Add your fresh comment below</p>
|
||||
|
||||
{% if success_message %}
|
||||
<div class="mb-8 p-4 bg-brand-cyan/10 border border-brand-cyan/20 font-mono text-sm text-brand-cyan animate-in fade-in">
|
||||
{{ success_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if comment_form.errors %}
|
||||
<div aria-label="Comment form errors" class="mb-8 p-4 bg-red-500/10 border border-red-500/20 font-mono text-sm text-red-400">
|
||||
<div class="font-bold mb-2 uppercase tracking-widest text-xs">There were some errors:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{% if comment_form.non_field_errors %}
|
||||
{% for error in comment_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
{% endif %}
|
||||
{% for field in comment_form %}
|
||||
{% if field.errors %}
|
||||
{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-6"
|
||||
hx-post="{% url 'comment_post' %}" hx-target="#comment-form-container" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Full Name</label>
|
||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
|
||||
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Email Address</label>
|
||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
|
||||
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Your Thoughts</label>
|
||||
<textarea name="body" required rows="5"
|
||||
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 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 />
|
||||
|
||||
{% if turnstile_site_key %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="group relative inline-flex items-center gap-3 px-8 py-4 bg-brand-pink text-white font-display font-bold uppercase tracking-widest text-sm hover:-translate-y-1 transition-all active:translate-y-0">
|
||||
<span>Post comment</span>
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<div id="comments-list" class="space-y-12 mb-16"
|
||||
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>
|
||||
@@ -1,3 +0,0 @@
|
||||
{% for comment in approved_comments %}
|
||||
{% include "comments/_comment.html" with comment=comment page=page %}
|
||||
{% endfor %}
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,14 +0,0 @@
|
||||
<article id="comment-{{ reply.id }}" class="bg-zinc-50/50 dark:bg-zinc-900/30 border border-zinc-100 dark:border-zinc-800 p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0 rounded-sm"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||
<span class="font-display font-bold text-sm text-zinc-900 dark:text-zinc-100">{{ reply.author_name }}</span>
|
||||
<time datetime="{{ reply.created_at|date:'c' }}" class="font-mono text-[10px] text-zinc-400 uppercase tracking-wider">{{ reply.created_at|date:"M j, Y" }}</time>
|
||||
</div>
|
||||
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none text-zinc-600 dark:text-zinc-400 leading-relaxed text-sm">
|
||||
{{ reply.body|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,46 +0,0 @@
|
||||
{% load static %}
|
||||
<div id="reply-form-container-{{ comment.id }}">
|
||||
<h4 class="font-display font-bold text-sm mb-4 uppercase tracking-wider">Reply to {{ comment.author_name }}</h4>
|
||||
|
||||
{% if reply_success_message %}
|
||||
<div class="mb-6 p-4 bg-brand-cyan/10 border border-brand-cyan/20 font-mono text-sm text-brand-cyan animate-in fade-in">
|
||||
{{ reply_success_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if reply_form_errors %}
|
||||
<div aria-label="Comment form errors" class="mb-6 p-4 bg-red-500/10 border border-red-500/20 font-mono text-sm text-red-400 animate-in shake-1">
|
||||
<div class="font-bold mb-2 uppercase tracking-widest text-xs">Errors:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{% for field, errors in reply_form_errors.items %}
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'comment_post' %}"
|
||||
hx-post="{% url 'comment_post' %}" hx-target="#reply-form-container-{{ comment.id }}" hx-swap="outerHTML"
|
||||
class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<input type="text" name="author_name" required placeholder="Name *"
|
||||
class="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all" />
|
||||
<input type="email" name="author_email" required placeholder="Email *"
|
||||
class="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all" />
|
||||
</div>
|
||||
<textarea name="body" required placeholder="Your reply..." rows="3"
|
||||
class="w-full bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all resize-none"></textarea>
|
||||
<input type="text" name="honeypot" hidden />
|
||||
|
||||
{% if turnstile_site_key %}
|
||||
<div class="cf-turnstile mb-4" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="flexible"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="submit" data-testid="post-reply-btn" class="px-6 py-2 bg-brand-pink text-white font-display font-bold text-sm shadow-solid-dark hover:-translate-y-0.5 hover:shadow-solid-dark/80 transition-all active:translate-y-0">Post Reply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
{% 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 %}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user