- Rate limits in the shared cache for comments, issues, uploads and invites; a limited HTMX reply keeps the draft. - Content-Security-Policy (self only, no inline script or style, bucket origin for images and frames) and Permissions-Policy on every page. htmx configured not to inject styles or eval. - Moderation: deactivating a user ends their sessions and keeps their content; deleted accounts show as "ghost"; reserved usernames; the admin won't delete an org's last owner. - Admin requires a TOTP code (django-otp), with add_admin_totp to enrol. - Email provider chosen by environment through django-anymail. - Playwright browser tests: axe WCAG 2.1 AA on every page in light and dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit Canvas/CanvasText on :root fixes dark-mode contrast detection. - bin/start creates the data dir if missing; docs/operations.md runbook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
48 lines
2.5 KiB
HTML
48 lines
2.5 KiB
HTML
{% comment %}
|
||
One comment. Needs `c` (prepared by intent.comments.presenters), `thread`, `project`, `focus`.
|
||
Forms post normally; with HTMX they swap the whole thread (`thread.dom_id`) in place.
|
||
{% endcomment %}
|
||
{% with by=c.author.username|default:"ghost" comment_id="comment-"|add:c.pk %}
|
||
<article id="{{ comment_id }}" class="comment" tabindex="-1"{% if focus == comment_id %} data-autofocus{% endif %}>
|
||
<header>
|
||
<strong>{{ by }}</strong>
|
||
<a href="#{{ comment_id }}"><time datetime="{{ c.created_at|date:'c' }}">{{ c.created_at|date:"j M Y, H:i" }}</time></a>
|
||
{% if c.edited_at and not c.is_deleted %}
|
||
· <a href="{% url 'comments:history' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk %}">edited<span class="visually-hidden"> (see earlier versions of {{ by }}’s comment)</span></a>
|
||
{% endif %}
|
||
</header>
|
||
{% if c.is_deleted %}
|
||
<p class="muted"><em>Comment deleted.</em></p>
|
||
{% else %}
|
||
<div class="prose">{{ c.html }}</div>
|
||
{% endif %}
|
||
{% if c.can_edit or c.can_delete %}
|
||
<div class="actions">
|
||
{% if c.can_edit %}
|
||
{% url 'comments:edit' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk as edit_url %}
|
||
<details>
|
||
<summary>Edit<span class="visually-hidden"> {{ by }}’s comment</span></summary>
|
||
<form method="post" action="{{ edit_url }}" hx-post="{{ edit_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
|
||
{% csrf_token %}
|
||
<label for="edit-{{ c.pk }}" class="visually-hidden">Comment text</label>
|
||
<textarea id="edit-{{ c.pk }}" name="body" rows="4" required>{{ c.body }}</textarea>
|
||
<button type="submit">Save edit</button>
|
||
</form>
|
||
</details>
|
||
{% endif %}
|
||
{% if c.can_delete %}
|
||
{% url 'comments:delete' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk as delete_url %}
|
||
{# A disclosure rather than a script confirm, so the second step works without JS. #}
|
||
<details>
|
||
<summary>Delete<span class="visually-hidden"> {{ by }}’s comment</span></summary>
|
||
<form method="post" action="{{ delete_url }}" hx-post="{{ delete_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
|
||
{% csrf_token %}
|
||
<p>The text and its edit history will be removed. This can't be undone.</p>
|
||
<button type="submit">Delete comment</button>
|
||
</form>
|
||
</details>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</article>
|
||
{% endwith %}
|