225 lines
9.3 KiB
HTML
225 lines
9.3 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">
|
||
{% 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 ql-table-tools">
|
||
<button type="button" class="ql-table-op" data-op="insert" title="Insert table">▦</button>
|
||
<button type="button" class="ql-table-op" data-op="row" title="Add row below">+Row</button>
|
||
<button type="button" class="ql-table-op" data-op="col" title="Add column right">+Col</button>
|
||
<button type="button" class="ql-table-op" data-op="delrow" title="Delete row">−Row</button>
|
||
<button type="button" class="ql-table-op" data-op="delcol" title="Delete column">−Col</button>
|
||
<button type="button" class="ql-table-op" data-op="deltable" title="Delete table">✕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>
|
||
(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');
|
||
var quill = new Quill(editorEl, {
|
||
theme: 'snow',
|
||
// `table: true` enables Quill 2's built-in table module.
|
||
modules: { toolbar: toolbarEl, table: true }
|
||
});
|
||
if (ta.value.trim()) quill.clipboard.dangerouslyPasteHTML(ta.value);
|
||
|
||
var csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||
var tableModule = quill.getModule('table');
|
||
|
||
// --- 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 controls. Row/column ops act on the cell holding the cursor.
|
||
toolbarEl.querySelectorAll('.ql-table-op').forEach(function (btn) {
|
||
// Preventing the mousedown default keeps the editor's selection so the
|
||
// table module knows which cell the cursor is in.
|
||
btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
||
btn.addEventListener('click', function () {
|
||
var op = btn.getAttribute('data-op');
|
||
try {
|
||
if (op === 'insert') {
|
||
if (!quill.getSelection()) quill.setSelection(quill.getLength() - 1, 0);
|
||
tableModule.insertTable(3, 3);
|
||
}
|
||
else if (op === 'row') tableModule.insertRowBelow();
|
||
else if (op === 'col') tableModule.insertColumnRight();
|
||
else if (op === 'delrow') tableModule.deleteRow();
|
||
else if (op === 'delcol') tableModule.deleteColumn();
|
||
else if (op === 'deltable') tableModule.deleteTable();
|
||
} catch (err) {
|
||
alert('Place the cursor inside a table cell first.');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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');
|
||
ta.value = (hasText || hasEmbed) ? quill.root.innerHTML : '';
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|