max / makenotwork
75 files changed,
+806 insertions,
-238 deletions
| @@ -376,9 +376,14 @@ async fn security_headers_middleware( | |||
| 376 | 376 | // user-pages host, so that origin must be a permitted frame source. | |
| 377 | 377 | let scheme = if state.config.host_url.starts_with("https") { "https" } else { "http" }; | |
| 378 | 378 | let user_pages_origin = format!("{scheme}://{}", state.config.user_pages_host); | |
| 379 | + | // script-src is 'self' (+ Stripe) with NO 'unsafe-inline': all inline | |
| 380 | + | // on*/hx-on handlers were moved to delegated listeners in static/*.js | |
| 381 | + | // (the data-action / data-hx-* dispatchers in mnw.js), so any injected | |
| 382 | + | // markup can no longer execute script. style-src keeps 'unsafe-inline' | |
| 383 | + | // because inline style="" attributes are still used throughout. | |
| 379 | 384 | let csp = format!( | |
| 380 | 385 | "default-src 'self'; \ | |
| 381 | - | script-src 'self' 'unsafe-inline' https://js.stripe.com; \ | |
| 386 | + | script-src 'self' https://js.stripe.com; \ | |
| 382 | 387 | style-src 'self' 'unsafe-inline'; \ | |
| 383 | 388 | img-src 'self' data: https:; \ | |
| 384 | 389 | font-src 'self'; \ |
| @@ -0,0 +1,33 @@ | |||
| 1 | + | // Delegated event handlers extracted from inline on* attributes in the | |
| 2 | + | // dashboard, dashboard editor, embed, and wizard templates, moved here for CSP | |
| 3 | + | // compliance (so script-src can drop 'unsafe-inline'). The dispatcher in | |
| 4 | + | // mnw.js invokes each of these by name with `this` bound to the dispatching | |
| 5 | + | // element and any data-arg/data-arg2 attributes passed as arguments. Bodies | |
| 6 | + | // are preserved verbatim from the original inline handlers. | |
| 7 | + | ||
| 8 | + | // setActiveTab is defined in mnw.js and takes the clicked button as its | |
| 9 | + | // argument; the inline handlers called setActiveTab(this), so wrap it. | |
| 10 | + | window.onSetActiveTab = function () { | |
| 11 | + | setActiveTab(this); | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | // Reactivate-account form: schedule a page reload after the HTMX request. | |
| 15 | + | window.reactivateReload = function () { | |
| 16 | + | setTimeout(function(){ window.location.reload(); }, 500); | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | // Blog editor breadcrumb / cancel links: after navigating, activate the Blog tab. | |
| 20 | + | window.blogTabNav = function () { | |
| 21 | + | setTimeout(function(){document.getElementById('tab-blog').click()},100); | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | // Copy the item id to the clipboard and flash feedback on the button. | |
| 25 | + | window.onCopyItemId = function (id) { | |
| 26 | + | navigator.clipboard.writeText(id); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy Item ID', 1500); | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | // Custom page reset form: confirm before submitting. data-submit auto-prevents | |
| 30 | + | // the native submit, so re-submit programmatically when confirmed. | |
| 31 | + | window.confirmResetPage = function () { | |
| 32 | + | if (confirm('Reset this page to the platform default? This clears your custom HTML and CSS.')) this.submit(); | |
| 33 | + | }; |
| @@ -0,0 +1,64 @@ | |||
| 1 | + | // Delegated handlers extracted from inline on* attributes for CSP compliance. | |
| 2 | + | // These are the named wrappers referenced by data-action / data-change / | |
| 3 | + | // data-input / data-submit in the pages templates. The action dispatcher in | |
| 4 | + | // mnw.js invokes each with `this` bound to the dispatching element, so the | |
| 5 | + | // bodies read args from the element (this.value, this.dataset, this itself) | |
| 6 | + | // exactly as the original inline handlers did. | |
| 7 | + | ||
| 8 | + | // No-op verb: used where the original inline handler only stopped propagation | |
| 9 | + | // (the data-stop modifier does the actual work; this satisfies the dispatcher's | |
| 10 | + | // requirement for a data-action value). | |
| 11 | + | window.noop = function () {}; | |
| 12 | + | ||
| 13 | + | // cart.html: onclick="removeCartGroup(this)" | |
| 14 | + | window.onRemoveCartGroup = function () { | |
| 15 | + | removeCartGroup(this); | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | // discover.html: onclick="document.querySelector('.discover-sidebar').classList.toggle('show'); this.classList.toggle('active');" | |
| 19 | + | window.toggleDiscoverFilters = function () { | |
| 20 | + | document.querySelector('.discover-sidebar').classList.toggle('show'); | |
| 21 | + | this.classList.toggle('active'); | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | // index.html: onsubmit="return submitNotify(event)" (data-submit auto-prevents; | |
| 25 | + | // submitNotify only uses the event for preventDefault). | |
| 26 | + | window.onNotifySubmit = function () { | |
| 27 | + | submitNotify({ preventDefault: function () {} }); | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | // item.html: onclick="openCollectionPicker('{{ item.id }}', this)" | |
| 31 | + | window.onOpenCollectionPicker = function () { | |
| 32 | + | openCollectionPicker(this.getAttribute('data-arg'), this); | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | // item.html / library_downloads.html / project.html: | |
| 36 | + | // onclick="switchSectionTab(this, 'section-{{ section.slug }}')" | |
| 37 | + | window.onSwitchSectionTab = function () { | |
| 38 | + | switchSectionTab(this, this.getAttribute('data-arg')); | |
| 39 | + | }; | |
| 40 | + | ||
| 41 | + | // library.html: onclick="setActiveTab(this)" | |
| 42 | + | window.onSetActiveTab = function () { | |
| 43 | + | setActiveTab(this); | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | // purchase.html: oninput="updatePwywButton(this.value)" | |
| 47 | + | window.onPwywInput = function () { | |
| 48 | + | updatePwywButton(this.value); | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | // purchase.html: onclick="fetch('/api/cart/{{ item.id }}',{...}).then(...);return false;" | |
| 52 | + | window.onAddToCartLink = function () { | |
| 53 | + | fetch('/api/cart/' + this.getAttribute('data-arg'), {method:'POST',headers:csrfHeaders()}).then(function(r){if(!r.ok)throw new Error('Failed');return r.json()}).then(function(){window.location.href='/cart'}).catch(function(){alert('Could not add to cart. Please try again.')}); | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | // purchase.html: onclick="window.history.back()" | |
| 57 | + | window.historyBack = function () { | |
| 58 | + | window.history.back(); | |
| 59 | + | }; | |
| 60 | + | ||
| 61 | + | // receipt.html: onclick="window.print()" | |
| 62 | + | window.printPage = function () { | |
| 63 | + | window.print(); | |
| 64 | + | }; |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | /* | |
| 2 | + | * actions-partials.js | |
| 3 | + | * | |
| 4 | + | * Delegated event handlers extracted from inline on* attributes and | |
| 5 | + | * href="javascript:" links in templates/partials/*.html, for CSP compliance | |
| 6 | + | * (lets script-src drop 'unsafe-inline'). The delegated dispatcher lives in | |
| 7 | + | * static/mnw.js; it looks these up by name as globals and invokes them with | |
| 8 | + | * `this` bound to the dispatching element and args from data-arg/data-arg2. | |
| 9 | + | * | |
| 10 | + | * Each function preserves the original inline behavior verbatim. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | // No-op verb: lets an element use data-stop/data-prevent modifiers without | |
| 14 | + | // running any action (former onclick="event.stopPropagation();"). | |
| 15 | + | window.noop = function () {}; | |
| 16 | + | ||
| 17 | + | // link_row.html | |
| 18 | + | window.onMoveLink = function (dir) { moveLink(this, parseInt(dir, 10)); }; | |
| 19 | + | window.onEditLink = function () { editLink(this); }; | |
| 20 | + | window.onSaveLink = function () { saveLink(this); }; | |
| 21 | + | window.onCancelLink = function () { cancelLink(this); }; | |
| 22 | + | ||
| 23 | + | // git_nav.html | |
| 24 | + | window.navToValue = function () { if (this.value) window.location.href = this.value; }; | |
| 25 | + | ||
| 26 | + | // insertion_list.html | |
| 27 | + | window.insertionsStartUpload = function () { MNW.insertions.startUpload(); }; | |
| 28 | + | window.onInsertionFileSelected = function () { MNW.insertions.handleFileSelected(this); }; | |
| 29 | + | window.onInsertionRename = function (id) { MNW.insertions.rename(id, this.dataset.title); }; | |
| 30 | + | ||
| 31 | + | // promo_codes_list.html and item_license_keys.html (copy to clipboard) | |
| 32 | + | window.copyText = function () { navigator.clipboard.writeText(this.dataset.copy); }; | |
| 33 | + | ||
| 34 | + | // promo_codes_list.html (toggle inline edit row) | |
| 35 | + | window.togglePromoEditRow = function () { this.closest('tr').nextElementSibling.classList.toggle('hidden'); }; | |
| 36 | + | ||
| 37 | + | // tag_suggestions.html | |
| 38 | + | window.onAddTagSuggestion = function (id) { addTagById(id); this.remove(); }; | |
| 39 | + | ||
| 40 | + | // discover_results.html | |
| 41 | + | window.onOpenCollectionPicker = function () { openCollectionPicker(this.dataset.itemId, this); }; | |
| 42 | + | ||
| 43 | + | // item_edit_row.html | |
| 44 | + | window.onPriceInput = function () { this.nextElementSibling.value = Math.round(parseFloat(this.value || 0) * 100); }; | |
| 45 | + | ||
| 46 | + | // placement_list.html | |
| 47 | + | window.onPlacementPositionChange = function () { MNW.insertions.toggleOffsetInput(this.value); }; | |
| 48 | + | window.onAddPlacement = function (id) { MNW.insertions.addPlacement(id); }; | |
| 49 | + | ||
| 50 | + | // toast.html | |
| 51 | + | window.dismissToast = function () { this.parentElement.remove(); }; | |
| 52 | + | ||
| 53 | + | // slug_status.html | |
| 54 | + | window.applySlugSuggestion = function () { | |
| 55 | + | var i = this.closest('.form-group').querySelector('input[name=slug]'); | |
| 56 | + | if (i) { i.value = this.dataset.slug; i.dispatchEvent(new Event('keyup', { bubbles: true })); } | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | // tip_button.html | |
| 60 | + | window.toggleTipForm = function () { this.nextElementSibling.classList.toggle('hidden'); }; |
| @@ -0,0 +1,213 @@ | |||
| 1 | + | // actions-tabs.js | |
| 2 | + | // Delegated handler functions extracted from inline on* attributes in | |
| 3 | + | // templates/partials/tabs/**, for CSP compliance (no script-src 'unsafe-inline'). | |
| 4 | + | // The dispatcher in mnw.js invokes each named function with `this` === the | |
| 5 | + | // dispatching element and args drawn from data-arg / data-arg2. Bodies are the | |
| 6 | + | // verbatim former inline handlers, adjusted only to read args from the element. | |
| 7 | + | ||
| 8 | + | // --- item_overview.html --- | |
| 9 | + | window.openInNewTab = function (url) { | |
| 10 | + | window.open(url, '_blank'); | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | window.copyItemLink = function (path) { | |
| 14 | + | navigator.clipboard.writeText(window.location.origin + path).then(() => this.textContent = 'Copied!').catch(() => this.textContent = 'Failed'); | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | window.openEmbedSection = function () { | |
| 18 | + | var d = document.querySelector('details.content-section'); if (d) { d.open = true; d.scrollIntoView({ behavior: 'smooth' }); } | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | // --- item_embed.html --- | |
| 22 | + | window.copyEmbedBtn = function () { | |
| 23 | + | copyEmbed(this); | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | // --- project_settings.html --- | |
| 27 | + | window.submitProjectInfo = function () { | |
| 28 | + | saveProjectInfo({ preventDefault: function () {} }, this.getAttribute('data-project-id')); | |
| 29 | + | }; | |
| 30 | + | ||
| 31 | + | window.submitProjectPricing = function () { | |
| 32 | + | saveProjectPricing({ preventDefault: function () {} }, this.getAttribute('data-project-id')); | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | // --- item_pricing.html --- | |
| 36 | + | window.togglePwywSettings = function () { | |
| 37 | + | document.getElementById('pwyw-settings').classList.toggle('hidden', !this.checked); | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | window.toggleCustomLicense = function () { | |
| 41 | + | document.getElementById('dash-custom-license').classList.toggle('hidden', this.value !== 'custom'); | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | window.toggleLicenseKeys = function () { | |
| 45 | + | document.getElementById('license-keys-section').classList.toggle('hidden', !this.checked); | |
| 46 | + | }; | |
| 47 | + | ||
| 48 | + | // --- CSV export buttons (project_subscriptions, buyer_contacts, user_payments, user_creator) --- | |
| 49 | + | window.exportCsvButton = function (url, filename) { | |
| 50 | + | this.textContent = 'Exporting...'; this.disabled = true; var btn = this; fetch(url, { method: 'POST', headers: csrfHeaders() }).then(function (r) { return r.blob(); }).then(function (b) { var a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = filename; a.click(); btn.textContent = 'Export CSV'; btn.disabled = false; }).catch(function () { btn.textContent = 'Export CSV'; btn.disabled = false; showToast('Export failed'); }); | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | // --- project_synckit.html / user_synckit.html --- | |
| 54 | + | window.syncKitShowSlugFromBtn = function (appId) { | |
| 55 | + | syncKitShowSlugForm(appId, this.dataset.slug); | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | window.syncKitDeleteFromBtn = function (appId) { | |
| 59 | + | syncKitDeleteApp(appId, this.dataset.name); | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | // --- project_promotions.html --- | |
| 63 | + | window.onPromoTypeChange = function () { | |
| 64 | + | togglePromoFields(this.value); | |
| 65 | + | }; | |
| 66 | + | ||
| 67 | + | window.syncTrialDays = function () { | |
| 68 | + | document.getElementById('pc-trial-days').value = this.value; | |
| 69 | + | }; | |
| 70 | + | ||
| 71 | + | // --- user_payments.html --- | |
| 72 | + | window.submitOwnForm = function () { | |
| 73 | + | this.form.requestSubmit(); | |
| 74 | + | }; | |
| 75 | + | ||
| 76 | + | // --- project_content.html --- | |
| 77 | + | window.deselectAll = function () { | |
| 78 | + | toggleSelectAll(false); | |
| 79 | + | }; | |
| 80 | + | ||
| 81 | + | window.toggleSelectAllFromEl = function () { | |
| 82 | + | toggleSelectAll(this.checked); | |
| 83 | + | }; | |
| 84 | + | ||
| 85 | + | window.sortContent = function (colIndex, sortType) { | |
| 86 | + | sortContentTable(parseInt(colIndex, 10), sortType); | |
| 87 | + | }; | |
| 88 | + | ||
| 89 | + | // --- library_collections.html --- | |
| 90 | + | window.slugifyCollName = function () { | |
| 91 | + | document.getElementById('new-coll-slug').value = this.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); | |
| 92 | + | }; | |
| 93 | + | ||
| 94 | + | // --- library_purchases.html --- | |
| 95 | + | window.filterLibraryRows = function () { | |
| 96 | + | (function (q) { var rows = document.querySelectorAll('#library-table tbody tr'); q = q.toLowerCase(); rows.forEach(function (r) { r.classList.toggle('hidden', r.textContent.toLowerCase().indexOf(q) === -1); }); })(this.value); | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | window.copyKeyCode = function (code) { | |
| 100 | + | navigator.clipboard.writeText(code); this.textContent = 'Copied!'; setTimeout(() => this.textContent = code, 1500); | |
| 101 | + | }; | |
| 102 | + | ||
| 103 | + | window.toggleContextMenuBtn = function (itemId) { | |
| 104 | + | toggleContextMenu({ stopPropagation: function () {} }, itemId); | |
| 105 | + | }; | |
| 106 | + | ||
| 107 | + | window.openCollectionPickerBtn = function (itemId) { | |
| 108 | + | openCollectionPicker(itemId, this); | |
| 109 | + | }; | |
| 110 | + | ||
| 111 | + | // --- user_profile.html --- | |
| 112 | + | window.moveLinkUp = function () { | |
| 113 | + | moveLink(this, -1); | |
| 114 | + | }; | |
| 115 | + | ||
| 116 | + | window.moveLinkDown = function () { | |
| 117 | + | moveLink(this, 1); | |
| 118 | + | }; | |
| 119 | + | ||
| 120 | + | window.editLinkBtn = function () { | |
| 121 | + | editLink(this); | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | window.saveLinkBtn = function () { | |
| 125 | + | saveLink(this); | |
| 126 | + | }; | |
| 127 | + | ||
| 128 | + | window.cancelLinkBtn = function () { | |
| 129 | + | cancelLink(this); | |
| 130 | + | }; | |
| 131 | + | ||
| 132 | + | window.copyElementText = function (id) { | |
| 133 | + | navigator.clipboard.writeText(document.getElementById(id).textContent).then(() => { this.textContent = 'Copied'; setTimeout(() => this.textContent = 'Copy', 1500); }); | |
| 134 | + | }; | |
| 135 | + | ||
| 136 | + | window.copyFeedUrl = function () { | |
| 137 | + | navigator.clipboard.writeText(document.getElementById('feed-url').value).then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy URL', 2000); }); | |
| 138 | + | }; | |
| 139 | + | ||
| 140 | + | // --- user_media.html --- | |
| 141 | + | window.onMediaFilesChange = function () { | |
| 142 | + | mediaUploadFiles(this.files); | |
| 143 | + | }; | |
| 144 | + | ||
| 145 | + | window.mediaFilterAll = function () { | |
| 146 | + | mediaFilterFolder(null, this); | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | window.mediaFilterFolderBtn = function (folder) { | |
| 150 | + | mediaFilterFolder(folder, this); | |
| 151 | + | }; | |
| 152 | + | ||
| 153 | + | window.mediaDeleteFileBtn = function (id) { | |
| 154 | + | mediaDeleteFile(id, this.dataset.filename); | |
| 155 | + | }; | |
| 156 | + | ||
| 157 | + | // --- user_settings.html --- | |
| 158 | + | window.setSettingsSectionBtn = function () { | |
| 159 | + | setSettingsSection(this); | |
| 160 | + | }; | |
| 161 | + | ||
| 162 | + | // --- item_details.html --- | |
| 163 | + | window.onSearchTagsInput = function () { | |
| 164 | + | searchTags(this.value); | |
| 165 | + | }; | |
| 166 | + | ||
| 167 | + | window.syncItemHiddenFields = function () { | |
| 168 | + | document.getElementById('hidden-title').value = document.getElementById('item-name').value; document.getElementById('hidden-desc').value = document.getElementById('item-description').value; document.getElementById('hidden-ai-tier').value = document.getElementById('ai_tier').value; document.getElementById('hidden-ai-disclosure').value = document.getElementById('ai_disclosure').value; | |
| 169 | + | }; | |
| 170 | + | ||
| 171 | + | // --- user_creator.html --- | |
| 172 | + | window.toggleTrialDetails = function () { | |
| 173 | + | document.getElementById('trial-details').classList.toggle('hidden', !this.checked); | |
| 174 | + | }; | |
| 175 | + | ||
| 176 | + | // --- Non-dispatcher delegated listeners --- | |
| 177 | + | // The mnw.js dispatcher only covers click/change/input/submit. The handlers | |
| 178 | + | // below cover drag-and-drop (user_media) and conditional submit (user_account), | |
| 179 | + | // keyed on data attributes so the inline handlers can be removed. | |
| 180 | + | ||
| 181 | + | // Media upload drop zone (user_media.html): former ondragover/ondragleave/ondrop. | |
| 182 | + | document.addEventListener('dragover', function (e) { | |
| 183 | + | var el = e.target.closest('[data-media-dropzone]'); | |
| 184 | + | if (!el) return; | |
| 185 | + | e.preventDefault(); el.classList.add('user-media-upload--dragover'); | |
| 186 | + | }); | |
| 187 | + | document.addEventListener('dragleave', function (e) { | |
| 188 | + | var el = e.target.closest('[data-media-dropzone]'); | |
| 189 | + | if (!el) return; | |
| 190 | + | el.classList.remove('user-media-upload--dragover'); | |
| 191 | + | }); | |
| 192 | + | document.addEventListener('drop', function (e) { | |
| 193 | + | var el = e.target.closest('[data-media-dropzone]'); | |
| 194 | + | if (!el) return; | |
| 195 | + | mediaHandleDrop(e); el.classList.remove('user-media-upload--dragover'); | |
| 196 | + | }); | |
| 197 | + | ||
| 198 | + | // Conditional submit guard (user_account.html): a former onsubmit that returned | |
| 199 | + | // false to block submission. Runs in capture phase so a false result can stop | |
| 200 | + | // propagation before htmx's submit handler sees the event. | |
| 201 | + | document.addEventListener('submit', function (e) { | |
| 202 | + | var el = e.target.closest('[data-check-submit]'); | |
| 203 | + | if (!el) return; | |
| 204 | + | var fn = window[el.getAttribute('data-check-submit')]; | |
| 205 | + | if (typeof fn === 'function') { | |
| 206 | + | var r = fn.call(el, e); | |
| 207 | + | if (r === false) { e.preventDefault(); e.stopPropagation(); } | |
| 208 | + | } | |
| 209 | + | }, true); | |
| 210 | + | ||
| 211 | + | window.checkPasswordMatch = function () { | |
| 212 | + | var np = document.getElementById('new-password').value, cp = document.getElementById('confirm-password').value; if (np !== cp) { var s = document.getElementById('password-status'); s.textContent = 'Passwords do not match'; s.classList.add('danger-text'); return false; } | |
| 213 | + | }; |
| @@ -24,3 +24,13 @@ function seek(e) { | |||
| 24 | 24 | const rect = e.currentTarget.getBoundingClientRect(); | |
| 25 | 25 | audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; | |
| 26 | 26 | } | |
| 27 | + | ||
| 28 | + | // Wire the play/seek controls without inline on* handlers (the embed page runs | |
| 29 | + | // under the same CSP that forbids script-src 'unsafe-inline'). This page loads | |
| 30 | + | // only this file, not mnw.js's data-action dispatcher, so bind directly. | |
| 31 | + | document.addEventListener('DOMContentLoaded', function () { | |
| 32 | + | var play = document.getElementById('play'); | |
| 33 | + | if (play) play.addEventListener('click', togglePlay); | |
| 34 | + | var bar = document.getElementById('progress-bar'); | |
| 35 | + | if (bar) bar.addEventListener('click', seek); | |
| 36 | + | }); |
| @@ -674,3 +674,181 @@ document.addEventListener('click', function(evt) { | |||
| 674 | 674 | window.prompt('Copy this link:', url); | |
| 675 | 675 | } | |
| 676 | 676 | }); | |
| 677 | + | ||
| 678 | + | /* =========================================== | |
| 679 | + | ACTION DISPATCHER — delegated on* replacement | |
| 680 | + | =========================================== * | |
| 681 | + | * Lets templates drop inline on* handlers so the CSP can drop | |
| 682 | + | * script-src 'unsafe-inline'. An element declares behavior via a data-* | |
| 683 | + | * attribute whose value is either a built-in verb or a global function name: | |
| 684 | + | * | |
| 685 | + | * data-action -> click | |
| 686 | + | * data-change -> change | |
| 687 | + | * data-input -> input | |
| 688 | + | * data-submit -> submit (preventDefault is automatic for submit) | |
| 689 | + | * | |
| 690 | + | * Modifiers (presence of the attribute): data-prevent -> preventDefault, | |
| 691 | + | * data-stop -> stopPropagation. Applied before the verb runs. | |
| 692 | + | * | |
| 693 | + | * Built-in verbs operate on data-target (one or more element ids, space | |
| 694 | + | * separated); data-href supplies a URL: | |
| 695 | + | * show / hide / toggle -> classList remove/add/toggle 'hidden' on target(s) | |
| 696 | + | * click -> .click() the target(s) | |
| 697 | + | * remove -> .remove() the target(s) | |
| 698 | + | * remove-self -> .remove() the dispatching element | |
| 699 | + | * nav -> window.location.href = data-href | |
| 700 | + | * | |
| 701 | + | * Any other value is treated as a global function name and invoked as | |
| 702 | + | * fn.apply(element, args) with `this` === the element and args drawn from | |
| 703 | + | * data-arg / data-arg2 (both optional). So a former `fn()` becomes | |
| 704 | + | * data-action="fn"; `fn('x')` becomes data-action="fn" data-arg="x"; and a | |
| 705 | + | * former `fn(this)` becomes data-action="fn" with fn reading `this`. | |
| 706 | + | */ | |
| 707 | + | (function () { | |
| 708 | + | function targets(el) { | |
| 709 | + | var raw = el.getAttribute('data-target'); | |
| 710 | + | if (!raw) return []; | |
| 711 | + | return raw.split(/\s+/).filter(Boolean).map(function (id) { | |
| 712 | + | return document.getElementById(id); | |
| 713 | + | }).filter(Boolean); | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | var BUILTINS = { | |
| 717 | + | show: function (el) { targets(el).forEach(function (t) { t.classList.remove('hidden'); }); }, | |
| 718 | + | hide: function (el) { targets(el).forEach(function (t) { t.classList.add('hidden'); }); }, | |
| 719 | + | toggle: function (el) { targets(el).forEach(function (t) { t.classList.toggle('hidden'); }); }, | |
| 720 | + | click: function (el) { targets(el).forEach(function (t) { t.click(); }); }, | |
| 721 | + | remove: function (el) { targets(el).forEach(function (t) { t.remove(); }); }, | |
| 722 | + | 'remove-self': function (el) { el.remove(); } | |
| 723 | + | }; | |
| 724 | + | ||
| 725 | + | function run(el, verb, evt) { | |
| 726 | + | if (el.hasAttribute('data-prevent')) evt.preventDefault(); | |
| 727 | + | if (el.hasAttribute('data-stop')) evt.stopPropagation(); | |
| 728 | + | if (verb === 'nav') { | |
| 729 | + | var href = el.getAttribute('data-href'); | |
| 730 | + | if (href) window.location.href = href; | |
| 731 | + | return; | |
| 732 | + | } | |
| 733 | + | if (BUILTINS[verb]) { BUILTINS[verb](el, evt); return; } | |
| 734 | + | var fn = window[verb]; | |
| 735 | + | if (typeof fn !== 'function') { | |
| 736 | + | if (window.console) console.warn('data-action: no global function', verb); | |
| 737 | + | return; | |
| 738 | + | } | |
| 739 | + | var args = []; | |
| 740 | + | if (el.hasAttribute('data-arg')) args.push(el.getAttribute('data-arg')); | |
| 741 | + | if (el.hasAttribute('data-arg2')) args.push(el.getAttribute('data-arg2')); | |
| 742 | + | fn.apply(el, args); | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | function listen(eventName, attr, autoPrevent) { | |
| 746 | + | document.addEventListener(eventName, function (evt) { | |
| 747 | + | var el = evt.target.closest('[' + attr + ']'); | |
| 748 | + | if (!el) return; | |
| 749 | + | if (autoPrevent) evt.preventDefault(); | |
| 750 | + | run(el, el.getAttribute(attr), evt); | |
| 751 | + | }); | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | listen('click', 'data-action', false); | |
| 755 | + | listen('change', 'data-change', false); | |
| 756 | + | listen('input', 'data-input', false); | |
| 757 | + | listen('submit', 'data-submit', true); | |
| 758 | + | })(); | |
| 759 | + | ||
| 760 | + | /* =========================================== | |
| 761 | + | HTMX CALLBACK DISPATCHER — hx-on:: replacement | |
| 762 | + | =========================================== * | |
| 763 | + | * Replaces inline `hx-on::after-request` / `hx-on:htmx:config-request` | |
| 764 | + | * attributes (which are inline scripts and would keep script-src pinned to | |
| 765 | + | * 'unsafe-inline') with delegated listeners driven by data attributes. | |
| 766 | + | * | |
| 767 | + | * On the element that fired the request (evt.detail.elt): | |
| 768 | + | * data-hx-always behavior name that runs regardless of outcome | |
| 769 | + | * data-hx-always-click=ID .click() that element id regardless of outcome | |
| 770 | + | * ...and, only when the request succeeded (evt.detail.successful): | |
| 771 | + | * data-hx-reset this.reset() (form) | |
| 772 | + | * data-hx-click=ID .click() the element with that id | |
| 773 | + | * data-hx-get=URL + data-hx-target=SEL htmx.ajax('GET', URL, SEL) | |
| 774 | + | * data-hx-nav=URL window.location.href = URL | |
| 775 | + | * data-hx-reload window.location.reload() | |
| 776 | + | * data-hx-toast=MSG (+ data-hx-toast-type) showToast(MSG, type) | |
| 777 | + | * data-hx-behavior=NAME run a named behavior (custom multi-step cases) | |
| 778 | + | * | |
| 779 | + | * configRequest (before send): | |
| 780 | + | * data-hx-config="publish-at-iso" normalize publish_at to ISO 8601 | |
| 781 | + | */ | |
| 782 | + | (function () { | |
| 783 | + | function byId(id) { return id && document.getElementById(id); } | |
| 784 | + | ||
| 785 | + | function refreshProfileTab() { | |
| 786 | + | var t = document.getElementById('settings-body'); | |
| 787 | + | if (t) { | |
| 788 | + | htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' }); | |
| 789 | + | } else { | |
| 790 | + | var b = document.getElementById('tab-profile'); | |
| 791 | + | if (b) b.click(); | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | var BEHAVIORS = { | |
| 796 | + | 'mark-added': function (el) { el.textContent = 'Added'; el.disabled = true; }, | |
| 797 | + | // Faithful to the original inline handler: the row fades only on success, | |
| 798 | + | // but the label flips to 'Added' unconditionally. Registered under | |
| 799 | + | // data-hx-always so it sees both outcomes. | |
| 800 | + | 'fade-row-added': function (el, evt) { | |
| 801 | + | if (evt.detail.successful) { | |
| 802 | + | var tr = el.closest('tr'); | |
| 803 | + | if (tr) tr.classList.add('is-faded'); | |
| 804 | + | } | |
| 805 | + | el.textContent = 'Added'; | |
| 806 | + | }, | |
| 807 | + | 'refresh-profile': function () { refreshProfileTab(); }, | |
| 808 | + | 'refresh-profile-if-verified': function (el, evt) { | |
| 809 | + | if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) { | |
| 810 | + | refreshProfileTab(); | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | }; | |
| 814 | + | ||
| 815 | + | document.body.addEventListener('htmx:afterRequest', function (evt) { | |
| 816 | + | var el = evt.detail.elt; | |
| 817 | + | if (!el || !el.getAttribute) return; | |
| 818 | + | ||
| 819 | + | var always = el.getAttribute('data-hx-always'); | |
| 820 | + | if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt); | |
| 821 | + | if (el.hasAttribute('data-hx-always-click')) { | |
| 822 | + | var ac = byId(el.getAttribute('data-hx-always-click')); | |
| 823 | + | if (ac) ac.click(); | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | if (!evt.detail.successful) return; | |
| 827 | + | ||
| 828 | + | if (el.hasAttribute('data-hx-reset') && typeof el.reset === 'function') el.reset(); | |
| 829 | + | if (el.hasAttribute('data-hx-click')) { | |
| 830 | + | var t = byId(el.getAttribute('data-hx-click')); | |
| 831 | + | if (t) t.click(); | |
| 832 | + | } | |
| 833 | + | if (el.hasAttribute('data-hx-get')) { | |
| 834 | + | var url = el.getAttribute('data-hx-get'); | |
| 835 | + | var target = el.getAttribute('data-hx-target'); | |
| 836 | + | if (url && target) htmx.ajax('GET', url, target); | |
| 837 | + | } | |
| 838 | + | if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav'); | |
| 839 | + | if (el.hasAttribute('data-hx-reload')) window.location.reload(); | |
| 840 | + | if (el.hasAttribute('data-hx-toast')) { | |
| 841 | + | showToast(el.getAttribute('data-hx-toast'), el.getAttribute('data-hx-toast-type') || 'info'); | |
| 842 | + | } | |
| 843 | + | var beh = el.getAttribute('data-hx-behavior'); | |
| 844 | + | if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt); | |
| 845 | + | }); | |
| 846 | + | ||
| 847 | + | document.body.addEventListener('htmx:configRequest', function (evt) { | |
| 848 | + | var el = evt.detail.elt; | |
| 849 | + | if (el && el.getAttribute && el.getAttribute('data-hx-config') === 'publish-at-iso') { | |
| 850 | + | var v = evt.detail.parameters.publish_at; | |
| 851 | + | if (v) evt.detail.parameters.publish_at = new Date(v).toISOString(); | |
| 852 | + | } | |
| 853 | + | }); | |
| 854 | + | })(); |
| @@ -35,8 +35,8 @@ | |||
| 35 | 35 | <a href="/changelog">Changelog</a> | |
| 36 | 36 | <a href="mailto:info@makenot.work">Contact</a> | |
| 37 | 37 | <a href="/health" title="Service status and uptime">Status</a> | |
| 38 | - | <a href="#" onclick="event.preventDefault(); showWhatsNewModal();">What's new</a> | |
| 39 | - | <a href="#" onclick="event.preventDefault(); toggleShortcutsHelp();" title="Keyboard shortcuts (?)">Shortcuts</a> | |
| 38 | + | <a href="#" data-prevent data-action="showWhatsNewModal">What's new</a> | |
| 39 | + | <a href="#" data-prevent data-action="toggleShortcutsHelp" title="Keyboard shortcuts (?)">Shortcuts</a> | |
| 40 | 40 | </div> | |
| 41 | 41 | <p>© 2026 Make Creative, LLC</p> | |
| 42 | 42 | </footer> | |
| @@ -44,7 +44,14 @@ | |||
| 44 | 44 | <!-- Toast notification container --> | |
| 45 | 45 | <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div> | |
| 46 | 46 | ||
| 47 | - | <script src="/static/mnw.js?v=0531"></script> | |
| 47 | + | <script src="/static/mnw.js?v=0701"></script> | |
| 48 | + | <!-- Delegated on* handlers, extracted from inline attributes so the CSP can | |
| 49 | + | drop script-src 'unsafe-inline'. Loaded globally after mnw.js (which | |
| 50 | + | defines the data-action dispatcher). --> | |
| 51 | + | <script src="/static/actions-pages.js?v=0701"></script> | |
| 52 | + | <script src="/static/actions-partials.js?v=0701"></script> | |
| 53 | + | <script src="/static/actions-tabs.js?v=0701"></script> | |
| 54 | + | <script src="/static/actions-dashboards.js?v=0701"></script> | |
| 48 | 55 | <script src="/static/carousel.js?v=0605"></script> | |
| 49 | 56 | <script src="/static/collections.js?v=0514"></script> | |
| 50 | 57 | <script src="/static/synckit-billing.js?v=0620"></script> |
| @@ -86,7 +86,7 @@ | |||
| 86 | 86 | </section> | |
| 87 | 87 | ||
| 88 | 88 | <form class="cp-reset" method="post" action="{{ base_path }}/reset" | |
| 89 | - | onsubmit="return confirm('Reset this page to the platform default? This clears your custom HTML and CSS.');"> | |
| 89 | + | data-submit="confirmResetPage"> | |
| 90 | 90 | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 91 | 91 | <button type="submit" class="btn-danger">Reset to default</button> | |
| 92 | 92 | </form> |
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | <div class="breadcrumb"> | |
| 11 | 11 | <a href="/dashboard">Dashboard</a> / | |
| 12 | 12 | <a href="/dashboard/project/{{ project_slug }}">{{ project_slug }}</a> / | |
| 13 | - | <a href="/dashboard/project/{{ project_slug }}" onclick="setTimeout(function(){document.getElementById('tab-blog').click()},100)">Blog</a> / | |
| 13 | + | <a href="/dashboard/project/{{ project_slug }}" data-action="blogTabNav">Blog</a> / | |
| 14 | 14 | {% if editing %}Edit{% else %}New Post{% endif %} | |
| 15 | 15 | </div> | |
| 16 | 16 | ||
| @@ -37,7 +37,7 @@ | |||
| 37 | 37 | <div class="form-group"> | |
| 38 | 38 | <label for="post-body">Content (Markdown)</label> | |
| 39 | 39 | <textarea id="post-body" rows="20" class="blog-editor-input input--sm w-full" placeholder="Write your post in Markdown...">{{ post_body }}</textarea> | |
| 40 | - | <button type="button" class="btn-secondary blog-editor-media-btn" onclick="mediaPickerOpen('post-body')">Insert Image</button> | |
| 40 | + | <button type="button" class="btn-secondary blog-editor-media-btn" data-action="mediaPickerOpen" data-arg="post-body">Insert Image</button> | |
| 41 | 41 | </div> | |
| 42 | 42 | {% if is_changelog_project %} | |
| 43 | 43 | <div class="form-group blog-editor-landing-toggle"> | |
| @@ -51,7 +51,7 @@ | |||
| 51 | 51 | <div class="blog-editor-actions"> | |
| 52 | 52 | <button class="btn-primary" id="save-draft-btn">{% if editing %}Save as Draft{% else %}Save Draft{% endif %}</button> | |
| 53 | 53 | <button class="btn-secondary" id="publish-btn">{% if editing && post_is_published %}Update{% else %}Publish{% endif %}</button> | |
| 54 | - | <a href="/dashboard/project/{{ project_slug }}" onclick="setTimeout(function(){document.getElementById('tab-blog').click()},100)"> | |
| 54 | + | <a href="/dashboard/project/{{ project_slug }}" data-action="blogTabNav"> | |
| 55 | 55 | <button class="btn-secondary" type="button">Cancel</button> | |
| 56 | 56 | </a> | |
| 57 | 57 | </div> |
| @@ -17,7 +17,7 @@ | |||
| 17 | 17 | <h1 class="page-title">Export Your Data</h1> | |
| 18 | 18 | <p class="subtitle">Download your content, projects, and transaction history.</p> | |
| 19 | 19 | </div> | |
| 20 | - | <button class="btn-primary export-all-btn" id="export-all-btn" onclick="exportAll()"> | |
| 20 | + | <button class="btn-primary export-all-btn" id="export-all-btn" data-action="exportAll"> | |
| 21 | 21 | Export All | |
| 22 | 22 | </button> | |
| 23 | 23 | </header> |
| @@ -33,7 +33,7 @@ | |||
| 33 | 33 | <span>{{ item.sales_count }} sales</span> | |
| 34 | 34 | <button class="btn-secondary small dashboard-item-share-btn" | |
| 35 | 35 | title="Copy item ID for use in embeds or integrations" | |
| 36 | - | onclick="navigator.clipboard.writeText('{{ item.id }}'); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy Item ID', 1500);">Copy Item ID</button> | |
| 36 | + | data-action="onCopyItemId" data-arg="{{ item.id }}">Copy Item ID</button> | |
| 37 | 37 | </div> | |
| 38 | 38 | </header> | |
| 39 | 39 | ||
| @@ -48,7 +48,7 @@ | |||
| 48 | 48 | hx-target="#tab-content" | |
| 49 | 49 | hx-swap="innerHTML" | |
| 50 | 50 | hx-indicator="#tab-spinner" | |
| 51 | - | onclick="setActiveTab(this)">Overview</button> | |
| 51 | + | data-action="onSetActiveTab">Overview</button> | |
| 52 | 52 | <button class="tab" | |
| 53 | 53 | role="tab" | |
| 54 | 54 | aria-selected="false" | |
| @@ -59,7 +59,7 @@ | |||
| 59 | 59 | hx-target="#tab-content" | |
| 60 | 60 | hx-swap="innerHTML" | |
| 61 | 61 | hx-indicator="#tab-spinner" | |
| 62 | - | onclick="setActiveTab(this)">Details</button> | |
| 62 | + | data-action="onSetActiveTab">Details</button> | |
| 63 | 63 | <button class="tab" | |
| 64 | 64 | role="tab" | |
| 65 | 65 | aria-selected="false" | |
| @@ -70,7 +70,7 @@ | |||
| 70 | 70 | hx-target="#tab-content" | |
| 71 | 71 | hx-swap="innerHTML" | |
| 72 | 72 | hx-indicator="#tab-spinner" | |
| 73 | - | onclick="setActiveTab(this)">Pricing</button> | |
| 73 | + | data-action="onSetActiveTab">Pricing</button> | |
| 74 | 74 | {% if item.item_type != "bundle" %} | |
| 75 | 75 | <button class="tab" | |
| 76 | 76 | role="tab" | |
| @@ -82,7 +82,7 @@ | |||
| 82 | 82 | hx-target="#tab-content" | |
| 83 | 83 | hx-swap="innerHTML" | |
| 84 | 84 | hx-indicator="#tab-spinner" | |
| 85 | - | onclick="setActiveTab(this)">Files</button> | |
| 85 | + | data-action="onSetActiveTab">Files</button> | |
| 86 | 86 | {% endif %} | |
| 87 | 87 | <button class="tab" | |
| 88 | 88 | role="tab" | |
| @@ -94,7 +94,7 @@ | |||
| 94 | 94 | hx-target="#tab-content" | |
| 95 | 95 | hx-swap="innerHTML" | |
| 96 | 96 | hx-indicator="#tab-spinner" | |
| 97 | - | onclick="setActiveTab(this)">Sales</button> | |
| 97 | + | data-action="onSetActiveTab">Sales</button> | |
| 98 | 98 | <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span> | |
| 99 | 99 | </div> | |
| 100 | 100 |
| @@ -40,7 +40,7 @@ | |||
| 40 | 40 | hx-target="#tab-content" | |
| 41 | 41 | hx-swap="innerHTML" | |
| 42 | 42 | hx-indicator="#tab-spinner" | |
| 43 | - | onclick="setActiveTab(this)">Overview</button> | |
| 43 | + | data-action="onSetActiveTab">Overview</button> | |
| 44 | 44 | <button class="tab" | |
| 45 | 45 | role="tab" | |
| 46 | 46 | aria-selected="false" | |
| @@ -51,7 +51,7 @@ | |||
| 51 | 51 | hx-target="#tab-content" | |
| 52 | 52 | hx-swap="innerHTML" | |
| 53 | 53 | hx-indicator="#tab-spinner" | |
| 54 | - | onclick="setActiveTab(this)">Content</button> | |
| 54 | + | data-action="onSetActiveTab">Content</button> | |
| 55 | 55 | <button class="tab" | |
| 56 | 56 | role="tab" | |
| 57 | 57 | aria-selected="false" | |
| @@ -62,7 +62,7 @@ | |||
| 62 | 62 | hx-target="#tab-content" | |
| 63 | 63 | hx-swap="innerHTML" | |
| 64 | 64 | hx-indicator="#tab-spinner" | |
| 65 | - | onclick="setActiveTab(this)">Analytics</button> | |
| 65 | + | data-action="onSetActiveTab">Analytics</button> | |
| 66 | 66 | <button class="tab" | |
| 67 | 67 | role="tab" | |
| 68 | 68 | aria-selected="false" | |
| @@ -73,7 +73,7 @@ | |||
| 73 | 73 | hx-target="#tab-content" | |
| 74 | 74 | hx-swap="innerHTML" | |
| 75 | 75 | hx-indicator="#tab-spinner" | |
| 76 | - | onclick="setActiveTab(this)">Monetization</button> | |
| 76 | + | data-action="onSetActiveTab">Monetization</button> | |
| 77 | 77 | {% if git_enabled %} | |
| 78 | 78 | <button class="tab" | |
| 79 | 79 | role="tab" | |
| @@ -85,7 +85,7 @@ | |||
| 85 | 85 | hx-target="#tab-content" | |
| 86 | 86 | hx-swap="innerHTML" | |
| 87 | 87 | hx-indicator="#tab-spinner" | |
| 88 | - | onclick="setActiveTab(this)">Code</button> | |
| 88 | + | data-action="onSetActiveTab">Code</button> | |
| 89 | 89 | {% endif %} | |
| 90 | 90 | {% if synckit_enabled %} | |
| 91 | 91 | <button class="tab" | |
| @@ -98,7 +98,7 @@ | |||
| 98 | 98 | hx-target="#tab-content" | |
| 99 | 99 | hx-swap="innerHTML" | |
| 100 | 100 | hx-indicator="#tab-spinner" | |
| 101 | - | onclick="setActiveTab(this)">Cloud Sync</button> | |
| 101 | + | data-action="onSetActiveTab">Cloud Sync</button> | |
| 102 | 102 | {% endif %} | |
| 103 | 103 | <button class="tab" | |
| 104 | 104 | role="tab" | |
| @@ -110,7 +110,7 @@ | |||
| 110 | 110 | hx-target="#tab-content" | |
| 111 | 111 | hx-swap="innerHTML" | |
| 112 | 112 | hx-indicator="#tab-spinner" | |
| 113 | - | onclick="setActiveTab(this)">Settings</button> | |
| 113 | + | data-action="onSetActiveTab">Settings</button> | |
| 114 | 114 | <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span> | |
| 115 | 115 | </div> | |
| 116 | 116 |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | <p>You can:</p> | |
| 22 | 22 | <div class="account-status-actions"> | |
| 23 | 23 | <form hx-post="/api/users/me/reactivate" hx-swap="none" | |
| 24 | - | onsubmit="setTimeout(function(){ window.location.reload(); }, 500)"> | |
| 24 | + | data-submit="reactivateReload"> | |
| 25 | 25 | <button type="submit" class="btn-primary">Reactivate Account</button> | |
| 26 | 26 | </form> | |
| 27 | 27 | <a href="/dashboard/export" class="button">Export Data</a> | |
| @@ -83,7 +83,7 @@ | |||
| 83 | 83 | hx-target="#tab-content" | |
| 84 | 84 | hx-swap="innerHTML" | |
| 85 | 85 | hx-indicator="#tab-spinner" | |
| 86 | - | onclick="setActiveTab(this)">Support</button> | |
| 86 | + | data-action="onSetActiveTab">Support</button> | |
| 87 | 87 | <span id="tab-spinner" class="htmx-indicator tab-spinner" aria-live="polite"> Loading...</span> | |
| 88 | 88 | </div> | |
| 89 | 89 | ||
| @@ -106,7 +106,7 @@ | |||
| 106 | 106 | hx-target="#tab-content" | |
| 107 | 107 | hx-swap="innerHTML" | |
| 108 | 108 | hx-indicator="#tab-spinner" | |
| 109 | - | onclick="setActiveTab(this)">Projects</button> | |
| 109 | + | data-action="onSetActiveTab">Projects</button> | |
| 110 | 110 | {% endif %}{% endif %} | |
| 111 | 111 | <button class="tab{% if let Some(su) = session_user %}{% if !su.can_create_projects %} is-selected{% endif %}{% endif %}" | |
| 112 | 112 | role="tab" | |
| @@ -118,7 +118,7 @@ | |||
| 118 | 118 | hx-target="#tab-content" | |
| 119 | 119 | hx-swap="innerHTML" | |
| 120 | 120 | hx-indicator="#tab-spinner" | |
| 121 | - | onclick="setActiveTab(this)">Payments</button> | |
| 121 | + | data-action="onSetActiveTab">Payments</button> | |
| 122 | 122 | {% if let Some(su) = session_user %}{% if su.can_create_projects %} | |
| 123 | 123 | <button class="tab" | |
| 124 | 124 | role="tab" | |
| @@ -130,7 +130,7 @@ | |||
| 130 | 130 | hx-target="#tab-content" | |
| 131 | 131 | hx-swap="innerHTML" | |
| 132 | 132 | hx-indicator="#tab-spinner" | |
| 133 | - | onclick="setActiveTab(this)">Analytics</button> | |
| 133 | + | data-action="onSetActiveTab">Analytics</button> | |
| 134 | 134 | {% endif %}{% endif %} | |
| 135 | 135 | <button class="tab" | |
| 136 | 136 | role="tab" | |
| @@ -142,7 +142,7 @@ | |||
| 142 | 142 | hx-target="#tab-content" | |
| 143 | 143 | hx-swap="innerHTML" | |
| 144 | 144 | hx-indicator="#tab-spinner" | |
| 145 | - | onclick="setActiveTab(this)">Settings</button> | |
| 145 | + | data-action="onSetActiveTab">Settings</button> | |
| 146 | 146 | <button class="tab" | |
| 147 | 147 | role="tab" | |
| 148 | 148 | aria-selected="false" | |
| @@ -153,7 +153,7 @@ | |||
| 153 | 153 | hx-target="#tab-content" | |
| 154 | 154 | hx-swap="innerHTML" | |
| 155 | 155 | hx-indicator="#tab-spinner" | |
| 156 | - | onclick="setActiveTab(this)">Support</button> | |
| 156 | + | data-action="onSetActiveTab">Support</button> | |
| 157 | 157 | <span id="tab-spinner" class="htmx-indicator tab-spinner" aria-live="polite"> Loading...</span> | |
| 158 | 158 | </div> | |
| 159 | 159 |
| @@ -54,8 +54,8 @@ body { | |||
| 54 | 54 | </div> | |
| 55 | 55 | <div class="creator">by {{ creator_display_name }}</div> | |
| 56 | 56 | <div class="controls"> | |
| 57 | - | <button class="play-btn" id="play" onclick="togglePlay()">▶</button> | |
| 58 | - | <div class="progress-bar" id="progress-bar" onclick="seek(event)"> | |
| 57 | + | <button class="play-btn" id="play">▶</button> | |
| 58 | + | <div class="progress-bar" id="progress-bar"> | |
| 59 | 59 | <div class="progress-fill" id="progress"></div> | |
| 60 | 60 | </div> | |
| 61 | 61 | <span class="time" id="time">0:00</span> |
| @@ -38,7 +38,7 @@ | |||
| 38 | 38 | <input type="number" id="pwyw_amount" value="{{ suggested_price }}" min="{{ pwyw_min_dollars }}" step="0.01"> | |
| 39 | 39 | </div> | |
| 40 | 40 | {% endif %} | |
| 41 | - | <button class="buy-btn" id="buy-btn" onclick="buy()">Buy Now — {{ item.price }}</button> | |
| 41 | + | <button class="buy-btn" id="buy-btn" data-action="buy">Buy Now — {{ item.price }}</button> | |
| 42 | 42 | {% endif %} | |
| 43 | 43 | ||
| 44 | 44 | <div class="note"> |
| @@ -124,7 +124,7 @@ | |||
| 124 | 124 | <p>This creator hasn't set up payments yet. You can keep these items in your cart for later, or remove them.</p> | |
| 125 | 125 | <button type="button" class="btn-secondary small" | |
| 126 | 126 | data-seller-name="{{ group.seller_username }}" | |
| 127 | - | onclick="removeCartGroup(this)">Remove all from {{ group.seller_username }}</button> | |
| 127 | + | data-action="onRemoveCartGroup">Remove all from {{ group.seller_username }}</button> | |
| 128 | 128 | </div> | |
| 129 | 129 | {% endif %} | |
| 130 | 130 | </div> | |
| @@ -158,7 +158,7 @@ | |||
| 158 | 158 | <td> | |
| 159 | 159 | <button class="btn-primary small cart-row-btn" | |
| 160 | 160 | hx-post="/api/cart/{{ item.item_id }}" | |
| 161 | - | hx-on::after-request="if(event.detail.successful) this.closest('tr').classList.add('is-faded'); this.textContent='Added'">Add to Cart</button> | |
| 161 | + | data-hx-always="fade-row-added">Add to Cart</button> | |
| 162 | 162 | </td> | |
| 163 | 163 | </tr> | |
| 164 | 164 | {% endfor %} |
| @@ -85,9 +85,9 @@ | |||
| 85 | 85 | <span class="count" aria-label="{{ tg.count }} items">{{ tg.count }}</span> | |
| 86 | 86 | {% if session_user.is_some() && !tg.id.is_empty() %} | |
| 87 | 87 | {% if tg.following %} | |
| 88 | - | <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" onclick="event.stopPropagation();">Following</button> | |
| 88 | + | <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" data-action="noop" data-stop>Following</button> | |
| 89 | 89 | {% else %} | |
| 90 | - | <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" onclick="event.stopPropagation();">Follow</button> | |
| 90 | + | <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" data-action="noop" data-stop>Follow</button> | |
| 91 | 91 | {% endif %} | |
| 92 | 92 | {% endif %} | |
| 93 | 93 | </span> | |
| @@ -151,7 +151,7 @@ | |||
| 151 | 151 | {% endif %} | |
| 152 | 152 | </aside> | |
| 153 | 153 | ||
| 154 | - | <button type="button" class="discover-filter-toggle" onclick="document.querySelector('.discover-sidebar').classList.toggle('show'); this.classList.toggle('active');" aria-label="Toggle filters"> | |
| 154 | + | <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters"> | |
| 155 | 155 | Filters{% if active_filter_count > 0 %} <span class="filter-count">{{ active_filter_count }}</span>{% endif %} | |
| 156 | 156 | </button> | |
| 157 | 157 |
| @@ -206,7 +206,7 @@ | |||
| 206 | 206 | <div class="tier-section"> | |
| 207 | 207 | <h2 class="section-label">Stay in the loop</h2> | |
| 208 | 208 | <p class="landing-prose mb-4">Get notified when something ships.</p> | |
| 209 | - | <form class="notify-form" id="notify-form" onsubmit="return submitNotify(event)"> | |
| 209 | + | <form class="notify-form" id="notify-form" data-submit="onNotifySubmit"> | |
| 210 | 210 | <input type="email" name="email" placeholder="you@example.com" required class="notify-input" aria-label="Email address"> | |
| 211 | 211 | <button type="submit" class="btn-primary">Notify Me</button> | |
| 212 | 212 | </form> |
| @@ -196,17 +196,17 @@ | |||
| 196 | 196 | {% if !is_owner %} | |
| 197 | 197 | <div class="action-row"> | |
| 198 | 198 | <button class="btn-secondary full-width-btn" id="cart-btn" | |
| 199 | - | onclick="toggleCart('{{ item.id }}')">{% if in_cart %}In Cart{% else %}Add to Cart{% endif %}</button> | |
| 199 | + | data-action="toggleCart" data-arg="{{ item.id }}">{% if in_cart %}In Cart{% else %}Add to Cart{% endif %}</button> | |
| 200 | 200 | </div> | |
| 201 | 201 | <div class="action-row-tight"> | |
| 202 | 202 | <button class="btn-secondary full-width-btn" id="wishlist-btn" | |
| 203 | - | onclick="toggleWishlist('{{ item.id }}')">{% if is_wishlisted %}Wishlisted{% else %}Add to Wishlist{% endif %}</button> | |
| 203 | + | data-action="toggleWishlist" data-arg="{{ item.id }}">{% if is_wishlisted %}Wishlisted{% else %}Add to Wishlist{% endif %}</button> | |
| 204 | 204 | </div> | |
| 205 | 205 | {% endif %} | |
| 206 | 206 | <div class="action-row-tight collection-picker-anchor"> | |
| 207 | 207 | <button class="btn-secondary full-width-btn{% if collection_count > 0 %} saved{% endif %}" | |
| 208 | 208 | data-collection-trigger data-item-id="{{ item.id }}" data-collection-label | |
| 209 | - | onclick="openCollectionPicker('{{ item.id }}', this)">{% if collection_count > 0 %}Saved ({{ collection_count }}){% else %}Save to collection{% endif %}</button> | |
| 209 | + | data-action="onOpenCollectionPicker" data-arg="{{ item.id }}">{% if collection_count > 0 %}Saved ({{ collection_count }}){% else %}Save to collection{% endif %}</button> | |
| 210 | 210 | </div> | |
| 211 | 211 | {% endif %} | |
| 212 | 212 | </div> | |
| @@ -231,7 +231,7 @@ | |||
| 231 | 231 | {% for section in sections %} | |
| 232 | 232 | <button class="section-tab{% if loop.first %} is-selected{% endif %}" | |
| 233 | 233 | data-tab="section-{{ section.slug }}" | |
| 234 | - | onclick="switchSectionTab(this, 'section-{{ section.slug }}')">{{ section.title }}</button> | |
| 234 | + | data-action="onSwitchSectionTab" data-arg="section-{{ section.slug }}">{{ section.title }}</button> | |
| 235 | 235 | {% endfor %} | |
| 236 | 236 | </div> | |
| 237 | 237 | {% for section in sections %} | |
| @@ -312,7 +312,7 @@ | |||
| 312 | 312 | {% endif %} | |
| 313 | 313 | <a href="/i/{{ item.id }}" data-copy-link>Copy link</a> · | |
| 314 | 314 | {% if session_user.is_some() %} | |
| 315 | - | <a href="javascript:void(0)" onclick="document.getElementById('report-modal').classList.remove('hidden')">Report this item</a> · | |
| 315 | + | <a href="#" data-prevent data-action="show" data-target="report-modal">Report this item</a> · | |
| 316 | 316 | {% else %} | |
| 317 | 317 | <a href="/login">Report this item</a> · | |
| 318 | 318 | {% endif %} |