Jul 22 - Update - add Rich-text editor, draft/publish, drag-to-reorder

This commit is contained in:
2026-07-22 17:13:26 -04:00
parent a39fa091fa
commit 69b97d51fe
15 changed files with 1295 additions and 23 deletions
+3
View File
@@ -4,10 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin{% endblock %} · JQC</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
{% block head_extra %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% if session.get('admin') %}
@@ -38,5 +40,6 @@
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+62 -7
View File
@@ -4,7 +4,7 @@
<header class="page-head">
<div>
<h1>Content</h1>
<p class="muted">Edit the sections and topics shown on the public site.</p>
<p class="muted">Drag the handles to reorder. Toggle Publish to show or hide on the public site.</p>
</div>
<div class="page-head__actions">
<a class="btn btn--ghost" href="{{ url_for('admin.section_form') }}">+ Section</a>
@@ -16,11 +16,13 @@
<div class="empty">No sections yet. Start by adding one.</div>
{% endif %}
<div id="sections">
{% for section in sections %}
<section class="sec-card">
<section class="sec-card" data-section-id="{{ section.id }}">
<div class="sec-card__head">
<div class="sec-card__meta">
<span class="pill">§{{ '%02d' % section.num }}</span>
<button type="button" class="drag-handle sec-drag" title="Drag to reorder sections" aria-label="Reorder section">&#10247;</button>
<span class="pill">&sect;{{ '%02d' % section.num }}</span>
<h2>{{ section.title }}</h2>
{% if section.subtitle %}<span class="muted">{{ section.subtitle }}</span>{% endif %}
</div>
@@ -28,7 +30,7 @@
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.section_form', section_id=section.id) }}">Edit</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form') }}?section={{ section.id }}">+ Topic</a>
<form method="post" action="{{ url_for('admin.section_delete', section_id=section.id) }}"
onsubmit="return confirm('Delete section {{ section.title }} and ALL its topics?');">
onsubmit="return confirm('Delete section &ldquo;{{ section.title }}&rdquo; and ALL its topics?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form>
@@ -36,19 +38,25 @@
</div>
{% if section.topics %}
<ul class="topic-rows">
<ul class="topic-rows" data-section-id="{{ section.id }}">
{% for topic in section.topics %}
<li class="topic-row">
<li class="topic-row {{ 'is-draft' if not topic.is_published }}" data-topic-id="{{ topic.id }}">
<div class="topic-row__main">
<button type="button" class="drag-handle row-drag" title="Drag to reorder" aria-label="Reorder topic">&#10247;</button>
<span class="topic-row__order">{{ topic.sort_order }}</span>
<a class="topic-row__title" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">{{ topic.title | safe }}</a>
{% if not topic.is_published %}<span class="chip chip--draft">draft</span>{% endif %}
{% if topic.media_type and topic.media_type != 'none' %}<span class="chip">{{ topic.media_type }}</span>{% endif %}
{% if topic.link_url %}<span class="chip chip--link">link</span>{% endif %}
</div>
<div class="topic-row__actions">
<form method="post" action="{{ url_for('admin.topic_toggle', topic_id=topic.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--ghost btn--sm" type="submit">{{ 'Unpublish' if topic.is_published else 'Publish' }}</button>
</form>
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">Edit</a>
<form method="post" action="{{ url_for('admin.topic_delete', topic_id=topic.id) }}"
onsubmit="return confirm('Delete topic {{ topic.title }}?');">
onsubmit="return confirm('Delete topic &ldquo;{{ topic.title }}&rdquo;?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form>
@@ -61,4 +69,51 @@
{% endif %}
</section>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/Sortable.min.js') }}"></script>
<script>
(function () {
var token = document.querySelector('meta[name="csrf-token"]').content;
function post(payload, rows) {
fetch('{{ url_for('admin.reorder') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': token },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) { console.error('reorder failed', r.status); return; }
if (rows) rows.forEach(function (el, i) {
var n = el.querySelector('.topic-row__order');
if (n) n.textContent = (i + 1) * 10;
});
}).catch(function (e) { console.error(e); });
}
var secWrap = document.getElementById('sections');
if (secWrap && window.Sortable) {
Sortable.create(secWrap, {
handle: '.sec-drag', animation: 150, draggable: '.sec-card',
onEnd: function () {
var order = Array.from(secWrap.querySelectorAll(':scope > .sec-card'))
.map(function (el) { return parseInt(el.dataset.sectionId, 10); });
post({ type: 'section', order: order });
}
});
}
document.querySelectorAll('.topic-rows').forEach(function (ul) {
Sortable.create(ul, {
handle: '.row-drag', animation: 150, draggable: '.topic-row',
onEnd: function () {
var lis = Array.from(ul.querySelectorAll(':scope > .topic-row'));
var order = lis.map(function (el) { return parseInt(el.dataset.topicId, 10); });
post({ type: 'topic', section_id: parseInt(ul.dataset.sectionId, 10), order: order }, lis);
}
});
});
})();
</script>
{% endblock %}
+58 -3
View File
@@ -1,6 +1,10 @@
{% extends "admin/base.html" %}
{% block title %}{{ 'Edit topic' if topic else 'New topic' }}{% endblock %}
{% block head_extra %}
<link href="{{ url_for('static', filename='vendor/quill.snow.css') }}" rel="stylesheet">
{% endblock %}
{% macro val(field, default='') -%}
{%- if form is not none -%}{{ form.get(field, default) }}
{%- elif topic is not none -%}{{ topic[field] if topic[field] is not none else default }}
@@ -50,9 +54,39 @@
<input type="text" name="slug" value="{{ val('slug') }}" placeholder="auto">
</label>
<label class="field">
<span>Body <small class="muted">(HTML — use &lt;p&gt;, &lt;strong&gt;, &lt;ul&gt;&lt;li&gt;)</small></span>
<textarea name="body_html" rows="7">{{ val('body_html') }}</textarea>
<div class="field">
<span>Body</span>
<div id="editor-toolbar" class="ql-tools">
<span class="ql-formats">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
</span>
<span class="ql-formats">
<button class="ql-header" value="2"></button>
<button class="ql-header" value="3"></button>
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered"></button>
<button class="ql-list" value="bullet"></button>
<button class="ql-blockquote"></button>
</span>
<span class="ql-formats">
<button class="ql-link"></button>
<button class="ql-clean"></button>
</span>
</div>
<div id="editor"></div>
<!-- Real field. Quill enhances it; if Quill fails to load, this stays a
usable raw-HTML textarea so a save never wipes the body. -->
<textarea name="body_html" id="body_src" rows="7">{{ val('body_html') }}</textarea>
</div>
{% set pub = (form.get('is_published') == '1') if form is not none else (topic.is_published if topic else True) %}
<label class="field field--check">
<input type="checkbox" name="is_published" value="1" {{ 'checked' if pub }}>
<span>Published <small class="muted">(uncheck to keep as a draft — hidden from the public site)</small></span>
</label>
<fieldset class="fieldset">
@@ -99,3 +133,24 @@
</div>
</form>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/quill.min.js') }}"></script>
<script>
(function () {
if (typeof Quill === 'undefined') return; // textarea stays usable
var ta = document.getElementById('body_src');
var toolbar = document.getElementById('editor-toolbar');
var editorEl = document.getElementById('editor');
var quill = new Quill(editorEl, { theme: 'snow', modules: { toolbar: toolbar } });
if (ta.value.trim()) quill.clipboard.dangerouslyPasteHTML(ta.value);
// Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on');
ta.form.addEventListener('submit', function () {
ta.value = quill.getText().trim().length ? quill.root.innerHTML : '';
});
})();
</script>
{% endblock %}