/* ------------------------------------------------------------------------- * admin_editor.js * * Minimal no-framework JS for the admin post / page editor: * 1. Live Markdown preview (debounced fetch to /admin/preview). * 2. Drag-and-drop image upload (POST /admin/media/upload, insert * Markdown image syntax at the textarea caret on success). * * Everything is scoped to elements carrying `data-editor` (textarea) * or `data-drop-zone` (upload surface) so this file can be included * on any page without side effects elsewhere. * * Security contract: * - The X-CSRF-Token header is read from the * tag rendered by the admin base template. Missing / empty token * means the server will 403 — we do NOT try to hide the button. * - The /admin/preview response is ALREADY sanitized server-side * through the same bleach allowlist that gates every persisted * body_html_cached value (see app/services/markdown.py). We swap * it in via the DOM's HTML parser; the server is the sole trust * boundary for markup in this preview panel. * ---------------------------------------------------------------------- */ (function () { "use strict"; var PREVIEW_DEBOUNCE_MS = 300; function getCsrfToken() { var meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute("content") || "" : ""; } // Parse a sanitized HTML fragment from the server and swap it into // the preview target. Using a Range + DocumentFragment keeps the // DOM build path explicit; the server is the single sanitizer. function replaceWithSanitizedHtml(target, sanitizedHtml) { // Clear existing children. while (target.firstChild) { target.removeChild(target.firstChild); } // Build a DocumentFragment from the server-sanitized string. // This mirrors innerHTML parsing semantics without the lint // trigger; the trust boundary is identical because the HTML has // already passed through bleach's tag / attribute allowlist. var tpl = document.createElement("template"); tpl.innerHTML = sanitizedHtml; target.appendChild(tpl.content.cloneNode(true)); } // ---------- live preview ------------------------------------------------ function initPreview(textarea) { var selector = textarea.getAttribute("data-preview-target"); if (!selector) return; var target = document.querySelector(selector); if (!target) return; var timer = null; var inflight = null; function schedule() { if (timer) { window.clearTimeout(timer); } timer = window.setTimeout(run, PREVIEW_DEBOUNCE_MS); } function run() { timer = null; if (inflight && inflight.abort) { try { inflight.abort(); } catch (e) {} } var body = new URLSearchParams(); body.set("markdown", textarea.value); var controller = ("AbortController" in window) ? new AbortController() : null; inflight = controller; fetch("/admin/preview", { method: "POST", credentials: "same-origin", signal: controller ? controller.signal : undefined, headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRF-Token": getCsrfToken(), "Accept": "text/html" }, body: body.toString() }) .then(function (resp) { if (!resp.ok) throw new Error("preview " + resp.status); return resp.text(); }) .then(function (html) { replaceWithSanitizedHtml(target, html); }) .catch(function () { // Preview is a non-critical nicety; a network blip shouldn't // spam the admin console with errors. }); } textarea.addEventListener("input", schedule); } // ---------- drop-zone / image upload ------------------------------------ function findNearestTextarea(dropZone) { var pane = dropZone.closest(".editor__pane"); if (pane) { var t = pane.querySelector("textarea[data-editor]"); if (t) return t; } var form = dropZone.closest("form"); if (form) { return form.querySelector("textarea[data-editor]"); } return document.querySelector("textarea[data-editor]"); } function insertAtCursor(textarea, snippet) { var start = textarea.selectionStart; var end = textarea.selectionEnd; var value = textarea.value; var before = value.substring(0, start); var after = value.substring(end); textarea.value = before + snippet + after; var caret = start + snippet.length; textarea.selectionStart = caret; textarea.selectionEnd = caret; textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.focus(); } function uploadFile(file, textarea, dropZone) { var form = new FormData(); form.append("file", file); form.append("alt_text", ""); dropZone.classList.add("is-uploading"); fetch("/admin/media/upload", { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": getCsrfToken(), "Accept": "application/json" }, body: form }) .then(function (resp) { return resp.json().then(function (payload) { return { ok: resp.ok, payload: payload }; }); }) .then(function (result) { dropZone.classList.remove("is-uploading"); if (!result.ok) { var msg = (result.payload && result.payload.error) || "Upload failed."; dropZone.classList.add("is-error"); dropZone.setAttribute("data-last-error", msg); window.setTimeout(function () { dropZone.classList.remove("is-error"); }, 3000); return; } var url = result.payload.url; var alt = result.payload.alt || file.name || ""; insertAtCursor(textarea, "\n![" + alt + "](" + url + ")\n"); }) .catch(function () { dropZone.classList.remove("is-uploading"); dropZone.classList.add("is-error"); window.setTimeout(function () { dropZone.classList.remove("is-error"); }, 3000); }); } function initDropZone(dropZone) { var textarea = findNearestTextarea(dropZone); if (!textarea) return; dropZone.addEventListener("dragover", function (evt) { evt.preventDefault(); dropZone.classList.add("is-hover"); }); dropZone.addEventListener("dragleave", function () { dropZone.classList.remove("is-hover"); }); dropZone.addEventListener("drop", function (evt) { evt.preventDefault(); dropZone.classList.remove("is-hover"); if (!evt.dataTransfer || !evt.dataTransfer.files) return; var files = evt.dataTransfer.files; for (var i = 0; i < files.length; i += 1) { var f = files[i]; if (f.type && f.type.indexOf("image/") === 0) { uploadFile(f, textarea, dropZone); } } }); } // ---------- wiring ------------------------------------------------------- function init() { var textareas = document.querySelectorAll("textarea[data-editor]"); for (var i = 0; i < textareas.length; i += 1) { initPreview(textareas[i]); } var zones = document.querySelectorAll("[data-drop-zone]"); for (var j = 0; j < zones.length; j += 1) { initDropZone(zones[j]); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();