Files
JQC_features/templates/admin/topic_form.html
T

253 lines
10 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 src="{{ asset_url('js/image-upload.js') }}"></script>
<script src="{{ asset_url('js/image-resize.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');
// 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 csrf = document.querySelector('meta[name="csrf-token"]').content;
// Paste/drop image uploading. Quill's default uploader base64s the file
// into the body, which the sanitizer strips on save — this replaces it with
// a real upload to /admin/upload.
var uploads = window.JQCImageUpload
? window.JQCImageUpload.create({ url: '{{ url_for("admin.upload") }}', csrf: csrf })
: null;
var modules = { toolbar: toolbarEl };
if (uploads) {
modules.uploader = { mimetypes: uploads.mimetypes, handler: uploads.handler };
}
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);
}
}
// --- Image button: pick a file and upload it through the same path as a
// paste/drop, so the DB stays small (no base64) and the image is served as
// a static file.
if (uploads) {
quill.getModule('toolbar').addHandler('image', function () {
var input = document.createElement('input');
input.type = 'file';
input.accept = uploads.mimetypes.join(',');
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
var range = quill.getSelection(true);
uploads.insertFiles(quill, range ? range.index : quill.getLength(), [file]);
};
input.click();
});
// Pasted HTML can carry base64 images (Word, Google Docs); rehost those
// as they arrive, since the uploader module never sees them.
uploads.watch(quill);
}
// Table resize + cell alignment are handled by quill-table-better's own
// drag handles and floating cell menu — no extra toolbar wiring needed.
// --- Click an image to resize it (corner handles + S/M/L/Full presets).
// Guarded: if the script didn't load, the editor still works, images just
// stay at their natural size.
if (window.JQCImageResize) {
window.JQCImageResize.init(quill);
}
// 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 save the raw
// editor HTML. We deliberately avoid getSemanticHTML() here: it rewrites
// Quill's `ql-align-*` class as an inline text-align style, which breaks
// quill-table-better's cell-alignment readback (its getAlign() only reads
// the class). innerHTML keeps the class so alignment round-trips.
if (hasTables) {
try { quill.getModule('table-better').deleteTableTemporary(); } catch (e) {}
}
ta.value = quill.root.innerHTML;
});
})();
</script>
{% endblock %}