Files
JQC_features/templates/admin/topic_form.html
T

239 lines
9.6 KiB
HTML

{% 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">
<link href="{{ url_for('static', filename='vendor/quill-table-better.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 }}
{%- else -%}{{ default }}{%- endif -%}
{%- endmacro %}
{% block content %}
{% set current_section = (form.get('section_id') if form else (topic.section_id if topic else request.args.get('section'))) %}
{% set current_media = (form.get('media_type') if form else (topic.media_type if topic else 'none')) %}
<header class="page-head">
<div>
<h1>{{ 'Edit topic' if topic else 'New topic' }}</h1>
<p class="muted">{{ topic.title if topic else 'Add a new topic to a section.' }}</p>
</div>
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">← Back</a>
</header>
<form method="post" class="form-card stack"
action="{{ url_for('admin.topic_form', topic_id=topic.id) if topic else url_for('admin.topic_form') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid-2">
<label class="field">
<span>Section *</span>
<select name="section_id" required>
{% for s in sections %}
<option value="{{ s.id }}" {{ 'selected' if current_section and s.id|string == current_section|string }}>
§{{ '%02d' % s.num }} — {{ s.title }}
</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Sort order</span>
<input type="number" name="sort_order" value="{{ val('sort_order', '10') }}">
</label>
</div>
<label class="field">
<span>Title *</span>
<input type="text" name="title" value="{{ val('title') }}" required>
</label>
<label class="field">
<span>Slug <small class="muted">(optional — auto-generated from the title, must be unique)</small></span>
<input type="text" name="slug" value="{{ val('slug') }}" placeholder="auto">
</label>
<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-image" title="Insert image"></button>
<button class="ql-clean"></button>
</span>
<span class="ql-formats">
<button class="ql-table-better" title="Insert table"></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">
<legend>Media</legend>
<div class="grid-2">
<label class="field">
<span>Type</span>
<select name="media_type">
{% for mt in media_types %}
<option value="{{ mt }}" {{ 'selected' if mt == current_media }}>{{ mt }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Caption</span>
<input type="text" name="media_caption" value="{{ val('media_caption') }}">
</label>
</div>
<label class="field">
<span>Media URL</span>
<input type="text" name="media_url" value="{{ val('media_url') }}"
placeholder="/static/img/photo.jpg · /static/vid/demo.mp4 · https://www.youtube.com/embed/ID">
</label>
<p class="hint muted">image → &lt;img&gt; · video → &lt;video&gt; · embed → &lt;iframe&gt; (use a YouTube <code>/embed/</code> URL)</p>
</fieldset>
<fieldset class="fieldset">
<legend>Link button (optional)</legend>
<div class="grid-2">
<label class="field">
<span>Link URL</span>
<input type="text" name="link_url" value="{{ val('link_url') }}" placeholder="https://…">
</label>
<label class="field">
<span>Link label</span>
<input type="text" name="link_label" value="{{ val('link_label') }}" placeholder="Learn more">
</label>
</div>
</fieldset>
<div class="form-actions">
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save topic</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/quill.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/quill-table-better.js') }}"></script>
<script>
(function () {
if (typeof Quill === 'undefined') return; // textarea stays usable
var ta = document.getElementById('body_src');
var toolbarEl = document.getElementById('editor-toolbar');
var editorEl = document.getElementById('editor');
// Persist alignment as inline `text-align` (style) rather than a Quill CSS
// class, so cell alignment survives the HTML sanitizer and renders on the
// public page with no extra CSS. Must run before the editor is created.
Quill.register(Quill.import('attributors/style/align'), true);
// quill-table-better: resizable columns/rows + a cell menu for alignment,
// borders and background. Registered only if the vendor script loaded.
var hasTables = typeof QuillTableBetter !== 'undefined';
if (hasTables) {
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
}
var modules = { toolbar: toolbarEl };
if (hasTables) {
modules.table = false; // disable Quill's basic table
modules['table-better'] = {
language: 'en_US',
menus: ['column', 'row', 'merge', 'table', 'cell', 'wrap', 'delete'],
toolbarTable: true
};
modules.keyboard = { bindings: QuillTableBetter.keyboardBindings };
}
var quill = new Quill(editorEl, { theme: 'snow', modules: modules });
// Load existing body. quill-table-better warns that setContents /
// dangerouslyPasteHTML make tables render blank, so convert the saved HTML
// to a delta and apply it with updateContents instead.
if (ta.value.trim()) {
if (hasTables) {
var delta = quill.clipboard.convert({ html: ta.value });
quill.updateContents(delta, Quill.sources.USER);
quill.setSelection(delta.length(), Quill.sources.SILENT);
} else {
quill.clipboard.dangerouslyPasteHTML(ta.value);
}
}
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// --- Image button: upload the file, then embed the returned URL. This
// keeps the DB small (no base64) and the image is served as a static file.
quill.getModule('toolbar').addHandler('image', function () {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
var fd = new FormData();
fd.append('file', file);
fetch('{{ url_for("admin.upload") }}', {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, body: j }; });
}).then(function (res) {
if (!res.ok) { alert(res.body.error || 'Upload failed.'); return; }
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', res.body.url, 'user');
quill.setSelection(range.index + 1, 'silent');
}).catch(function () { alert('Upload failed.'); });
};
input.click();
});
// Table resize + cell alignment are handled by quill-table-better's own
// drag handles and floating cell menu — no extra toolbar wiring needed.
// Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on');
ta.form.addEventListener('submit', function () {
// getText() is empty for an image/table-only body, so also check for
// embeds before treating the editor as blank (which would wipe the save).
var hasText = quill.getText().trim().length > 0;
var hasEmbed = quill.root.querySelector('img, table');
if (!hasText && !hasEmbed) { ta.value = ''; return; }
// Drop quill-table-better's transient selection nodes, then serialize
// clean semantic HTML (not raw innerHTML, which can leak UI markup).
if (hasTables) {
try { quill.getModule('table-better').deleteTableTemporary(); } catch (e) {}
}
ta.value = quill.getSemanticHTML();
});
})();
</script>
{% endblock %}