feat: phase 4 admin CMS — dashboard, editor, media, CSRF
Head Hen CMS end-to-end: dashboard lists all posts (drafts + published), Markdown editor with live preview + drag-drop image upload, Pillow media pipeline re-encoding every upload to JPEG, post CRUD + publish toggle + hard delete, About page edit, and double-submit CSRF cookie enforced on every admin mutating endpoint (Phase 3's TODO markers resolved). Slug auto-generated on create and server-locked once a post has been published. Unpublish preserves `published_at` so re-publish keeps original date ordering. Every admin write invalidates the read-side Post/Page TTL caches and records an `auth_events` audit row. CSRF middleware is narrow by design — issues/refreshes the `cb_csrf` cookie only on `GET /admin*`, and mutating endpoints opt in via `require_csrf_form` or `require_csrf_header` Depends. Public routes, healthz, and pre-auth login stay untouched. 64 new tests cover slugs, CSRF, media, admin posts/pages services, and end-to-end CMS routes. Tests never mock the DB — real temp SQLite files per the CLAUDE.md mandate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
104
app/templates/admin/post_form.html
Normal file
104
app/templates/admin/post_form.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{#
|
||||
Shared create + edit form for posts.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User
|
||||
- post : app.models.entities.Post | None (None on create)
|
||||
- form : dict {title, body_md, status}
|
||||
- errors : dict {field_name: message}
|
||||
- csrf_token : str
|
||||
|
||||
Status dropdown policy:
|
||||
- On create: draft is default, admin may pick published.
|
||||
- On edit: status is read-only here — use the toggle-publish
|
||||
button on the dashboard. Keeps the write path explicit.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if post %}Edit post{% else %}New post{% endif %} — Admin
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">
|
||||
{% if post %}Edit post{% else %}New post{% endif %}
|
||||
</h1>
|
||||
{% if post %}
|
||||
<p>Slug: <code>{{ post.slug }}</code>
|
||||
{% if post.status.value == "published" %}
|
||||
· slug is locked because this post is published.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">
|
||||
{% for field, msg in errors.items() %}
|
||||
<span>{{ msg }}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="editor"
|
||||
method="post"
|
||||
action="{% if post %}/admin/posts/{{ post.id }}{% else %}/admin/posts{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="editor__field">
|
||||
<label for="post-title">Title</label>
|
||||
<input type="text"
|
||||
id="post-title"
|
||||
name="title"
|
||||
value="{{ form.title|e }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
{% if not post %}
|
||||
<div class="editor__field">
|
||||
<label for="post-status">Status</label>
|
||||
<select id="post-status" name="status">
|
||||
<option value="draft" {% if form.status == "draft" %}selected{% endif %}>Draft</option>
|
||||
<option value="published" {% if form.status == "published" %}selected{% endif %}>Published</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="editor__split">
|
||||
<div class="editor__pane">
|
||||
<label for="post-body">Body (Markdown)</label>
|
||||
<textarea id="post-body"
|
||||
name="body_md"
|
||||
data-editor
|
||||
data-preview-target="#post-preview"
|
||||
rows="20">{{ form.body_md|e }}</textarea>
|
||||
|
||||
<div class="drop-zone" data-drop-zone
|
||||
aria-label="Drag an image here to upload and insert it">
|
||||
Drop an image here to upload & insert. Accepted: JPG, PNG, WebP up to 8 MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__pane">
|
||||
<span class="editor__label">Preview</span>
|
||||
<div id="post-preview" class="editor__preview" aria-live="polite">
|
||||
{% if post %}{{ post.body_html_cached|safe }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__actions">
|
||||
<button type="submit" class="btn btn--primary">
|
||||
{% if post %}Save changes{% else %}Create post{% endif %}
|
||||
</button>
|
||||
<a class="btn btn--secondary" href="/admin">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user