Skip to main content

max / makenotwork

server: frontend hygiene — dead JS, fragmented tab JS, inline CSS, img shift Frontend cold-spot pass (audit Run 17): - Delete static/insertions.js (185 lines, referenced by nothing). - Merge tab-project-settings{,-2,-3}.js into one file loaded once at the end of the settings partial (each was an independent self-guarding IIFE); drop the two extra <script> tags. - Drop the duplicate csrfHeaders() in project-sections.js — use the mnw.js global, which reads the csrf meta live (the token rotates mid-session). - Move the 25-line inline <style> block out of custom_page_editor.html into style.css (.cp-* rules). - Reserve layout space for the two cover images whose class lacked an aspect-ratio (library-locked-cover, project-image preview) and the TOTP QR (data-URI img). The other flagged covers already reserve space via CSS aspect-ratio, so no redundant HTML attrs were added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 17:37 UTC
Commit: bd448cbe1a58df1dad0ca3432e1e50484f513040
Parent: 404a4c4
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">