253 lines
10 KiB
HTML
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 → <img> · video → <video> · embed → <iframe> (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 %}
|