intent/templates/files/detail.html
Alec Perkins 604c2bbf91
Some checks failed
ci / test (push) Failing after 1m6s
ci / deploy (push) Failing after 0s
Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
- 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>
2026-10-27 10:51:06 -04:00

138 lines
7 KiB
HTML

{% extends "projects/base.html" %}
{% block page_title %}{{ version.original_name }} · {{ feature.key }}{% endblock %}
{% block project_content %}
{% url 'files:raw' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk version=version.version name=version.original_name as raw_url %}
{% url 'files:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk as file_url %}
<p><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}#files">Back to {{ feature.key }} {{ feature.title }}</a></p>
<h1>{{ version.original_name }} <span class="muted">version {{ version.version }}</span></h1>
<p>
{{ file.get_kind_display }} for {{ feature.key }}.
Uploaded by {{ version.uploaded_by.username|default:"ghost" }}
on <time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"j M Y" }}</time>,
{{ version.size|filesizeformat }}.
<a href="{{ raw_url }}"{% if not version.opens_inline %} download{% endif %}>{% if version.opens_inline %}Open{% else %}Download{% endif %}<span class="visually-hidden"> {{ version.original_name }}</span></a>
</p>
{% if file.is_removed %}
<p role="note"><strong>Removed</strong> from the feature in revision {{ file.removed_in_revision }}. Its versions and comments are kept.</p>
{% elif not is_current %}
<p role="note"><strong>Older version.</strong> <a href="{{ file_url }}">See the latest, version {{ file.current_version }}</a>.</p>
{% endif %}
{% if version.is_image %}
<figure class="pinboard{% if is_current and access.can_contribute %} placing{% endif %}" data-pinboard>
<img src="{{ raw_url }}" alt="{{ version.original_name }}, version {{ version.version }}.{% if pins %} Pinned comments are listed below the image.{% endif %}">
<svg class="pins" role="group" aria-label="Pins" data-pins>
{% for t in pins %}
<a href="#thread-{{ t.pk }}" aria-label="Pin {{ t.pin_number }}, thread by {{ t.author.username|default:'ghost' }}">
<circle cx="{{ t.x_pct }}%" cy="{{ t.y_pct }}%" r="12"></circle>
<text x="{{ t.x_pct }}%" y="{{ t.y_pct }}%">{{ t.pin_number }}</text>
</a>
{% endfor %}
</svg>
</figure>
{% elif version.is_html %}
{# Sandboxed without allow-same-origin: the prototype runs in an opaque origin. #}
<iframe class="prototype" src="{{ raw_url }}" sandbox="allow-scripts allow-modals allow-popups" title="Prototype: {{ version.original_name }}"></iframe>
{% elif version.is_text %}
{% if version.content_type == "text/markdown" %}
<div class="prose">{{ preview }}</div>
{% else %}
<pre class="quote">{{ preview }}</pre>
{% endif %}
{% if truncated %}<p class="muted">Only the start of the file is shown. Open it to see everything.</p>{% endif %}
{% endif %}
<section id="comments" aria-labelledby="comments-heading">
<h2 id="comments-heading">Comments on this version</h2>
{% for thread in file_threads %}
{% include "comments/_thread.html" %}
{% empty %}
<p class="muted">No comments yet.</p>
{% endfor %}
{% if is_current and access.can_contribute %}
<form method="post" action="{% url 'files:thread_create' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk %}">
{% csrf_token %}
{% if version.is_image %}
<fieldset>
<legend>Pin to a point (optional)</legend>
<p class="muted" id="pin-help">Click the image to place a pin, or enter its position.</p>
<label for="pin-across">Across (%)</label>
<input type="number" id="pin-across" name="across" min="0" max="100" step="0.1" aria-describedby="pin-help">
<label for="pin-down">Down (%)</label>
<input type="number" id="pin-down" name="down" min="0" max="100" step="0.1" aria-describedby="pin-help">
</fieldset>
{% endif %}
<label for="file-comment">Comment on {{ version.original_name }}</label>
<textarea id="file-comment" name="body" rows="4" required></textarea>
<button type="submit">Comment</button>
</form>
{% elif not user.is_authenticated and project.open_contribution %}
<p><a href="{% url 'account_login' %}?next={{ request.path|urlencode }}">Sign in</a> to comment.</p>
{% endif %}
</section>
{% if other_threads %}
<section aria-labelledby="other-heading">
<h2 id="other-heading">Comments on other versions</h2>
{% for thread in other_threads %}
{% include "comments/_thread.html" %}
{% endfor %}
</section>
{% endif %}
<section aria-labelledby="versions-heading">
<h2 id="versions-heading">Versions</h2>
<table>
<thead>
<tr>
<th scope="col">Version</th>
<th scope="col">Name</th>
<th scope="col">Uploaded</th>
<th scope="col">Feature revision</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody>
{% for v in versions %}
<tr>
<th scope="row">
{% if v.version == version.version %}{{ v.version }} (shown){% else %}<a href="{{ file_url }}?version={{ v.version }}">{{ v.version }}</a>{% endif %}
</th>
<td>{{ v.original_name }}</td>
<td><time datetime="{{ v.created_at|date:'c' }}">{{ v.created_at|date:"j M Y" }}</time> by {{ v.uploaded_by.username|default:"ghost" }}</td>
<td><a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ v.created_in_revision }}</a></td>
<td>{{ v.size|filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if access.can_edit and not file.is_removed %}
<section aria-labelledby="change-heading">
<h2 id="change-heading">Change this file</h2>
<p>Each change is a new revision of {{ feature.key }}. Comments on earlier versions are kept.</p>
<details>
<summary>Upload a new version</summary>
<form method="post" enctype="multipart/form-data" action="{% url 'files:replace' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk %}">
{% csrf_token %}
{{ replace_form.as_p }}
<button type="submit">Upload version {{ file.current_version|add:1 }}</button>
</form>
</details>
<details>
<summary>Remove from the feature</summary>
<form method="post" action="{% url 'files:remove' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk %}">
{% csrf_token %}
<p>
<label for="remove-note">Change note</label>
<input type="text" id="remove-note" name="note" maxlength="500" required>
</p>
<button type="submit">Remove {{ file.name }}</button>
</form>
</details>
</section>
{% endif %}
{% endblock %}