max / makenotwork
9 files changed,
+238 insertions,
-411 deletions
| @@ -1,185 +0,0 @@ | |||
| 1 | - | // Content insertion management: upload flow, placement form, duration detection. | |
| 2 | - | (function() { | |
| 3 | - | 'use strict'; | |
| 4 | - | ||
| 5 | - | // Namespace | |
| 6 | - | window.MNW = window.MNW || {}; | |
| 7 | - | ||
| 8 | - | function startUpload() { | |
| 9 | - | document.getElementById('insertion-file-input').click(); | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | // Map a file extension to a MIME type when the browser doesn't supply one | |
| 13 | - | // (some OSes leave file.type empty). Must stay in sync with the server's | |
| 14 | - | // ALLOWED_INSERTION_TYPES allow-list. | |
| 15 | - | var EXT_MIME = { | |
| 16 | - | mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', | |
| 17 | - | flac: 'audio/flac', aac: 'audio/aac', | |
| 18 | - | mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' | |
| 19 | - | }; | |
| 20 | - | ||
| 21 | - | function resolveMime(file) { | |
| 22 | - | if (file.type) return file.type; | |
| 23 | - | var ext = (file.name.split('.').pop() || '').toLowerCase(); | |
| 24 | - | return EXT_MIME[ext] || 'application/octet-stream'; | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | async function handleFileSelected(input) { | |
| 28 | - | const file = input.files[0]; | |
| 29 | - | if (!file) return; | |
| 30 | - | ||
| 31 | - | var mime = resolveMime(file); | |
| 32 | - | var isVideo = mime.indexOf('video/') === 0; | |
| 33 | - | ||
| 34 | - | // Detect duration via a temporary media element matching the clip kind | |
| 35 | - | var durationMs; | |
| 36 | - | try { | |
| 37 | - | durationMs = await detectDuration(file, isVideo); | |
| 38 | - | } catch (e) { | |
| 39 | - | showToast('Could not detect clip duration. Please try a different file.'); | |
| 40 | - | input.value = ''; | |
| 41 | - | return; | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | try { | |
| 45 | - | // Step 1: Get presigned URL | |
| 46 | - | var presignRes = await fetch('/api/users/me/insertions/presign', { | |
| 47 | - | method: 'POST', | |
| 48 | - | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 49 | - | body: JSON.stringify({ | |
| 50 | - | file_name: file.name, | |
| 51 | - | content_type: mime | |
| 52 | - | }) | |
| 53 | - | }); | |
| 54 | - | ||
| 55 | - | if (!presignRes.ok) { | |
| 56 | - | var err = await presignRes.json().catch(function() { return {}; }); | |
| 57 | - | throw new Error(err.error || 'Failed to get upload URL'); | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | var presignData = await presignRes.json(); | |
| 61 | - | ||
| 62 | - | // Step 2: Upload to S3 | |
| 63 | - | var uploadRes = await fetch(presignData.upload_url, { | |
| 64 | - | method: 'PUT', | |
| 65 | - | headers: { 'Content-Type': mime }, | |
| 66 | - | body: file | |
| 67 | - | }); | |
| 68 | - | ||
| 69 | - | if (!uploadRes.ok) { | |
| 70 | - | throw new Error('Upload failed'); | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // Step 3: Confirm upload | |
| 74 | - | var title = file.name.replace(/\.[^.]+$/, ''); | |
| 75 | - | var confirmRes = await fetch('/api/users/me/insertions/confirm', { | |
| 76 | - | method: 'POST', | |
| 77 | - | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 78 | - | body: JSON.stringify({ | |
| 79 | - | s3_key: presignData.s3_key, | |
| 80 | - | title: title, | |
| 81 | - | duration_ms: durationMs, | |
| 82 | - | file_size: file.size, | |
| 83 | - | mime_type: mime | |
| 84 | - | }) | |
| 85 | - | }); | |
| 86 | - | ||
| 87 | - | if (!confirmRes.ok) { | |
| 88 | - | var confirmErr = await confirmRes.json().catch(function() { return {}; }); | |
| 89 | - | throw new Error(confirmErr.error || 'Failed to confirm upload'); | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // Refresh the insertion list via HTMX | |
| 93 | - | htmx.ajax('GET', '/api/users/me/insertions', { target: '#insertion-library', swap: 'outerHTML' }); | |
| 94 | - | } catch (e) { | |
| 95 | - | showToast('Upload error: ' + e.message); | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | input.value = ''; | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | function detectDuration(file, isVideo) { | |
| 102 | - | return new Promise(function(resolve, reject) { | |
| 103 | - | var el = document.createElement(isVideo ? 'video' : 'audio'); | |
| 104 | - | el.preload = 'metadata'; | |
| 105 | - | ||
| 106 | - | el.addEventListener('loadedmetadata', function() { | |
| 107 | - | var ms = Math.round(el.duration * 1000); | |
| 108 | - | URL.revokeObjectURL(el.src); | |
| 109 | - | resolve(ms); | |
| 110 | - | }); | |
| 111 | - | ||
| 112 | - | el.addEventListener('error', function() { | |
| 113 | - | URL.revokeObjectURL(el.src); | |
| 114 | - | reject(new Error('Cannot read media metadata')); | |
| 115 | - | }); | |
| 116 | - | ||
| 117 | - | el.src = URL.createObjectURL(file); | |
| 118 | - | }); | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | function rename(id, currentTitle) { | |
| 122 | - | var newTitle = prompt('New title:', currentTitle); | |
| 123 | - | if (!newTitle || newTitle === currentTitle) return; | |
| 124 | - | ||
| 125 | - | fetch('/api/insertions/' + id, { | |
| 126 | - | method: 'PUT', | |
| 127 | - | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 128 | - | body: JSON.stringify({ title: newTitle }) | |
| 129 | - | }).then(function() { | |
| 130 | - | htmx.ajax('GET', '/api/users/me/insertions', { target: '#insertion-library', swap: 'outerHTML' }); | |
| 131 | - | }); | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | function toggleOffsetInput(position) { | |
| 135 | - | var group = document.getElementById('offset-input-group'); | |
| 136 | - | if (group) { | |
| 137 | - | group.classList.toggle('hidden', position !== 'mid_roll'); | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | function addPlacement(itemId) { | |
| 142 | - | var insertionId = document.getElementById('placement-insertion-id').value; | |
| 143 | - | var position = document.getElementById('placement-position').value; | |
| 144 | - | var offsetInput = document.getElementById('placement-offset'); | |
| 145 | - | var offsetMs = null; | |
| 146 | - | ||
| 147 | - | if (position === 'mid_roll') { | |
| 148 | - | var offsetSecs = parseInt(offsetInput.value, 10); | |
| 149 | - | if (isNaN(offsetSecs) || offsetSecs < 0) { | |
| 150 | - | showToast('Please enter a valid offset in seconds for mid-roll.'); | |
| 151 | - | return; | |
| 152 | - | } | |
| 153 | - | offsetMs = offsetSecs * 1000; | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | fetch('/api/items/' + itemId + '/insertions', { | |
| 157 | - | method: 'POST', | |
| 158 | - | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 159 | - | body: JSON.stringify({ | |
| 160 | - | insertion_id: insertionId, | |
| 161 | - | position: position, | |
| 162 | - | offset_ms: offsetMs, | |
| 163 | - | sort_order: 0 | |
| 164 | - | }) | |
| 165 | - | }).then(function(res) { | |
| 166 | - | if (res.ok) { | |
| 167 | - | htmx.ajax('GET', '/api/items/' + itemId + '/insertions', { target: '#placement-list', swap: 'outerHTML' }); | |
| 168 | - | } else { | |
| 169 | - | res.json().then(function(data) { | |
| 170 | - | showToast(data.error || 'Failed to add clip'); | |
| 171 | - | }).catch(function() { | |
| 172 | - | showToast('Failed to add clip'); | |
| 173 | - | }); | |
| 174 | - | } | |
| 175 | - | }); | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | MNW.insertions = { | |
| 179 | - | startUpload: startUpload, | |
| 180 | - | handleFileSelected: handleFileSelected, | |
| 181 | - | rename: rename, | |
| 182 | - | toggleOffsetInput: toggleOffsetInput, | |
| 183 | - | addPlacement: addPlacement | |
| 184 | - | }; | |
| 185 | - | })(); |
| @@ -3,10 +3,9 @@ | |||
| 3 | 3 | (function() { | |
| 4 | 4 | 'use strict'; | |
| 5 | 5 | ||
| 6 | - | function csrfHeaders() { | |
| 7 | - | var token = document.querySelector('meta[name="csrf-token"]'); | |
| 8 | - | return token ? { 'X-CSRF-Token': token.content } : {}; | |
| 9 | - | } | |
| 6 | + | // csrfHeaders() is the global from mnw.js (loaded first in base.html); it | |
| 7 | + | // reads the csrf-token meta live on each call, which matters because the | |
| 8 | + | // token rotates mid-session. Don't shadow it with a local copy. | |
| 10 | 9 | ||
| 11 | 10 | function escapeHtml(s) { | |
| 12 | 11 | var d = document.createElement('div'); |
| @@ -11386,3 +11386,37 @@ button.saved { border-color: var(--action); color: var(--action); } | |||
| 11386 | 11386 | @media (max-width: 640px) { | |
| 11387 | 11387 | .carousel-nav { width: 2rem; height: 2rem; font-size: 1.2rem; } | |
| 11388 | 11388 | } | |
| 11389 | + | ||
| 11390 | + | /* --------------------------------------------------------------------------- | |
| 11391 | + | Custom page editor (dashboard/custom_page_editor.html) | |
| 11392 | + | Moved out of an inline <style> block per the extract-shared-CSS rule. | |
| 11393 | + | --------------------------------------------------------------------------- */ | |
| 11394 | + | .cp-editor { max-width: 1200px; margin: 0 auto; padding: 1rem; } | |
| 11395 | + | .cp-editor-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; flex-wrap: wrap; } | |
| 11396 | + | .cp-editor-actions { display: flex; gap: 1rem; } | |
| 11397 | + | .cp-intro { color: var(--muted, #666); margin: .25rem 0 1rem; } | |
| 11398 | + | .cp-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; } | |
| 11399 | + | .cp-editor-form { display: flex; flex-direction: column; gap: .35rem; } | |
| 11400 | + | .cp-code { width: 100%; min-height: 220px; font-family: var(--font-mono, ui-monospace, monospace); font-size: .85rem; line-height: 1.4; resize: vertical; } | |
| 11401 | + | .cp-editor-buttons { display: flex; align-items: center; gap: 1rem; margin-top: .5rem; } | |
| 11402 | + | .cp-preview-pane { border: 1px solid var(--detail, #ccc); border-radius: 4px; overflow: hidden; } | |
| 11403 | + | .cp-preview-bar { font-size: .8rem; padding: .3rem .6rem; background: var(--light-background, #f2f2f2); border-bottom: 1px solid var(--detail, #ccc); } | |
| 11404 | + | .cp-preview { width: 100%; height: 520px; border: 0; background: #fff; display: block; } | |
| 11405 | + | .cp-blocked { margin-top: 1.25rem; } | |
| 11406 | + | .cp-blocked-title { font-weight: 600; margin-bottom: .5rem; } | |
| 11407 | + | .cp-blocked-empty { color: var(--muted, #666); } | |
| 11408 | + | .cp-blocked-list { list-style: none; padding: 0; margin: 0; } | |
| 11409 | + | .cp-blocked-list li { padding: .4rem 0; border-bottom: 1px solid var(--detail, #eee); font-size: .85rem; display: flex; gap: .5rem; flex-wrap: wrap; align-items: baseline; } | |
| 11410 | + | .cp-blocked-kind { font-weight: 600; } | |
| 11411 | + | .cp-blocked-loc { color: var(--muted, #666); } | |
| 11412 | + | .cp-blocked-val { background: var(--light-background, #f2f2f2); padding: 0 .25rem; } | |
| 11413 | + | .cp-status.cp-ok { color: var(--success, #2a7); } | |
| 11414 | + | .cp-status.cp-err { color: var(--warning, #b00); } | |
| 11415 | + | .cp-reset { margin-top: 1.5rem; } | |
| 11416 | + | .cp-locked-banner { margin: .5rem 0 1rem; padding: .75rem 1rem; border: 1px solid var(--warning, #b00); border-radius: 4px; background: var(--light-background, #fbeaea); } | |
| 11417 | + | @media (max-width: 800px) { .cp-editor-grid { grid-template-columns: 1fr; } } | |
| 11418 | + | ||
| 11419 | + | /* Layout-shift fixes (audit Run 17 Frontend): reserve space for cover images | |
| 11420 | + | whose class didn't already set an aspect-ratio / fixed size. */ | |
| 11421 | + | .library-locked-cover { aspect-ratio: 1 / 1; object-fit: cover; } | |
| 11422 | + | .proj-image-preview img { width: 120px; height: 120px; object-fit: cover; } |
| @@ -1,20 +0,0 @@ | |||
| 1 | - | // This tab can arrive via an HTMX swap (DOMContentLoaded won't fire), so init | |
| 2 | - | // immediately, lazy-loading gallery.js if it isn't present yet. | |
| 3 | - | (function() { | |
| 4 | - | var __cfg = document.getElementById('tab-project-settings-cfg'); | |
| 5 | - | var projectId = __cfg ? __cfg.dataset.projectId : ''; | |
| 6 | - | function go() { | |
| 7 | - | initGalleryManager({ | |
| 8 | - | targetType: 'project', | |
| 9 | - | targetId: projectId, | |
| 10 | - | listId: 'project-gallery-list', | |
| 11 | - | inputId: 'project-gallery-input', | |
| 12 | - | statusId: 'project-gallery-status' | |
| 13 | - | }); | |
| 14 | - | } | |
| 15 | - | if (window.initGalleryManager) { go(); return; } | |
| 16 | - | var s = document.createElement('script'); | |
| 17 | - | s.src = '/static/gallery.js'; | |
| 18 | - | s.onload = go; | |
| 19 | - | document.head.appendChild(s); | |
| 20 | - | })(); |
| @@ -1,169 +0,0 @@ | |||
| 1 | - | (function() { | |
| 2 | - | // Category suggestion dropdown | |
| 3 | - | var input = document.getElementById('settings-category'); | |
| 4 | - | var dropdown = document.getElementById('settings-category-dropdown'); | |
| 5 | - | if (!input || !dropdown) return; | |
| 6 | - | var debounce; | |
| 7 | - | ||
| 8 | - | function showDropdown(items, query) { | |
| 9 | - | dropdown.innerHTML = ''; | |
| 10 | - | items.forEach(function(c) { | |
| 11 | - | var div = document.createElement('div'); | |
| 12 | - | div.className = 'suggestion-item'; | |
| 13 | - | div.textContent = c.name; | |
| 14 | - | div.addEventListener('mousedown', function(e) { | |
| 15 | - | e.preventDefault(); | |
| 16 | - | input.value = c.name; | |
| 17 | - | dropdown.classList.remove('open'); | |
| 18 | - | }); | |
| 19 | - | dropdown.appendChild(div); | |
| 20 | - | }); | |
| 21 | - | var q = query.trim(); | |
| 22 | - | if (q.length > 0 && !items.some(function(c) { return c.name.toLowerCase() === q.toLowerCase(); })) { | |
| 23 | - | var create = document.createElement('div'); | |
| 24 | - | create.className = 'suggestion-item suggestion-create'; | |
| 25 | - | create.textContent = 'Create: ' + q; | |
| 26 | - | create.addEventListener('mousedown', function(e) { | |
| 27 | - | e.preventDefault(); | |
| 28 | - | input.value = q; | |
| 29 | - | dropdown.classList.remove('open'); | |
| 30 | - | }); | |
| 31 | - | dropdown.appendChild(create); | |
| 32 | - | } | |
| 33 | - | if (dropdown.children.length > 0) { | |
| 34 | - | dropdown.classList.add('open'); | |
| 35 | - | } else { | |
| 36 | - | dropdown.classList.remove('open'); | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | input.addEventListener('input', function() { | |
| 41 | - | clearTimeout(debounce); | |
| 42 | - | var q = input.value.trim(); | |
| 43 | - | if (q.length < 1) { dropdown.classList.remove('open'); return; } | |
| 44 | - | debounce = setTimeout(function() { | |
| 45 | - | fetch('/api/categories/search?q=' + encodeURIComponent(q)) | |
| 46 | - | .then(function(r) { return r.json(); }) | |
| 47 | - | .then(function(cats) { showDropdown(cats, q); }) | |
| 48 | - | .catch(function() {}); | |
| 49 | - | }, 200); | |
| 50 | - | }); | |
| 51 | - | ||
| 52 | - | input.addEventListener('focus', function() { | |
| 53 | - | if (dropdown.children.length > 0) dropdown.classList.add('open'); | |
| 54 | - | }); | |
| 55 | - | ||
| 56 | - | input.addEventListener('blur', function() { | |
| 57 | - | setTimeout(function() { dropdown.classList.remove('open'); }, 150); | |
| 58 | - | }); | |
| 59 | - | })(); | |
| 60 | - | ||
| 61 | - | // Project info save | |
| 62 | - | function saveProjectInfo(e, projectId) { | |
| 63 | - | e.preventDefault(); | |
| 64 | - | var status = document.getElementById('project-save-status'); | |
| 65 | - | var data = { | |
| 66 | - | title: document.getElementById('project-name').value, | |
| 67 | - | description: document.getElementById('project-description').value, | |
| 68 | - | category: document.getElementById('settings-category').value | |
| 69 | - | }; | |
| 70 | - | fetch('/api/projects/' + projectId, { | |
| 71 | - | method: 'PUT', | |
| 72 | - | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 73 | - | body: JSON.stringify(data) | |
| 74 | - | }).then(function(r) { | |
| 75 | - | if (r.ok) { | |
| 76 | - | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; } | |
| 77 | - | setTimeout(function() { if (status) status.textContent = ''; }, 2000); | |
| 78 | - | } else { | |
| 79 | - | r.text().then(function(body) { | |
| 80 | - | var msg = 'Save failed'; | |
| 81 | - | try { msg = JSON.parse(body).error || msg; } catch(_) {} | |
| 82 | - | if (status) { status.textContent = msg; status.style.color = 'var(--danger)'; } | |
| 83 | - | }); | |
| 84 | - | } | |
| 85 | - | }).catch(function() { | |
| 86 | - | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--danger)'; } | |
| 87 | - | }); | |
| 88 | - | return false; | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Monetization save | |
| 92 | - | function updateSettingsPricingUI() { | |
| 93 | - | var model = document.getElementById('settings-pricing-model').value; | |
| 94 | - | var sections = [ | |
| 95 | - | { id: 'settings-buy-once-fields', active: model === 'buy_once' }, | |
| 96 | - | { id: 'settings-pwyw-fields', active: model === 'pwyw' }, | |
| 97 | - | { id: 'settings-subscription-note', active: model === 'subscription' } | |
| 98 | - | ]; | |
| 99 | - | sections.forEach(function(s) { | |
| 100 | - | var el = document.getElementById(s.id); | |
| 101 | - | if (el) el.classList.toggle('hidden', !s.active); | |
| 102 | - | }); | |
| 103 | - | } | |
| 104 | - | updateSettingsPricingUI(); | |
| 105 | - | ||
| 106 | - | function saveProjectPricing(e, projectId) { | |
| 107 | - | e.preventDefault(); | |
| 108 | - | var status = document.getElementById('project-pricing-status'); | |
| 109 | - | var model = document.getElementById('settings-pricing-model').value; | |
| 110 | - | var data = { pricing_model: model }; | |
| 111 | - | ||
| 112 | - | if (model === 'buy_once') { | |
| 113 | - | var p = parseFloat(document.getElementById('settings-price-dollars').value); | |
| 114 | - | if (!(p >= 0.50)) { | |
| 115 | - | if (status) { status.textContent = 'Price must be at least $0.50'; status.style.color = 'var(--danger)'; } | |
| 116 | - | return false; | |
| 117 | - | } | |
| 118 | - | data.price_dollars = p; | |
| 119 | - | } else if (model === 'pwyw') { | |
| 120 | - | var v = document.getElementById('settings-pwyw-min-dollars').value; | |
| 121 | - | data.pwyw_min_dollars = v === '' ? 0 : parseFloat(v); | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | fetch('/api/projects/' + projectId, { | |
| 125 | - | method: 'PUT', | |
| 126 | - | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 127 | - | body: JSON.stringify(data) | |
| 128 | - | }).then(function(r) { | |
| 129 | - | if (r.ok) { | |
| 130 | - | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; } | |
| 131 | - | setTimeout(function() { if (status) status.textContent = ''; }, 2000); | |
| 132 | - | } else { | |
| 133 | - | r.text().then(function(body) { | |
| 134 | - | var msg = 'Save failed'; | |
| 135 | - | try { msg = JSON.parse(body).error || msg; } catch(_) {} | |
| 136 | - | if (status) { status.textContent = msg; status.style.color = 'var(--danger)'; } | |
| 137 | - | }); | |
| 138 | - | } | |
| 139 | - | }).catch(function() { | |
| 140 | - | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--danger)'; } | |
| 141 | - | }); | |
| 142 | - | return false; | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // Features update | |
| 146 | - | function updateFeatures(projectId) { | |
| 147 | - | var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]'); | |
| 148 | - | var selected = []; | |
| 149 | - | checkboxes.forEach(function(cb) { | |
| 150 | - | if (cb.checked) selected.push(cb.value); | |
| 151 | - | }); | |
| 152 | - | var status = document.getElementById('features-save-status'); | |
| 153 | - | ||
| 154 | - | fetch('/api/projects/' + projectId, { | |
| 155 | - | method: 'PUT', | |
| 156 | - | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 157 | - | body: JSON.stringify({features: selected}) | |
| 158 | - | }).then(function(r) { | |
| 159 | - | if (r.ok) { | |
| 160 | - | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; } | |
| 161 | - | // Reload the page so the tab bar reflects new features | |
| 162 | - | setTimeout(function() { window.location.reload(); }, 300); | |
| 163 | - | } else { | |
| 164 | - | if (status) { status.textContent = 'Save failed'; status.style.color = 'var(--danger)'; } | |
| 165 | - | } | |
| 166 | - | }).catch(function() { | |
| 167 | - | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--danger)'; } | |
| 168 | - | }); | |
| 169 | - | } |
| @@ -43,3 +43,200 @@ | |||
| 43 | 43 | .catch(function(err) { status.textContent = err.message; }); | |
| 44 | 44 | }); | |
| 45 | 45 | })(); | |
| 46 | + | ||
| 47 | + | // --------------------------------------------------------------------------- | |
| 48 | + | // Gallery manager (was tab-project-settings-2.js) | |
| 49 | + | // --------------------------------------------------------------------------- | |
| 50 | + | // This tab can arrive via an HTMX swap (DOMContentLoaded won't fire), so init | |
| 51 | + | // immediately, lazy-loading gallery.js if it isn't present yet. | |
| 52 | + | (function() { | |
| 53 | + | var __cfg = document.getElementById('tab-project-settings-cfg'); | |
| 54 | + | var projectId = __cfg ? __cfg.dataset.projectId : ''; | |
| 55 | + | function go() { | |
| 56 | + | initGalleryManager({ | |
| 57 | + | targetType: 'project', | |
| 58 | + | targetId: projectId, | |
| 59 | + | listId: 'project-gallery-list', | |
| 60 | + | inputId: 'project-gallery-input', | |
| 61 | + | statusId: 'project-gallery-status' | |
| 62 | + | }); | |
| 63 | + | } | |
| 64 | + | if (window.initGalleryManager) { go(); return; } | |
| 65 | + | var s = document.createElement('script'); | |
| 66 | + | s.src = '/static/gallery.js'; | |
| 67 | + | s.onload = go; | |
| 68 | + | document.head.appendChild(s); | |
| 69 | + | })(); | |
| 70 | + | ||
| 71 | + | // --------------------------------------------------------------------------- | |
| 72 | + | // Category suggestion dropdown (was tab-project-settings-3.js) | |
| 73 | + | // --------------------------------------------------------------------------- | |
| 74 | + | (function() { | |
| 75 | + | // Category suggestion dropdown | |
| 76 | + | var input = document.getElementById('settings-category'); | |
| 77 | + | var dropdown = document.getElementById('settings-category-dropdown'); | |
| 78 | + | if (!input || !dropdown) return; | |
| 79 | + | var debounce; | |
| 80 | + | ||
| 81 | + | function showDropdown(items, query) { | |
| 82 | + | dropdown.innerHTML = ''; | |
| 83 | + | items.forEach(function(c) { | |
| 84 | + | var div = document.createElement('div'); | |
| 85 | + | div.className = 'suggestion-item'; | |
| 86 | + | div.textContent = c.name; | |
| 87 | + | div.addEventListener('mousedown', function(e) { | |
| 88 | + | e.preventDefault(); | |
| 89 | + | input.value = c.name; | |
| 90 | + | dropdown.classList.remove('open'); | |
| 91 | + | }); | |
| 92 | + | dropdown.appendChild(div); | |
| 93 | + | }); | |
| 94 | + | var q = query.trim(); | |
| 95 | + | if (q.length > 0 && !items.some(function(c) { return c.name.toLowerCase() === q.toLowerCase(); })) { | |
| 96 | + | var create = document.createElement('div'); | |
| 97 | + | create.className = 'suggestion-item suggestion-create'; | |
| 98 | + | create.textContent = 'Create: ' + q; | |
| 99 | + | create.addEventListener('mousedown', function(e) { | |
| 100 | + | e.preventDefault(); | |
| 101 | + | input.value = q; | |
| 102 | + | dropdown.classList.remove('open'); | |
| 103 | + | }); | |
| 104 | + | dropdown.appendChild(create); | |
| 105 | + | } | |
| 106 | + | if (dropdown.children.length > 0) { | |
| 107 | + | dropdown.classList.add('open'); | |
| 108 | + | } else { | |
| 109 | + | dropdown.classList.remove('open'); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | input.addEventListener('input', function() { | |
| 114 | + | clearTimeout(debounce); | |
| 115 | + | var q = input.value.trim(); | |
| 116 | + | if (q.length < 1) { dropdown.classList.remove('open'); return; } | |
| 117 | + | debounce = setTimeout(function() { | |
| 118 | + | fetch('/api/categories/search?q=' + encodeURIComponent(q)) | |
| 119 | + | .then(function(r) { return r.json(); }) | |
| 120 | + | .then(function(cats) { showDropdown(cats, q); }) | |
| 121 | + | .catch(function() {}); | |
| 122 | + | }, 200); | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | input.addEventListener('focus', function() { | |
| 126 | + | if (dropdown.children.length > 0) dropdown.classList.add('open'); | |
| 127 | + | }); | |
| 128 | + | ||
| 129 | + | input.addEventListener('blur', function() { | |
| 130 | + | setTimeout(function() { dropdown.classList.remove('open'); }, 150); | |
| 131 | + | }); | |
| 132 | + | })(); | |
| 133 | + | ||
| 134 | + | // Project info save | |
| 135 | + | function saveProjectInfo(e, projectId) { | |
| 136 | + | e.preventDefault(); | |
| 137 | + | var status = document.getElementById('project-save-status'); | |
| 138 | + | var data = { | |
| 139 | + | title: document.getElementById('project-name').value, | |
| 140 | + | description: document.getElementById('project-description').value, | |
| 141 | + | category: document.getElementById('settings-category').value | |
| 142 | + | }; | |
| 143 | + | fetch('/api/projects/' + projectId, { | |
| 144 | + | method: 'PUT', | |
| 145 | + | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 146 | + | body: JSON.stringify(data) | |
| 147 | + | }).then(function(r) { | |
| 148 | + | if (r.ok) { | |
| 149 | + | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; } | |
| 150 | + | setTimeout(function() { if (status) status.textContent = ''; }, 2000); | |
| 151 | + | } else { | |
| 152 | + | r.text().then(function(body) { | |
| 153 | + | var msg = 'Save failed'; | |
| 154 | + | try { msg = JSON.parse(body).error || msg; } catch(_) {} | |
| 155 | + | if (status) { status.textContent = msg; status.style.color = 'var(--danger)'; } | |
| 156 | + | }); | |
| 157 | + | } | |
| 158 | + | }).catch(function() { | |
| 159 | + | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--danger)'; } | |
| 160 | + | }); | |
| 161 | + | return false; | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Monetization save | |
| 165 | + | function updateSettingsPricingUI() { | |
| 166 | + | var model = document.getElementById('settings-pricing-model').value; | |
| 167 | + | var sections = [ | |
| 168 | + | { id: 'settings-buy-once-fields', active: model === 'buy_once' }, | |
| 169 | + | { id: 'settings-pwyw-fields', active: model === 'pwyw' }, | |
| 170 | + | { id: 'settings-subscription-note', active: model === 'subscription' } | |
| 171 | + | ]; | |
| 172 | + | sections.forEach(function(s) { | |
| 173 | + | var el = document.getElementById(s.id); | |
| 174 | + | if (el) el.classList.toggle('hidden', !s.active); | |
| 175 | + | }); | |
| 176 | + | } | |
| 177 | + | updateSettingsPricingUI(); | |
| 178 | + | ||
| 179 | + | function saveProjectPricing(e, projectId) { | |
| 180 | + | e.preventDefault(); | |
| 181 | + | var status = document.getElementById('project-pricing-status'); | |
| 182 | + | var model = document.getElementById('settings-pricing-model').value; | |
| 183 | + | var data = { pricing_model: model }; | |
| 184 | + | ||
| 185 | + | if (model === 'buy_once') { | |
| 186 | + | var p = parseFloat(document.getElementById('settings-price-dollars').value); | |
| 187 | + | if (!(p >= 0.50)) { | |
| 188 | + | if (status) { status.textContent = 'Price must be at least $0.50'; status.style.color = 'var(--danger)'; } | |
| 189 | + | return false; | |
| 190 | + | } | |
| 191 | + | data.price_dollars = p; | |
| 192 | + | } else if (model === 'pwyw') { | |
| 193 | + | var v = document.getElementById('settings-pwyw-min-dollars').value; | |
| 194 | + | data.pwyw_min_dollars = v === '' ? 0 : parseFloat(v); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | fetch('/api/projects/' + projectId, { | |
| 198 | + | method: 'PUT', | |
| 199 | + | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 200 | + | body: JSON.stringify(data) | |
| 201 | + | }).then(function(r) { | |
| 202 | + | if (r.ok) { | |
| 203 | + | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; } | |
| 204 | + | setTimeout(function() { if (status) status.textContent = ''; }, 2000); | |
| 205 | + | } else { | |
| 206 | + | r.text().then(function(body) { | |
| 207 | + | var msg = 'Save failed'; | |
| 208 | + | try { msg = JSON.parse(body).error || msg; } catch(_) {} | |
| 209 | + | if (status) { status.textContent = msg; status.style.color = 'var(--danger)'; } | |
| 210 | + | }); | |
| 211 | + | } | |
| 212 | + | }).catch(function() { | |
| 213 | + | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--danger)'; } | |
| 214 | + | }); | |
| 215 | + | return false; | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Features update | |
| 219 | + | function updateFeatures(projectId) { | |
| 220 | + | var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]'); | |
| 221 | + | var selected = []; | |
| 222 | + | checkboxes.forEach(function(cb) { | |
| 223 | + | if (cb.checked) selected.push(cb.value); | |
| 224 | + | }); | |
| 225 | + | var status = document.getElementById('features-save-status'); | |
| 226 | + | ||
| 227 | + | fetch('/api/projects/' + projectId, { | |
| 228 | + | method: 'PUT', | |
| 229 | + | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 230 | + | body: JSON.stringify({features: selected}) | |
| 231 | + | }).then(function(r) { | |
| 232 | + | if (r.ok) { | |
| 233 | + | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; } | |
| 234 | + | // Reload the page so the tab bar reflects new features | |
| 235 | + | setTimeout(function() { window.location.reload(); }, 300); | |
| 236 | + | } else { | |
| 237 | + | if (status) { status.textContent = 'Save failed'; status.style.color = 'var(--danger)'; } | |
| 238 | + | } | |
| 239 | + | }).catch(function() { | |
| 240 | + | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--danger)'; } | |
| 241 | + | }); | |
| 242 | + | } |
| @@ -2,35 +2,6 @@ | |||
| 2 | 2 | ||
| 3 | 3 | {% block title %}Custom page: {{ heading }} - Makenotwork{% endblock %} | |
| 4 | 4 | ||
| 5 | - | {% block head %} | |
| 6 | - | <style> | |
| 7 | - | .cp-editor { max-width: 1200px; margin: 0 auto; padding: 1rem; } | |
| 8 | - | .cp-editor-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; flex-wrap: wrap; } | |
| 9 | - | .cp-editor-actions { display: flex; gap: 1rem; } | |
| 10 | - | .cp-intro { color: var(--muted, #666); margin: .25rem 0 1rem; } | |
| 11 | - | .cp-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; } | |
| 12 | - | .cp-editor-form { display: flex; flex-direction: column; gap: .35rem; } | |
| 13 | - | .cp-code { width: 100%; min-height: 220px; font-family: var(--font-mono, ui-monospace, monospace); font-size: .85rem; line-height: 1.4; resize: vertical; } | |
| 14 | - | .cp-editor-buttons { display: flex; align-items: center; gap: 1rem; margin-top: .5rem; } | |
| 15 | - | .cp-preview-pane { border: 1px solid var(--detail, #ccc); border-radius: 4px; overflow: hidden; } | |
| 16 | - | .cp-preview-bar { font-size: .8rem; padding: .3rem .6rem; background: var(--light-background, #f2f2f2); border-bottom: 1px solid var(--detail, #ccc); } | |
| 17 | - | .cp-preview { width: 100%; height: 520px; border: 0; background: #fff; display: block; } | |
| 18 | - | .cp-blocked { margin-top: 1.25rem; } | |
| 19 | - | .cp-blocked-title { font-weight: 600; margin-bottom: .5rem; } | |
| 20 | - | .cp-blocked-empty { color: var(--muted, #666); } | |
| 21 | - | .cp-blocked-list { list-style: none; padding: 0; margin: 0; } | |
| 22 | - | .cp-blocked-list li { padding: .4rem 0; border-bottom: 1px solid var(--detail, #eee); font-size: .85rem; display: flex; gap: .5rem; flex-wrap: wrap; align-items: baseline; } | |
| 23 | - | .cp-blocked-kind { font-weight: 600; } | |
| 24 | - | .cp-blocked-loc { color: var(--muted, #666); } | |
| 25 | - | .cp-blocked-val { background: var(--light-background, #f2f2f2); padding: 0 .25rem; } | |
| 26 | - | .cp-status.cp-ok { color: var(--success, #2a7); } | |
| 27 | - | .cp-status.cp-err { color: var(--warning, #b00); } | |
| 28 | - | .cp-reset { margin-top: 1.5rem; } | |
| 29 | - | .cp-locked-banner { margin: .5rem 0 1rem; padding: .75rem 1rem; border: 1px solid var(--warning, #b00); border-radius: 4px; background: var(--light-background, #fbeaea); } | |
| 30 | - | @media (max-width: 800px) { .cp-editor-grid { grid-template-columns: 1fr; } } | |
| 31 | - | </style> | |
| 32 | - | {% endblock %} | |
| 33 | - | ||
| 34 | 5 | {% block content %} | |
| 35 | 6 | <div class="cp-editor"> | |
| 36 | 7 | <header class="cp-editor-head"> |
| @@ -20,7 +20,6 @@ | |||
| 20 | 20 | </div> | |
| 21 | 21 | </div> | |
| 22 | 22 | <div id="tab-project-settings-cfg" hidden data-project-id="{{ project_id }}"></div> | |
| 23 | - | <script src="/static/tab-project-settings.js?v=0623"></script> | |
| 24 | 23 | ||
| 25 | 24 | <div class="form-section"> | |
| 26 | 25 | <h2 class="subsection-title">Gallery Images</h2> | |
| @@ -30,7 +29,6 @@ | |||
| 30 | 29 | <button type="button" class="btn-secondary btn-compact" data-action="click" data-target="project-gallery-input">Add Images</button> | |
| 31 | 30 | <span id="project-gallery-status" class="field-status"></span> | |
| 32 | 31 | </div> | |
| 33 | - | <script src="/static/tab-project-settings-2.js?v=0623"></script> | |
| 34 | 32 | ||
| 35 | 33 | <div class="form-section"> | |
| 36 | 34 | <h2 class="subsection-title">Project Information</h2> | |
| @@ -237,4 +235,6 @@ | |||
| 237 | 235 | </div> | |
| 238 | 236 | </details> | |
| 239 | 237 | <script src="/static/project-sections.js" defer></script> | |
| 240 | - | <script src="/static/tab-project-settings-3.js?v=0623"></script> | |
| 238 | + | <!-- Single consolidated settings-tab script: image upload, gallery manager, and | |
| 239 | + | category dropdown, loaded at the end so all referenced DOM is present. --> | |
| 240 | + | <script src="/static/tab-project-settings.js?v=0702"></script> |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | <p class="muted mb-4">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):</p> | |
| 5 | 5 | ||
| 6 | 6 | <div class="totp-qr"> | |
| 7 | - | <img src="data:image/png;base64,{{ qr_base64 }}" alt="TOTP QR Code"> | |
| 7 | + | <img src="data:image/png;base64,{{ qr_base64 }}" alt="TOTP QR Code" width="200" height="200"> | |
| 8 | 8 | </div> | |
| 9 | 9 | ||
| 10 | 10 | <details class="totp-manual"> |