From 2380afabf89d86df84afa8ee4ac8a640b826e8db Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Tue, 19 May 2026 11:59:51 +0545 Subject: [PATCH] refactor(web): decompose compose.js init() into 6 inner helpers Drops dead `data-mode` attribute on .compose-form. --- CHANGELOG.md | 16 ++ web/static/js/compose.js | 520 +++++++++++++++++++------------------ web/templates/compose.html | 2 +- 3 files changed, 282 insertions(+), 256 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f96d58f..5027978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.18.0] - 2026-05-19 + +Compose readability pass. No behavior change. + +### Changed + +- `compose.js` `init()` decomposed into 6 inner-function helpers (preview, + autosave, submit, image upload, banner upload, drag-drop). Shared + utilities hoisted to outer scope; section-local helpers scoped to their + section. AbortController signal closure preserved. + +### Removed + +- Dead `data-mode` attribute on `.compose-form` — set by template, read + by no JS or CSS. + ## [3.17.0] - 2026-05-18 Theme: **dark-mode contrast verification + SW cache auto-bump.** A WCAG diff --git a/web/static/js/compose.js b/web/static/js/compose.js index d3fe907..9028ebb 100644 --- a/web/static/js/compose.js +++ b/web/static/js/compose.js @@ -26,6 +26,7 @@ export function init() { ac = new AbortController(); const { signal } = ac; + // --- Element references --- const textarea = document.getElementById('content'); const previewBtn = document.querySelector('.compose-preview-btn'); const previewPanel = document.querySelector('.compose-preview-panel'); @@ -37,9 +38,33 @@ export function init() { if (!textarea || !previewBtn || !previewPanel || !previewContent) return; + const titleInput = document.getElementById('title'); + const descInput = document.getElementById('description'); + const linkInput = document.getElementById('link_url'); + const tagsInput = document.getElementById('tags'); + const catsInput = document.getElementById('categories'); + const draftNotice = document.getElementById('compose-draft-notice'); + const draftDiscard = document.getElementById('compose-draft-discard'); + const saveWarning = document.getElementById('compose-save-warning'); + + const uploadBtn = document.querySelector('.compose-upload-btn'); + const fileInput = document.querySelector('.compose-file-input'); + const uploadStatus = document.querySelector('.upload-status'); + + const bannerInput = document.querySelector('[data-banner-input]'); + const bannerPreview = document.querySelector('[data-banner-preview]'); + const bannerUploadBtn = document.querySelector('[data-banner-upload-btn]'); + const bannerRemoveBtn = document.querySelector('[data-banner-remove-btn]'); + const bannerFileInput = document.querySelector('[data-banner-file-input]'); + + const maxFileSize = parseInt(form?.dataset.maxUpload || '10485760', 10); + + // --- Shared mutable state --- let previewVisible = false; let debounceTimer = null; + // --- Shared utilities --- + /** * Safely set preview HTML from server-rendered markdown. * Uses DOMParser instead of innerHTML. @@ -90,86 +115,160 @@ export function init() { debounceTimer = setTimeout(fetchPreview, 500); } - previewBtn.addEventListener('click', () => { - previewVisible = !previewVisible; - form.classList.toggle('preview-visible', previewVisible); - previewBtn.textContent = previewVisible ? 'Edit' : 'Preview'; - previewBtn.setAttribute('aria-pressed', previewVisible); - - if (previewVisible) { - fetchPreview(); - textarea.addEventListener('input', debouncedPreview); - } else { - textarea.removeEventListener('input', debouncedPreview); - clearTimeout(debounceTimer); - } - }, { signal }); - - // ========================================================================= - // Auto-save (new compose only, not editing) - // ========================================================================= + function getSlug() { + return form?.dataset.slug || ''; + } - const titleInput = document.getElementById('title'); - const descInput = document.getElementById('description'); - const linkInput = document.getElementById('link_url'); - const tagsInput = document.getElementById('tags'); - const catsInput = document.getElementById('categories'); - const draftNotice = document.getElementById('compose-draft-notice'); - const draftDiscard = document.getElementById('compose-draft-discard'); - const saveWarning = document.getElementById('compose-save-warning'); + function formatFileSize(bytes) { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + ' MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB'; + return bytes + ' bytes'; + } - function getFormFields() { - return { - content: textarea?.value || '', - title: titleInput?.value || '', - description: descInput?.value || '', - link_url: linkInput?.value || '', - tags: tagsInput?.value || '', - categories: catsInput?.value || '', - }; + function setUploadStatus(msg, isError) { + if (!uploadStatus) return; + uploadStatus.textContent = msg; + uploadStatus.className = 'upload-status' + (isError ? ' upload-error' : msg ? ' upload-success' : ''); } - function hasContent(fields) { - return Object.values(fields).some((v) => v.trim() !== ''); + function insertAtCursor(text) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const before = textarea.value.substring(0, start); + const after = textarea.value.substring(end); + textarea.value = before + text + after; + textarea.selectionStart = textarea.selectionEnd = start + text.length; + textarea.focus(); + if (previewVisible) debouncedPreview(); } - function saveDraft() { - const fields = getFormFields(); - if (!hasContent(fields)) { - clearDraft(); + function uploadFile(file) { + if (!file) return; + + const slug = getSlug(); + if (!slug) { + setUploadStatus('Save as draft first to upload files', false); return; } - try { - localStorage.setItem(FULL_DRAFT_KEY, JSON.stringify({ ...fields, ts: Date.now() })); - if (saveWarning) saveWarning.hidden = true; - } catch (err) { - console.warn('Draft auto-save failed:', err.message); - if (saveWarning) saveWarning.hidden = false; + + if (file.size > maxFileSize) { + setUploadStatus(`File too large. Maximum size is ${formatFileSize(maxFileSize)}.`, true); + return; } + + setUploadStatus('Uploading...', false); + + const formData = new FormData(); + formData.append('file', file); + + authenticatedFetch(`/compose/upload/${encodeURIComponent(slug)}`, { + method: 'POST', + body: formData, + }) + .then((res) => { + if (!res.ok) { + return res.json() + .catch(() => { throw new Error('Upload failed (server error)'); }) + .then((data) => { throw new Error(data.error || 'Upload failed'); }); + } + return res.json(); + }) + .then((data) => { + insertAtCursor(data.markdown + '\n'); + setUploadStatus('Uploaded: ' + data.filename, false); + }) + .catch((err) => { + setUploadStatus(err.message || 'Upload failed', true); + }); } - function scheduleSave() { - clearTimeout(saveTimer); - saveTimer = setTimeout(saveDraft, SAVE_DELAY); + function updateUploadButton() { + const slug = getSlug(); + if (uploadBtn) uploadBtn.disabled = !slug; + // Mirror the same discipline on the banner button so both upload + // affordances communicate "save draft first" via disabled state at + // init, not just at click time. + const bannerBtn = document.querySelector('[data-banner-upload-btn]'); + if (bannerBtn) bannerBtn.disabled = !slug; + if (!slug) { + setUploadStatus('Save as draft first to upload files', false); + } } + // Called from initAutoSave (discard, empty-on-save) and initFormSubmit + // (success/redirect). Lives at outer scope so editing-mode submit still + // clears any stale FULL_DRAFT_KEY and the saveTimer. function clearDraft() { clearTimeout(saveTimer); try { localStorage.removeItem(FULL_DRAFT_KEY); } catch (err) { console.warn('Failed to clear draft:', err.message); } } - function loadDraft() { - try { - const raw = localStorage.getItem(FULL_DRAFT_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch (err) { - console.warn('Failed to load draft:', err.message); - return null; - } + // --- Section helpers --- + + function initPreview() { + previewBtn.addEventListener('click', () => { + previewVisible = !previewVisible; + form.classList.toggle('preview-visible', previewVisible); + previewBtn.textContent = previewVisible ? 'Edit' : 'Preview'; + previewBtn.setAttribute('aria-pressed', previewVisible); + + if (previewVisible) { + fetchPreview(); + textarea.addEventListener('input', debouncedPreview); + } else { + textarea.removeEventListener('input', debouncedPreview); + clearTimeout(debounceTimer); + } + }, { signal }); } - if (!isEditing) { + function initAutoSave() { + function getFormFields() { + return { + content: textarea?.value || '', + title: titleInput?.value || '', + description: descInput?.value || '', + link_url: linkInput?.value || '', + tags: tagsInput?.value || '', + categories: catsInput?.value || '', + }; + } + + function hasContent(fields) { + return Object.values(fields).some((v) => v.trim() !== ''); + } + + function saveDraft() { + const fields = getFormFields(); + if (!hasContent(fields)) { + clearDraft(); + return; + } + try { + localStorage.setItem(FULL_DRAFT_KEY, JSON.stringify({ ...fields, ts: Date.now() })); + if (saveWarning) saveWarning.hidden = true; + } catch (err) { + console.warn('Draft auto-save failed:', err.message); + if (saveWarning) saveWarning.hidden = false; + } + } + + function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(saveDraft, SAVE_DELAY); + } + + function loadDraft() { + try { + const raw = localStorage.getItem(FULL_DRAFT_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (err) { + console.warn('Failed to load draft:', err.message); + return null; + } + } + // Recover draft on page load const draft = loadDraft(); if (draft && hasContent(draft)) { @@ -204,22 +303,20 @@ export function init() { } } - // ========================================================================= - // Fetch-based form submit with 401 handling - // ========================================================================= + function initFormSubmit() { + if (!form) return; - function resetButtons() { - if (saveDraftBtn) { - saveDraftBtn.disabled = false; - saveDraftBtn.textContent = 'Save Draft'; - } - if (publishBtn) { - publishBtn.disabled = false; - publishBtn.textContent = publishBtn.dataset.label || 'Publish'; + function resetButtons() { + if (saveDraftBtn) { + saveDraftBtn.disabled = false; + saveDraftBtn.textContent = 'Save Draft'; + } + if (publishBtn) { + publishBtn.disabled = false; + publishBtn.textContent = publishBtn.dataset.label || 'Publish'; + } } - } - if (form) { // Store original label for the publish button (Update vs Publish) if (publishBtn) publishBtn.dataset.label = publishBtn.textContent; @@ -275,208 +372,121 @@ export function init() { }, { signal }); } - // ========================================================================= - // Image Upload - // ========================================================================= - - const uploadBtn = document.querySelector('.compose-upload-btn'); - const fileInput = document.querySelector('.compose-file-input'); - const uploadStatus = document.querySelector('.upload-status'); + function initImageUpload() { + updateUploadButton(); - const maxFileSize = parseInt(form?.dataset.maxUpload || '10485760', 10); - - function formatFileSize(bytes) { - if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + ' MB'; - if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB'; - return bytes + ' bytes'; - } - - function getSlug() { - return form?.dataset.slug || ''; - } - - function insertAtCursor(text) { - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const before = textarea.value.substring(0, start); - const after = textarea.value.substring(end); - textarea.value = before + text + after; - textarea.selectionStart = textarea.selectionEnd = start + text.length; - textarea.focus(); - if (previewVisible) debouncedPreview(); - } - - function setUploadStatus(msg, isError) { - if (!uploadStatus) return; - uploadStatus.textContent = msg; - uploadStatus.className = 'upload-status' + (isError ? ' upload-error' : msg ? ' upload-success' : ''); - } - - function updateUploadButton() { - const slug = getSlug(); - if (uploadBtn) uploadBtn.disabled = !slug; - // Mirror the same discipline on the banner button so both upload - // affordances communicate "save draft first" via disabled state at - // init, not just at click time. - const bannerBtn = document.querySelector('[data-banner-upload-btn]'); - if (bannerBtn) bannerBtn.disabled = !slug; - if (!slug) { - setUploadStatus('Save as draft first to upload files', false); + if (uploadBtn && fileInput) { + uploadBtn.addEventListener('click', () => fileInput.click(), { signal }); + fileInput.addEventListener('change', () => { + if (fileInput.files.length > 0) { + uploadFile(fileInput.files[0]); + fileInput.value = ''; + } + }, { signal }); } } - function uploadFile(file) { - if (!file) return; - - const slug = getSlug(); - if (!slug) { - setUploadStatus('Save as draft first to upload files', false); - return; - } - - if (file.size > maxFileSize) { - setUploadStatus(`File too large. Maximum size is ${formatFileSize(maxFileSize)}.`, true); - return; - } - - setUploadStatus('Uploading...', false); - - const formData = new FormData(); - formData.append('file', file); - - authenticatedFetch(`/compose/upload/${encodeURIComponent(slug)}`, { - method: 'POST', - body: formData, - }) - .then((res) => { - if (!res.ok) { - return res.json() - .catch(() => { throw new Error('Upload failed (server error)'); }) - .then((data) => { throw new Error(data.error || 'Upload failed'); }); + function initBannerUpload() { + function setBannerValue(url) { + if (!bannerInput) return; + bannerInput.value = url; + if (bannerPreview) { + if (url) { + bannerPreview.src = url; + bannerPreview.hidden = false; + } else { + bannerPreview.removeAttribute('src'); + bannerPreview.hidden = true; } - return res.json(); - }) - .then((data) => { - insertAtCursor(data.markdown + '\n'); - setUploadStatus('Uploaded: ' + data.filename, false); - }) - .catch((err) => { - setUploadStatus(err.message || 'Upload failed', true); - }); - } - - updateUploadButton(); + } + if (bannerRemoveBtn) bannerRemoveBtn.hidden = !url; + // banner_alt has no meaning without a banner — clear it on remove so + // the form doesn't submit orphaned alt text. Server-side CreatePost + // / UpdateArticle also drop banner_alt when banner is empty, but the + // UI should reflect the rule. + if (!url) { + const altInput = document.querySelector('.compose-banner-alt'); + if (altInput) altInput.value = ''; + } + } - if (uploadBtn && fileInput) { - uploadBtn.addEventListener('click', () => fileInput.click(), { signal }); - fileInput.addEventListener('change', () => { - if (fileInput.files.length > 0) { - uploadFile(fileInput.files[0]); - fileInput.value = ''; + function uploadBanner(file) { + if (!file) return; + const slug = getSlug(); + if (!slug) { + setUploadStatus('Save as draft first to upload a banner', false); + return; + } + if (file.size > maxFileSize) { + setUploadStatus(`Banner too large. Maximum size is ${formatFileSize(maxFileSize)}.`, true); + return; } - }, { signal }); - } - // ========================================================================= - // Banner upload (separate flow — sets a hidden field, doesn't insert markdown) - // ========================================================================= + setUploadStatus('Uploading banner...', false); - const bannerInput = document.querySelector('[data-banner-input]'); - const bannerPreview = document.querySelector('[data-banner-preview]'); - const bannerUploadBtn = document.querySelector('[data-banner-upload-btn]'); - const bannerRemoveBtn = document.querySelector('[data-banner-remove-btn]'); - const bannerFileInput = document.querySelector('[data-banner-file-input]'); + const formData = new FormData(); + formData.append('file', file); - function setBannerValue(url) { - if (!bannerInput) return; - bannerInput.value = url; - if (bannerPreview) { - if (url) { - bannerPreview.src = url; - bannerPreview.hidden = false; - } else { - bannerPreview.removeAttribute('src'); - bannerPreview.hidden = true; - } - } - if (bannerRemoveBtn) bannerRemoveBtn.hidden = !url; - // banner_alt has no meaning without a banner — clear it on remove so - // the form doesn't submit orphaned alt text. Server-side CreatePost - // / UpdateArticle also drop banner_alt when banner is empty, but the - // UI should reflect the rule. - if (!url) { - const altInput = document.querySelector('.compose-banner-alt'); - if (altInput) altInput.value = ''; + authenticatedFetch(`/compose/upload/${encodeURIComponent(slug)}`, { + method: 'POST', + body: formData, + }) + .then((res) => { + if (!res.ok) { + return res.json() + .catch(() => { throw new Error('Banner upload failed (server error)'); }) + .then((data) => { throw new Error(data.error || 'Banner upload failed'); }); + } + return res.json(); + }) + .then((data) => { + setBannerValue(data.url); + setUploadStatus('Banner uploaded: ' + data.filename, false); + }) + .catch((err) => { + setUploadStatus(err.message || 'Banner upload failed', true); + }); } - } - function uploadBanner(file) { - if (!file) return; - const slug = getSlug(); - if (!slug) { - setUploadStatus('Save as draft first to upload a banner', false); - return; + if (bannerUploadBtn && bannerFileInput) { + bannerUploadBtn.addEventListener('click', () => bannerFileInput.click(), { signal }); + bannerFileInput.addEventListener('change', () => { + if (bannerFileInput.files.length > 0) { + uploadBanner(bannerFileInput.files[0]); + bannerFileInput.value = ''; + } + }, { signal }); } - if (file.size > maxFileSize) { - setUploadStatus(`Banner too large. Maximum size is ${formatFileSize(maxFileSize)}.`, true); - return; + if (bannerRemoveBtn) { + bannerRemoveBtn.addEventListener('click', () => setBannerValue(''), { signal }); } + } - setUploadStatus('Uploading banner...', false); - - const formData = new FormData(); - formData.append('file', file); + function initDragDrop() { + textarea.addEventListener('dragover', (e) => { + e.preventDefault(); + if (getSlug()) textarea.classList.add('compose-textarea-dragover'); + }, { signal }); - authenticatedFetch(`/compose/upload/${encodeURIComponent(slug)}`, { - method: 'POST', - body: formData, - }) - .then((res) => { - if (!res.ok) { - return res.json() - .catch(() => { throw new Error('Banner upload failed (server error)'); }) - .then((data) => { throw new Error(data.error || 'Banner upload failed'); }); - } - return res.json(); - }) - .then((data) => { - setBannerValue(data.url); - setUploadStatus('Banner uploaded: ' + data.filename, false); - }) - .catch((err) => { - setUploadStatus(err.message || 'Banner upload failed', true); - }); - } + textarea.addEventListener('dragleave', () => { + textarea.classList.remove('compose-textarea-dragover'); + }, { signal }); - if (bannerUploadBtn && bannerFileInput) { - bannerUploadBtn.addEventListener('click', () => bannerFileInput.click(), { signal }); - bannerFileInput.addEventListener('change', () => { - if (bannerFileInput.files.length > 0) { - uploadBanner(bannerFileInput.files[0]); - bannerFileInput.value = ''; - } + textarea.addEventListener('drop', (e) => { + e.preventDefault(); + textarea.classList.remove('compose-textarea-dragover'); + const files = e.dataTransfer.files; + if (files.length > 0) uploadFile(files[0]); }, { signal }); } - if (bannerRemoveBtn) { - bannerRemoveBtn.addEventListener('click', () => setBannerValue(''), { signal }); - } - // Drag and drop on textarea - textarea.addEventListener('dragover', (e) => { - e.preventDefault(); - if (getSlug()) textarea.classList.add('compose-textarea-dragover'); - }, { signal }); - - textarea.addEventListener('dragleave', () => { - textarea.classList.remove('compose-textarea-dragover'); - }, { signal }); - - textarea.addEventListener('drop', (e) => { - e.preventDefault(); - textarea.classList.remove('compose-textarea-dragover'); - const files = e.dataTransfer.files; - if (files.length > 0) uploadFile(files[0]); - }, { signal }); + // --- Wire up (matches original section order) --- + initPreview(); + if (!isEditing) initAutoSave(); + initFormSubmit(); + initImageUpload(); + initBannerUpload(); + initDragDrop(); } export function destroy() { diff --git a/web/templates/compose.html b/web/templates/compose.html index 20a36d2..660d589 100644 --- a/web/templates/compose.html +++ b/web/templates/compose.html @@ -36,7 +36,7 @@

Compose

Draft saving unavailable — copy your work before leaving -
+