feat(emails): subtask 5 — compose preview (admin_compose template + HTMX endpoint)
- Add emails/admin_compose.html: branded wrapper for ad-hoc compose body - Update email_compose.html: two-column layout with HTMX live preview pane (hx-post, hx-trigger=input delay:500ms, hx-target=#preview-pane) - Add partials/email_preview_frame.html: sandboxed iframe partial - Add POST /admin/emails/compose/preview route (no CSRF — read-only render) - Update email_compose POST handler to use render_email_template() instead of importing _email_wrap from worker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ from ..core import (
|
||||
utcnow,
|
||||
utcnow_iso,
|
||||
)
|
||||
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1398,10 +1399,16 @@ async def email_compose():
|
||||
email_addresses=EMAIL_ADDRESSES,
|
||||
)
|
||||
|
||||
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||
if wrap:
|
||||
from ..worker import _email_wrap
|
||||
html = _email_wrap(html)
|
||||
html = render_email_template(
|
||||
"emails/admin_compose.html",
|
||||
lang="en",
|
||||
body_html=body_html,
|
||||
preheader="",
|
||||
)
|
||||
else:
|
||||
html = body_html
|
||||
|
||||
result = await send_email(
|
||||
to=to, subject=subject, html=html,
|
||||
@@ -1424,6 +1431,36 @@ async def email_compose():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/emails/compose/preview", methods=["POST"])
|
||||
@role_required("admin")
|
||||
async def compose_preview():
|
||||
"""HTMX endpoint: render live preview for compose textarea (no CSRF — read-only)."""
|
||||
form = await request.form
|
||||
body = form.get("body", "").strip()
|
||||
wrap = form.get("wrap", "") == "1"
|
||||
|
||||
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>" if body else ""
|
||||
|
||||
if wrap and body_html:
|
||||
try:
|
||||
rendered_html = render_email_template(
|
||||
"emails/admin_compose.html",
|
||||
lang="en",
|
||||
body_html=body_html,
|
||||
preheader="",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("compose_preview: template render failed")
|
||||
rendered_html = body_html
|
||||
else:
|
||||
rendered_html = body_html
|
||||
|
||||
return await render_template(
|
||||
"admin/partials/email_preview_frame.html",
|
||||
rendered_html=rendered_html,
|
||||
)
|
||||
|
||||
|
||||
# --- Audiences ---
|
||||
|
||||
@bp.route("/emails/audiences")
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
{% set admin_page = "compose" %}
|
||||
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.compose-layout { display: grid; grid-template-columns: 480px 1fr; gap: 1.5rem; align-items: start; }
|
||||
@media (max-width: 1100px) { .compose-layout { grid-template-columns: 1fr; } }
|
||||
.preview-panel { position: sticky; top: 1rem; }
|
||||
.preview-label { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
#preview-pane { min-height: 400px; background: #F1F5F9; border-radius: 8px; }
|
||||
#preview-pane.loading { opacity: 0.5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="mb-6">
|
||||
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
||||
<h1 class="text-2xl mt-1">Compose Email</h1>
|
||||
</header>
|
||||
|
||||
<div class="card" style="padding:1.5rem;max-width:640px">
|
||||
<form method="post" action="{{ url_for('admin.email_compose') }}">
|
||||
<div class="compose-layout">
|
||||
{# ── Left: form ────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<form id="compose-form" method="post" action="{{ url_for('admin.email_compose') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -33,12 +47,28 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
|
||||
<textarea name="body" rows="12" class="form-input" placeholder="Plain text (line breaks become <br>)" required>{{ data.get('body', '') }}</textarea>
|
||||
<textarea
|
||||
name="body" rows="14" class="form-input"
|
||||
placeholder="Plain text (line breaks become <br>)"
|
||||
required
|
||||
hx-post="{{ url_for('admin.compose_preview') }}"
|
||||
hx-trigger="input delay:500ms, change"
|
||||
hx-target="#preview-pane"
|
||||
hx-include="#compose-form [name='wrap']"
|
||||
hx-indicator="#preview-pane"
|
||||
>{{ data.get('body', '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="wrap" value="1" checked>
|
||||
<input
|
||||
type="checkbox" name="wrap" value="1"
|
||||
{% if data.get('wrap', True) %}checked{% endif %}
|
||||
hx-post="{{ url_for('admin.compose_preview') }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#preview-pane"
|
||||
hx-include="#compose-form textarea[name='body']"
|
||||
>
|
||||
Wrap in branded email template
|
||||
</label>
|
||||
</div>
|
||||
@@ -49,4 +79,14 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Right: live preview panel ─────────────────────── #}
|
||||
<div class="preview-panel">
|
||||
<div class="preview-label">Live preview</div>
|
||||
<div id="preview-pane">
|
||||
<p style="padding:2rem;font-size:0.875rem;color:#94A3B8;text-align:center;">Start typing to see a preview…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{# HTMX partial: sandboxed iframe showing a rendered email preview.
|
||||
Rendered by POST /admin/emails/compose/preview. #}
|
||||
<iframe
|
||||
srcdoc="{{ rendered_html | e }}"
|
||||
style="width:100%;height:600px;border:none;border-radius:8px;background:#F1F5F9;"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email preview"
|
||||
></iframe>
|
||||
5
web/src/padelnomics/templates/emails/admin_compose.html
Normal file
5
web/src/padelnomics/templates/emails/admin_compose.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends "emails/_base.html" %}
|
||||
|
||||
{% block body %}
|
||||
{{ body_html | safe }}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user