merge(articles): iframe preview + collapsible meta + word count
This commit is contained in:
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
|
||||
|
||||
### Changed
|
||||
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
|
||||
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
|
||||
|
||||
@@ -2458,11 +2458,11 @@ async def article_new():
|
||||
|
||||
if not title or not body:
|
||||
await flash("Title and body are required.", "error")
|
||||
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
||||
return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
|
||||
|
||||
if is_reserved_path(url_path):
|
||||
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
||||
return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
|
||||
|
||||
# Render markdown → HTML with scenario + product cards baked in
|
||||
body_html = mistune.html(body)
|
||||
@@ -2495,7 +2495,7 @@ async def article_new():
|
||||
await flash(f"Article '{title}' created.", "success")
|
||||
return redirect(url_for("admin.articles"))
|
||||
|
||||
return await render_template("admin/article_form.html", data={}, editing=False, body_html="")
|
||||
return await render_template("admin/article_form.html", data={}, editing=False, preview_doc="")
|
||||
|
||||
|
||||
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
|
||||
@@ -2531,7 +2531,7 @@ async def article_edit(article_id: int):
|
||||
if is_reserved_path(url_path):
|
||||
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||
return await render_template(
|
||||
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id,
|
||||
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
|
||||
)
|
||||
|
||||
# Re-render if body provided
|
||||
@@ -2576,6 +2576,13 @@ async def article_edit(article_id: int):
|
||||
body = raw[m.end():].lstrip("\n") if m else raw
|
||||
|
||||
body_html = mistune.html(body) if body else ""
|
||||
css_url = url_for("static", filename="css/output.css")
|
||||
preview_doc = (
|
||||
f"<!doctype html><html><head>"
|
||||
f"<link rel='stylesheet' href='{css_url}'>"
|
||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||
) if body_html else ""
|
||||
|
||||
data = {**dict(article), "body": body}
|
||||
return await render_template(
|
||||
@@ -2583,7 +2590,7 @@ async def article_edit(article_id: int):
|
||||
data=data,
|
||||
editing=True,
|
||||
article_id=article_id,
|
||||
body_html=body_html,
|
||||
preview_doc=preview_doc,
|
||||
)
|
||||
|
||||
|
||||
@@ -2592,13 +2599,19 @@ async def article_edit(article_id: int):
|
||||
@csrf_protect
|
||||
async def article_preview():
|
||||
"""Render markdown body to HTML for the live editor preview panel."""
|
||||
from markupsafe import Markup
|
||||
form = await request.form
|
||||
body = form.get("body", "")
|
||||
m = _FRONTMATTER_RE.match(body)
|
||||
body = body[m.end():].lstrip("\n") if m else body
|
||||
body_html = Markup(mistune.html(body)) if body else ""
|
||||
return await render_template("admin/partials/article_preview.html", body_html=body_html)
|
||||
body_html = mistune.html(body) if body else ""
|
||||
css_url = url_for("static", filename="css/output.css")
|
||||
preview_doc = (
|
||||
f"<!doctype html><html><head>"
|
||||
f"<link rel='stylesheet' href='{css_url}'>"
|
||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||
) if body_html else ""
|
||||
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
|
||||
|
||||
|
||||
@bp.route("/articles/<int:article_id>/delete", methods=["POST"])
|
||||
|
||||
@@ -195,84 +195,50 @@
|
||||
.ae-editor::placeholder { color: #CBD5E1; }
|
||||
.ae-editor:focus { outline: none; }
|
||||
|
||||
/* Preview pane */
|
||||
.ae-preview {
|
||||
/* Preview pane — iframe fills the content area */
|
||||
#ae-preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Preview typography — maps rendered markdown */
|
||||
.ae-preview .preview-body { max-width: 42rem; }
|
||||
.ae-preview .preview-body h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.625rem; font-weight: 700;
|
||||
color: #0F172A; margin: 0 0 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.ae-preview .preview-body h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem; font-weight: 600;
|
||||
color: #0F172A; margin: 1.75rem 0 0.625rem;
|
||||
}
|
||||
.ae-preview .preview-body h3 {
|
||||
font-size: 1.0625rem; font-weight: 600;
|
||||
color: #0F172A; margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
.ae-preview .preview-body h4 {
|
||||
font-size: 0.9375rem; font-weight: 600;
|
||||
color: #334155; margin: 1rem 0 0.375rem;
|
||||
}
|
||||
.ae-preview .preview-body p { margin: 0 0 0.875rem; color: #1E293B; line-height: 1.75; }
|
||||
.ae-preview .preview-body ul,
|
||||
.ae-preview .preview-body ol { margin: 0 0 0.875rem 1.375rem; color: #1E293B; }
|
||||
.ae-preview .preview-body li { margin: 0.3rem 0; line-height: 1.65; }
|
||||
.ae-preview .preview-body code {
|
||||
font-family: var(--font-mono); font-size: 0.8125rem;
|
||||
background: #F1F5F9; color: #1D4ED8;
|
||||
padding: 0.1rem 0.35rem; border-radius: 3px;
|
||||
}
|
||||
.ae-preview .preview-body pre {
|
||||
background: #0F172A; color: #CBD5E1;
|
||||
padding: 1rem 1.125rem; border-radius: 6px;
|
||||
overflow-x: auto; margin: 0 0 0.875rem;
|
||||
font-size: 0.8125rem; line-height: 1.65;
|
||||
}
|
||||
.ae-preview .preview-body pre code {
|
||||
background: none; color: inherit; padding: 0;
|
||||
}
|
||||
.ae-preview .preview-body blockquote {
|
||||
border-left: 3px solid #1D4ED8;
|
||||
padding-left: 1rem; margin: 0 0 0.875rem;
|
||||
color: #475569;
|
||||
}
|
||||
.ae-preview .preview-body a { color: #1D4ED8; }
|
||||
.ae-preview .preview-body hr {
|
||||
border: none; border-top: 1px solid #E2E8F0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.ae-preview .preview-body strong { font-weight: 600; color: #0F172A; }
|
||||
.ae-preview .preview-body table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: 0.875rem; margin: 0 0 0.875rem;
|
||||
}
|
||||
.ae-preview .preview-body th {
|
||||
background: #F8FAFC; font-weight: 600;
|
||||
padding: 0.5rem 0.75rem; text-align: left;
|
||||
border: 1px solid #E2E8F0; color: #0F172A;
|
||||
}
|
||||
.ae-preview .preview-body td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #E2E8F0; color: #1E293B;
|
||||
}
|
||||
.ae-preview .preview-body tr:nth-child(even) td { background: #F8FAFC; }
|
||||
|
||||
.preview-placeholder {
|
||||
font-size: 0.875rem;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
margin: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* Collapsible metadata */
|
||||
.ae-meta--collapsed { display: none; }
|
||||
|
||||
.ae-toolbar__toggle {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748B;
|
||||
background: none;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
|
||||
|
||||
/* Word count footer */
|
||||
.ae-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.875rem;
|
||||
background: #F8FAFC;
|
||||
border-top: 1px solid #E2E8F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-wordcount {
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--font-mono);
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
/* HTMX loading indicator — htmx toggles .htmx-request on the element */
|
||||
@@ -308,6 +274,8 @@
|
||||
{{ data.get('status', 'draft') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<button type="button" class="ae-toolbar__toggle"
|
||||
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
|
||||
<button form="ae-form" type="submit" class="btn btn-sm">
|
||||
{% if editing %}Save Changes{% else %}Create Article{% endif %}
|
||||
</button>
|
||||
@@ -400,6 +368,9 @@
|
||||
hx-include="[name=csrf_token]"
|
||||
hx-indicator="#ae-loading"
|
||||
>{{ data.get('body', '') }}</textarea>
|
||||
<div class="ae-footer">
|
||||
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right — Rendered preview -->
|
||||
@@ -408,19 +379,35 @@
|
||||
<span class="ae-pane__label">Preview</span>
|
||||
<span id="ae-loading" class="ae-loading">Rendering…</span>
|
||||
</div>
|
||||
<div class="ae-preview">
|
||||
<div id="ae-preview-content">
|
||||
{% if body_html %}
|
||||
<div class="preview-body">{{ body_html }}</div>
|
||||
<div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
|
||||
{% if preview_doc %}
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
<p class="preview-placeholder">Start writing to see a preview.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var textarea = document.getElementById('body');
|
||||
var counter = document.getElementById('ae-wordcount');
|
||||
function updateCount() {
|
||||
var text = textarea.value.trim();
|
||||
var count = text ? text.split(/\s+/).length : 0;
|
||||
counter.textContent = count + (count === 1 ? ' word' : ' words');
|
||||
}
|
||||
textarea.addEventListener('input', updateCount);
|
||||
updateCount();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{% if body_html %}
|
||||
<div class="preview-body">{{ body_html }}</div>
|
||||
{# HTMX partial: sandboxed iframe showing a rendered article preview.
|
||||
Rendered by POST /admin/articles/preview. #}
|
||||
{% if preview_doc %}
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
<p class="preview-placeholder">Start writing to see a preview.</p>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user