Compare commits
8 Commits
ci/retrigg
...
fix/commen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393a574500 | ||
|
|
2f9babe18e | ||
| d39fff2be0 | |||
|
|
badd61b0aa | ||
|
|
a001ac1de6 | ||
| 9bee1b9a12 | |||
|
|
4796a08acc | ||
| 17484fa815 |
@@ -215,7 +215,7 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: deploy
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy to lintel-prod-01
|
- name: Deploy to lintel-prod-01
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
@@ -138,6 +140,54 @@ def test_article_page_renders_approved_comments_and_reply_form(client, home_page
|
|||||||
assert "Top level" in html
|
assert "Top level" in html
|
||||||
assert "Reply" in html
|
assert "Reply" in html
|
||||||
assert f'name="parent_id" value="{comment.id}"' in html
|
assert f'name="parent_id" value="{comment.id}"' in html
|
||||||
|
match = re.search(r'id="comments-empty-state"[^>]*class="([^"]+)"', html)
|
||||||
|
assert match is not None
|
||||||
|
assert "hidden" in match.group(1).split()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_page_shows_empty_state_when_no_approved_comments(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Main",
|
||||||
|
slug="main",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/main/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'id="comments-empty-state"' in html
|
||||||
|
assert "No comments yet. Be the first to comment." in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_page_loads_comment_client_script(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Main",
|
||||||
|
slug="main",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/main/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'src="/static/js/comments.js"' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article)
|
|||||||
# OOB swap appends the comment to #comments-list
|
# OOB swap appends the comment to #comments-list
|
||||||
assert "hx-swap-oob" in content
|
assert "hx-swap-oob" in content
|
||||||
assert "Hello world" in content
|
assert "Hello world" in content
|
||||||
|
assert 'id="comments-empty-state" hx-swap-oob="delete"' in content
|
||||||
comment = Comment.objects.get()
|
comment = Comment.objects.get()
|
||||||
assert comment.is_approved is True
|
assert comment.is_approved is True
|
||||||
|
|
||||||
@@ -132,8 +133,8 @@ def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_c
|
|||||||
)
|
)
|
||||||
content = resp.content.decode()
|
content = resp.content.decode()
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# OOB targets the sibling .replies-container of the parent comment article
|
# OOB targets a stable, explicit replies container for the parent comment.
|
||||||
assert f'hx-swap-oob="beforeend:#comment-{approved_comment.id} ~ .replies-container"' in content
|
assert f'hx-swap-oob="beforeend:#replies-for-{approved_comment.id}"' in content
|
||||||
# Verify content is rendered (not empty due to context mismatch)
|
# Verify content is rendered (not empty due to context mismatch)
|
||||||
assert "Replier" in content
|
assert "Replier" in content
|
||||||
assert "Nice reply" in content
|
assert "Nice reply" in content
|
||||||
@@ -165,6 +166,30 @@ def test_htmx_error_with_tampered_parent_id_falls_back_to_main_form(client, _art
|
|||||||
assert b"comment-form-container" in resp.content
|
assert b"comment-form-container" in resp.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_htmx_invalid_reply_rerenders_reply_form_with_values(client, _article, approved_comment):
|
||||||
|
"""Invalid reply keeps user input and returns the reply form container."""
|
||||||
|
cache.clear()
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{
|
||||||
|
"article_id": _article.id,
|
||||||
|
"parent_id": approved_comment.id,
|
||||||
|
"author_name": "Reply User",
|
||||||
|
"author_email": "reply@example.com",
|
||||||
|
"body": " ",
|
||||||
|
"honeypot": "",
|
||||||
|
},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert f'id="reply-form-container-{approved_comment.id}"' in content
|
||||||
|
assert "Comment form errors" in content
|
||||||
|
assert 'value="Reply User"' in content
|
||||||
|
assert "reply@example.com" in content
|
||||||
|
|
||||||
|
|
||||||
# ── Turnstile Integration ────────────────────────────────────────────────────
|
# ── Turnstile Integration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ class CommentCreateView(View):
|
|||||||
"comment": parent, "page": article,
|
"comment": parent, "page": article,
|
||||||
"turnstile_site_key": _turnstile_site_key(),
|
"turnstile_site_key": _turnstile_site_key(),
|
||||||
"reply_form_errors": form.errors,
|
"reply_form_errors": form.errors,
|
||||||
|
"reply_form": form,
|
||||||
}
|
}
|
||||||
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
|
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
|
||||||
ctx = {
|
ctx = {
|
||||||
@@ -144,7 +145,7 @@ class CommentCreateView(View):
|
|||||||
def _render_htmx_success(self, request, article, comment):
|
def _render_htmx_success(self, request, article, comment):
|
||||||
"""Return fresh form + OOB-appended comment (if approved)."""
|
"""Return fresh form + OOB-appended comment (if approved)."""
|
||||||
tsk = _turnstile_site_key()
|
tsk = _turnstile_site_key()
|
||||||
oob_html = ""
|
oob_parts = []
|
||||||
if comment.is_approved:
|
if comment.is_approved:
|
||||||
ctx = _comment_template_context(comment, article, request)
|
ctx = _comment_template_context(comment, article, request)
|
||||||
if comment.parent_id:
|
if comment.parent_id:
|
||||||
@@ -152,17 +153,14 @@ class CommentCreateView(View):
|
|||||||
reply_ctx = ctx.copy()
|
reply_ctx = ctx.copy()
|
||||||
reply_ctx["reply"] = reply_ctx.pop("comment")
|
reply_ctx["reply"] = reply_ctx.pop("comment")
|
||||||
comment_html = render_to_string("comments/_reply.html", reply_ctx, request)
|
comment_html = render_to_string("comments/_reply.html", reply_ctx, request)
|
||||||
# .replies-container is now a sibling of #comment-{id}
|
oob_parts.append(
|
||||||
oob_html = (
|
f'<div hx-swap-oob="beforeend:#replies-for-{comment.parent_id}">{comment_html}</div>'
|
||||||
f'<div hx-swap-oob="beforeend:#comment-{comment.parent_id} '
|
|
||||||
f'~ .replies-container">{comment_html}</div>'
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
comment_html = render_to_string("comments/_comment.html", ctx, request)
|
comment_html = render_to_string("comments/_comment.html", ctx, request)
|
||||||
oob_html = (
|
oob_parts.append(f'<div hx-swap-oob="beforeend:#comments-list">{comment_html}</div>')
|
||||||
f'<div hx-swap-oob="beforeend:#comments-list">'
|
# Ensure stale empty-state copy is removed when the first approved comment appears.
|
||||||
f"{comment_html}</div>"
|
oob_parts.append('<div id="comments-empty-state" hx-swap-oob="delete"></div>')
|
||||||
)
|
|
||||||
|
|
||||||
if comment.parent_id:
|
if comment.parent_id:
|
||||||
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
|
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
|
||||||
@@ -180,7 +178,7 @@ class CommentCreateView(View):
|
|||||||
"page": article, "turnstile_site_key": tsk, "success_message": msg,
|
"page": article, "turnstile_site_key": tsk, "success_message": msg,
|
||||||
}, request)
|
}, request)
|
||||||
|
|
||||||
resp = HttpResponse(form_html + oob_html)
|
resp = HttpResponse(form_html + "".join(oob_parts))
|
||||||
return _add_vary_header(resp)
|
return _add_vary_header(resp)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
|||||||
91
static/js/comments.js
Normal file
91
static/js/comments.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
(function () {
|
||||||
|
function renderTurnstileWidgets(root) {
|
||||||
|
if (!root || !window.turnstile || typeof window.turnstile.render !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = [];
|
||||||
|
if (root.matches && root.matches(".cf-turnstile")) {
|
||||||
|
widgets.push(root);
|
||||||
|
}
|
||||||
|
if (root.querySelectorAll) {
|
||||||
|
widgets.push(...root.querySelectorAll(".cf-turnstile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.forEach(function (widget) {
|
||||||
|
if (widget.dataset.turnstileRendered === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.querySelector("iframe")) {
|
||||||
|
widget.dataset.turnstileRendered = "true";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitekey = widget.dataset.sitekey;
|
||||||
|
if (!sitekey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
sitekey: sitekey,
|
||||||
|
theme: widget.dataset.theme || "auto",
|
||||||
|
};
|
||||||
|
if (widget.dataset.size) {
|
||||||
|
options.size = widget.dataset.size;
|
||||||
|
}
|
||||||
|
if (widget.dataset.action) {
|
||||||
|
options.action = widget.dataset.action;
|
||||||
|
}
|
||||||
|
if (widget.dataset.appearance) {
|
||||||
|
options.appearance = widget.dataset.appearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.turnstile.render(widget, options);
|
||||||
|
widget.dataset.turnstileRendered = "true";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCommentsEmptyState() {
|
||||||
|
const emptyState = document.getElementById("comments-empty-state");
|
||||||
|
const commentsList = document.getElementById("comments-list");
|
||||||
|
if (!emptyState || !commentsList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasComments = commentsList.querySelector("[data-comment-item='true']") !== null;
|
||||||
|
emptyState.classList.toggle("hidden", hasComments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTurnstileReady(root) {
|
||||||
|
if (!window.turnstile || typeof window.turnstile.ready !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.turnstile.ready(function () {
|
||||||
|
renderTurnstileWidgets(root || document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
syncCommentsEmptyState();
|
||||||
|
onTurnstileReady(document);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("htmx:afterSwap", function (event) {
|
||||||
|
const target = event.detail && event.detail.target ? event.detail.target : document;
|
||||||
|
syncCommentsEmptyState();
|
||||||
|
onTurnstileReady(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("toggle", function (event) {
|
||||||
|
const details = event.target;
|
||||||
|
if (!details || details.tagName !== "DETAILS" || !details.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTurnstileReady(details);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
syncCommentsEmptyState();
|
||||||
|
onTurnstileReady(document);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<script src="{% static 'js/theme.js' %}" defer></script>
|
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||||
<script src="{% static 'js/prism.js' %}" defer></script>
|
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||||
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/comments.js' %}" defer></script>
|
||||||
<script src="{% static 'js/htmx.min.js' %}" nonce="{{ request.csp_nonce|default:'' }}" 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 %}
|
{% if turnstile_site_key %}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer nonce="{{ request.csp_nonce|default:'' }}"></script>{% endif %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -141,16 +141,15 @@
|
|||||||
{% if page.comments_enabled %}
|
{% if page.comments_enabled %}
|
||||||
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
|
<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>
|
<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>
|
<h2 class="font-display font-bold text-3xl">Comments</h2>
|
||||||
|
<p class="mt-2 mb-6 font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
{{ approved_comments|length }} public comment{{ approved_comments|length|pluralize }}
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if approved_comments %}
|
{% include "comments/_comment_list.html" %}
|
||||||
{% include "comments/_comment_list.html" %}
|
<div id="comments-empty-state" class="mb-8 rounded-md border border-zinc-200 bg-zinc-50 p-4 text-center dark:border-zinc-800 dark:bg-zinc-900/40 {% if approved_comments %}hidden{% endif %}">
|
||||||
{% 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>
|
<p class="font-mono text-sm text-zinc-500">No comments yet. Be the first to comment.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% include "comments/_comment_form.html" %}
|
{% include "comments/_comment_form.html" %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
<div class="group">
|
<div class="group">
|
||||||
<!-- Top-level Comment -->
|
<article
|
||||||
<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">
|
id="comment-{{ comment.id }}"
|
||||||
<div class="flex items-start gap-4 mb-4">
|
data-comment-item="true"
|
||||||
<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>
|
class="rounded-lg border border-zinc-200 bg-brand-surfaceLight p-5 shadow-sm transition-colors hover:border-zinc-300 dark:border-zinc-800 dark:bg-brand-surfaceDark dark:hover:border-zinc-700 sm:p-6"
|
||||||
<div class="flex-1 min-w-0">
|
>
|
||||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<header class="mb-3 flex flex-wrap items-center 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>
|
<span class="font-display text-base font-bold 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>
|
<time datetime="{{ comment.created_at|date:'c' }}" class="font-mono text-[11px] uppercase tracking-wider text-zinc-500">
|
||||||
</div>
|
{{ comment.created_at|date:"M j, Y" }}
|
||||||
<div class="mt-3 prose prose-sm dark:prose-invert max-w-none text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
</time>
|
||||||
{{ comment.body|linebreaks }}
|
</header>
|
||||||
</div>
|
|
||||||
</div>
|
<div class="prose prose-sm mt-2 max-w-none leading-relaxed text-zinc-700 dark:prose-invert dark:text-zinc-300">
|
||||||
|
{{ comment.body|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-6">
|
<div class="mt-5 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||||
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
|
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
|
||||||
|
|
||||||
<details class="group/details">
|
<details class="group/details mt-3">
|
||||||
<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">
|
<summary class="list-none cursor-pointer font-mono text-xs font-bold uppercase tracking-wider text-zinc-500 transition-colors hover:text-brand-cyan [&::-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="group-open/details:hidden">Reply</span>
|
||||||
<span class="hidden group-open/details:inline text-brand-pink">Cancel Reply</span>
|
<span class="hidden group-open/details:inline">Cancel reply</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
<div class="mt-4 rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-950">
|
||||||
<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 %}
|
{% include "comments/_reply_form.html" with page=page comment=comment %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Nested Replies -->
|
<div id="replies-for-{{ comment.id }}" class="replies-container mt-3 space-y-3 border-l-2 border-zinc-100 pl-4 sm:ml-8 sm:pl-6 dark:border-zinc-800">
|
||||||
<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 %}
|
{% for reply in comment.replies.all %}
|
||||||
{% include "comments/_reply.html" with reply=reply %}
|
{% include "comments/_reply.html" with reply=reply %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,60 +1,92 @@
|
|||||||
{% load static %}
|
{% 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 id="comment-form-container" class="rounded-lg border border-zinc-200 bg-brand-surfaceLight p-6 shadow-sm dark:border-zinc-800 dark:bg-brand-surfaceDark sm:p-8">
|
||||||
<div class="max-w-2xl">
|
<div class="max-w-3xl">
|
||||||
<h3 class="font-display font-bold text-3xl mb-2">Join the conversation</h3>
|
<h3 class="font-display text-2xl font-bold text-zinc-900 dark:text-zinc-100">Leave a comment</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>
|
<p class="mt-1 font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
Keep it constructive. Your email will not be shown publicly.
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if success_message %}
|
{% 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">
|
<div class="mt-5 rounded-md border border-brand-cyan/30 bg-brand-cyan/10 p-3 font-mono text-sm text-brand-cyan">
|
||||||
{{ success_message }}
|
{{ success_message }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if comment_form.errors %}
|
{% 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 aria-label="Comment form errors" class="mt-5 rounded-md border border-red-500/30 bg-red-500/10 p-4 font-mono text-sm text-red-500">
|
||||||
<div class="font-bold mb-2 uppercase tracking-widest text-xs">There were some errors:</div>
|
<div class="mb-2 text-xs font-bold uppercase tracking-wider">There were some errors:</div>
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
{% if comment_form.non_field_errors %}
|
{% if comment_form.non_field_errors %}
|
||||||
{% for error in comment_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% for field in comment_form %}
|
||||||
</ul>
|
{% if field.errors %}
|
||||||
</div>
|
{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-6"
|
<form
|
||||||
hx-post="{% url 'comment_post' %}" hx-target="#comment-form-container" hx-swap="outerHTML">
|
method="post"
|
||||||
|
action="{% url 'comment_post' %}"
|
||||||
|
data-comment-form
|
||||||
|
class="mt-6 space-y-5"
|
||||||
|
hx-post="{% url 'comment_post' %}"
|
||||||
|
hx-target="#comment-form-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
<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">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Full Name</label>
|
<div>
|
||||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
|
<label for="comment-author-name" class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wider text-zinc-500">Name</label>
|
||||||
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" />
|
<input
|
||||||
|
id="comment-author-name"
|
||||||
|
type="text"
|
||||||
|
name="author_name"
|
||||||
|
value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div>
|
||||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Email Address</label>
|
<label for="comment-author-email" class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wider text-zinc-500">Email</label>
|
||||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
|
<input
|
||||||
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" />
|
id="comment-author-email"
|
||||||
|
type="email"
|
||||||
|
name="author_email"
|
||||||
|
value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Your Thoughts</label>
|
<div>
|
||||||
<textarea name="body" required rows="5"
|
<label for="comment-body" class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wider text-zinc-500">Comment</label>
|
||||||
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>
|
<textarea
|
||||||
|
id="comment-body"
|
||||||
|
name="body"
|
||||||
|
required
|
||||||
|
rows="5"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="text" name="honeypot" hidden />
|
<input type="text" name="honeypot" hidden />
|
||||||
|
|
||||||
{% if turnstile_site_key %}
|
{% if turnstile_site_key %}
|
||||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="pt-4">
|
<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">
|
<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>
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div id="comments-list" class="space-y-12 mb-16"
|
<div id="comments-list" class="space-y-6 mb-8"
|
||||||
hx-get="{% url 'comment_poll' article_id=page.id %}" hx-trigger="every 30s" hx-swap="innerHTML">
|
hx-get="{% url 'comment_poll' article_id=page.id %}" hx-trigger="every 30s" hx-swap="innerHTML">
|
||||||
{% for comment in approved_comments %}
|
{% for comment in approved_comments %}
|
||||||
{% include "comments/_comment.html" with comment=comment page=page %}
|
{% include "comments/_comment.html" with comment=comment page=page %}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
<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">
|
<article id="comment-{{ reply.id }}" class="rounded-md border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/40">
|
||||||
<div class="flex items-start gap-3 mb-3">
|
<header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<div class="w-8 h-8 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0 rounded-sm"></div>
|
<span class="font-display text-sm font-bold text-zinc-900 dark:text-zinc-100">{{ reply.author_name }}</span>
|
||||||
<div class="flex-1 min-w-0">
|
<time datetime="{{ reply.created_at|date:'c' }}" class="font-mono text-[10px] uppercase tracking-wider text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</time>
|
||||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
</header>
|
||||||
<span class="font-display font-bold text-sm text-zinc-900 dark:text-zinc-100">{{ reply.author_name }}</span>
|
<div class="prose prose-sm max-w-none text-sm leading-relaxed text-zinc-700 dark:prose-invert dark:text-zinc-300">
|
||||||
<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>
|
{{ reply.body|linebreaks }}
|
||||||
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,46 +1,77 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<div id="reply-form-container-{{ comment.id }}">
|
<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>
|
<h4 class="mb-3 font-display text-sm font-bold uppercase tracking-wider text-zinc-700 dark:text-zinc-200">Reply to {{ comment.author_name }}</h4>
|
||||||
|
|
||||||
{% if reply_success_message %}
|
{% 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">
|
<div class="mb-4 rounded-md border border-brand-cyan/30 bg-brand-cyan/10 p-3 font-mono text-sm text-brand-cyan">
|
||||||
{{ reply_success_message }}
|
{{ reply_success_message }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if reply_form_errors %}
|
{% 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 aria-label="Comment form errors" class="mb-4 rounded-md border border-red-500/30 bg-red-500/10 p-3 font-mono text-sm text-red-500">
|
||||||
<div class="font-bold mb-2 uppercase tracking-widest text-xs">Errors:</div>
|
<div class="mb-2 text-xs font-bold uppercase tracking-wider">Errors:</div>
|
||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
{% for field, errors in reply_form_errors.items %}
|
{% for field, errors in reply_form_errors.items %}
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'comment_post' %}"
|
<form
|
||||||
hx-post="{% url 'comment_post' %}" hx-target="#reply-form-container-{{ comment.id }}" hx-swap="outerHTML"
|
method="post"
|
||||||
class="space-y-4">
|
action="{% url 'comment_post' %}"
|
||||||
|
hx-post="{% url 'comment_post' %}"
|
||||||
|
hx-target="#reply-form-container-{{ comment.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
<input type="hidden" name="parent_id" value="{{ comment.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 *"
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
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
|
||||||
<input type="email" name="author_email" required placeholder="Email *"
|
type="text"
|
||||||
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" />
|
name="author_name"
|
||||||
|
required
|
||||||
|
placeholder="Name"
|
||||||
|
value="{% if reply_form %}{{ reply_form.author_name.value|default:'' }}{% endif %}"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="author_email"
|
||||||
|
required
|
||||||
|
placeholder="Email"
|
||||||
|
value="{% if reply_form %}{{ reply_form.author_email.value|default:'' }}{% endif %}"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
<textarea
|
||||||
|
name="body"
|
||||||
|
required
|
||||||
|
placeholder="Write your reply"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
>{% if reply_form %}{{ reply_form.body.value|default:'' }}{% endif %}</textarea>
|
||||||
|
|
||||||
<input type="text" name="honeypot" hidden />
|
<input type="text" name="honeypot" hidden />
|
||||||
|
|
||||||
{% if turnstile_site_key %}
|
{% if turnstile_site_key %}
|
||||||
<div class="cf-turnstile mb-4" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="flexible"></div>
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="flexible"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-start">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user