Skip to main content

max / makenotwork

ux: extract all executable inline JS from templates into static files Move every executable inline <script> block out of the Askama templates (44 templates) into external /static/*.js files, honoring the CLAUDE.md "JS goes in static/, not inline" rule and making the surface strict-CSP ready. JSON-LD and application/json data blocks and existing <script src> tags are left inline; CSP is unchanged (still allows unsafe-inline), so behavior is identical. Server-rendered values that the scripts used via Askama interpolation are passed through hidden data-* config elements (auto-escaped attribute context) and read via dataset, rather than baked into the JS. HTMX- swapped partials keep working because htmx executes external <script src> tags on swap (allowScriptTags). Functions invoked from inline onclick handlers are exposed on window so they still resolve. 56 new static/*.js files; no JS logic changed. Verified: all templates compile (cargo check), every new file passes node --check, and the full integration suite (971) renders every page green. ultra-fuzz Run 5 M-UX3 (deep).
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 03:18 UTC
Signed with PGP, not checked
Commit: b9dd22a60b591d28a4cc80c822e86cf7b3edd8eb
Parent: 3de6ec0
100 files changed, +3140 insertions, -3092 deletions
@@ -134,27 +134,5 @@
134 134 </div>
135 135 </div>
136 136
137 - <script>
138 - function exportAll() {
139 - var btn = document.getElementById('export-all-btn');
140 - var buttons = document.querySelectorAll('.export-card button.secondary');
141 - if (buttons.length === 0) return;
142 -
143 - btn.disabled = true;
144 - btn.textContent = 'Exporting...';
145 - var i = 0;
146 -
147 - function next() {
148 - if (i >= buttons.length) {
149 - btn.textContent = 'Done';
150 - setTimeout(function() { btn.textContent = 'Export All'; btn.disabled = false; }, 3000);
151 - return;
152 - }
153 - buttons[i].click();
154 - i++;
155 - setTimeout(next, 1500);
156 - }
157 - next();
158 - }
159 - </script>
137 + <script src="/static/dashboard-export-inline.js?v=0623" defer></script>
160 138 {% endblock %}
@@ -117,218 +117,6 @@
117 117 </div>
118 118 </div>
119 119
120 - <script>
121 - (function() {
122 - const fileInput = document.getElementById('import-file');
123 - const previewEl = document.getElementById('csv-preview');
124 - const previewTable = document.getElementById('preview-table');
125 - const mappingEl = document.getElementById('column-mapping');
126 - const startBtn = document.getElementById('start-import-btn');
127 - const progressEl = document.getElementById('import-progress');
128 - const resultEl = document.getElementById('import-result');
129 -
130 - let csvBase64 = '';
131 - let csvHeaders = [];
132 -
133 - fileInput.addEventListener('change', function(e) {
134 - const file = e.target.files[0];
135 - if (!file) return;
136 -
137 - const reader = new FileReader();
138 - reader.onload = function(ev) {
139 - const text = ev.target.result;
140 - csvBase64 = btoa(unescape(encodeURIComponent(text)));
141 -
142 - const lines = text.split('\n').filter(l => l.trim());
143 - if (lines.length < 2) {
144 - alert('CSV must have a header row and at least one data row.');
145 - return;
146 - }
147 -
148 - csvHeaders = parseCSVLine(lines[0]);
149 -
150 - // Build preview table
151 - let html = '<thead><tr>';
152 - csvHeaders.forEach(h => { html += '<th>' + escapeHtml(h) + '</th>'; });
153 - html += '</tr></thead><tbody>';
154 - for (let i = 1; i < Math.min(lines.length, 4); i++) {
155 - const cols = parseCSVLine(lines[i]);
156 - html += '<tr>';
157 - csvHeaders.forEach((_, ci) => {
158 - html += '<td>' + escapeHtml(cols[ci] || '') + '</td>';
159 - });
160 - html += '</tr>';
161 - }
162 - html += '</tbody>';
163 - previewTable.innerHTML = html;
164 - previewEl.classList.remove('hidden');
165 -
166 - // Populate mapping selects
167 - const selects = mappingEl.querySelectorAll('select');
168 - selects.forEach(sel => {
169 - const current = sel.value;
170 - sel.innerHTML = '<option value="">-- skip --</option>';
171 - csvHeaders.forEach((h, i) => {
172 - const opt = document.createElement('option');
173 - opt.value = i;
174 - opt.textContent = h;
175 - sel.appendChild(opt);
176 - });
177 - // Auto-detect common column names
178 - autoDetectColumn(sel, csvHeaders);
179 - });
180 - mappingEl.classList.remove('hidden');
181 - };
182 - reader.readAsText(file);
183 - });
184 -
185 - function autoDetectColumn(select, headers) {
186 - const id = select.id;
187 - const lower = headers.map(h => h.toLowerCase().trim());
188 - const patterns = {
189 - 'map-email': ['email', 'e-mail', 'email_address', 'subscriber_email'],
190 - 'map-name': ['name', 'full_name', 'display_name', 'subscriber_name'],
191 - 'map-amount': ['amount', 'total', 'price', 'payment', 'lifetime_amount'],
192 - 'map-date': ['date', 'created_at', 'joined', 'start_date', 'signup_date'],
193 - 'map-item-title': ['item', 'product', 'title', 'item_title', 'product_name'],
194 - 'map-tier': ['tier', 'plan', 'level', 'membership'],
195 - 'map-status': ['status', 'state', 'active']
196 - };
197 - const p = patterns[id] || [];
198 - for (let i = 0; i < lower.length; i++) {
199 - if (p.includes(lower[i])) {
200 - select.value = i;
201 - return;
202 - }
203 - }
204 - }
205 -
206 - startBtn.addEventListener('click', function() {
207 - const projectId = document.getElementById('import-project').value;
208 - if (!csvBase64) {
209 - alert('Please select a CSV file first.');
210 - return;
211 - }
212 -
213 - const mapping = {};
214 - const mapEmail = document.getElementById('map-email').value;
215 - const mapName = document.getElementById('map-name').value;
216 - const mapAmount = document.getElementById('map-amount').value;
217 - const mapDate = document.getElementById('map-date').value;
218 - const mapItem = document.getElementById('map-item-title').value;
219 - const mapTier = document.getElementById('map-tier').value;
220 - const mapStatus = document.getElementById('map-status').value;
221 -
222 - if (mapEmail) mapping.email = parseInt(mapEmail);
223 - if (mapName) mapping.name = parseInt(mapName);
224 - if (mapAmount) mapping.amount = parseInt(mapAmount);
225 - if (mapDate) mapping.date = parseInt(mapDate);
226 - if (mapItem) mapping.item_title = parseInt(mapItem);
227 - if (mapTier) mapping.tier = parseInt(mapTier);
228 - if (mapStatus) mapping.status = parseInt(mapStatus);
229 -
230 - if (!mapping.email && !mapping.amount) {
231 - alert('Please map at least an email or amount column.');
232 - return;
233 - }
234 -
235 - startBtn.disabled = true;
236 - startBtn.textContent = 'Starting...';
237 -
238 - fetch('/api/users/me/import', {
239 - method: 'POST',
240 - headers: {
241 - 'Content-Type': 'application/json',
242 - 'X-CSRF-Token': '{{ csrf_token.as_deref().unwrap_or_default() }}'
243 - },
244 - body: JSON.stringify({
245 - project_id: projectId,
246 - source: 'generic_csv',
247 - csv_data: csvBase64,
248 - column_mapping: mapping
249 - })
250 - })
251 - .then(r => r.json())
252 - .then(data => {
253 - if (data.error) {
254 - resultEl.className = 'import-result error';
255 - resultEl.textContent = data.error;
256 - startBtn.disabled = false;
257 - startBtn.textContent = 'Start Import';
258 - return;
259 - }
260 - progressEl.classList.remove('hidden');
261 - pollProgress(data.job_id);
262 - })
263 - .catch(err => {
264 - resultEl.className = 'import-result error';
265 - resultEl.textContent = 'Failed to start import: ' + err.message;
266 - startBtn.disabled = false;
267 - startBtn.textContent = 'Start Import';
268 - });
269 - });
270 -
271 - function pollProgress(jobId) {
272 - const interval = setInterval(() => {
273 - fetch('/api/users/me/import/' + jobId)
274 - .then(r => r.json())
275 - .then(data => {
276 - const pct = data.total_rows > 0
277 - ? Math.round((data.processed_rows / data.total_rows) * 100)
278 - : 0;
279 - document.getElementById('progress-bar').style.width = pct + '%';
280 - document.getElementById('stat-processed').textContent = data.processed_rows;
281 - document.getElementById('stat-created').textContent = data.created_rows;
282 - document.getElementById('stat-skipped').textContent = data.skipped_rows;
283 -
284 - if (data.status === 'completed' || data.status === 'failed') {
285 - clearInterval(interval);
286 - progressEl.classList.add('hidden');
287 -
288 - resultEl.classList.remove('hidden');
289 - if (data.status === 'completed') {
290 - resultEl.className = 'import-result success';
291 - resultEl.innerHTML = '<strong>Import complete.</strong> ' +
292 - data.created_rows + ' created, ' +
293 - data.skipped_rows + ' skipped.';
294 - } else {
295 - resultEl.className = 'import-result error';
296 - resultEl.innerHTML = '<strong>Import failed.</strong>';
297 - }
298 - if (data.error_log) {
299 - resultEl.innerHTML += '<div class="error-log">' +
300 - escapeHtml(data.error_log) + '</div>';
301 - }
302 - startBtn.disabled = false;
303 - startBtn.textContent = 'Start Import';
304 - }
305 - });
306 - }, 2000);
307 - }
308 -
309 - function parseCSVLine(line) {
310 - const result = [];
311 - let current = '';
312 - let inQuotes = false;
313 - for (let i = 0; i < line.length; i++) {
314 - const ch = line[i];
315 - if (inQuotes) {
316 - if (ch === '"' && line[i+1] === '"') { current += '"'; i++; }
317 - else if (ch === '"') { inQuotes = false; }
318 - else { current += ch; }
319 - } else {
320 - if (ch === '"') { inQuotes = true; }
321 - else if (ch === ',') { result.push(current.trim()); current = ''; }
322 - else { current += ch; }
323 - }
324 - }
325 - result.push(current.trim());
326 - return result;
327 - }
328 -
329 - function escapeHtml(s) {
330 - return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
331 - }
332 - })();
333 - </script>
120 + <div id="dashboard-import-inline-cfg" hidden data-csrf-token="{{ csrf_token.as_deref().unwrap_or_default() }}"></div>
121 + <script src="/static/dashboard-import-inline.js?v=0623" defer></script>
334 122 {% endblock %}
@@ -66,33 +66,6 @@
66 66 </div>
67 67 </div>
68 68 </div>
69 - <script>
70 - const audio = new Audio();
71 - let loaded = false;
72 - // Read the preview URL from the data- attribute rather than interpolating it
73 - // into this JS string. Askama autoescapes for HTML, which is the correct
74 - // escaper for an attribute value but NOT for a JS string literal — keeping the
75 - // URL in the attribute keeps escaping correct even once preview URLs derive
76 - // from user-influenced filenames.
77 - const previewUrl = document.querySelector('.player').dataset.previewUrl;
78 - function togglePlay() {
79 - if (!loaded) { audio.src = previewUrl; loaded = true; }
80 - if (audio.paused) { audio.play(); document.getElementById('play').innerHTML = '&#9646;&#9646;'; }
81 - else { audio.pause(); document.getElementById('play').innerHTML = '&#9654;'; }
82 - }
83 - audio.ontimeupdate = () => {
84 - const pct = (audio.currentTime / audio.duration) * 100;
85 - document.getElementById('progress').style.width = pct + '%';
86 - const m = Math.floor(audio.currentTime / 60);
87 - const s = Math.floor(audio.currentTime % 60);
88 - document.getElementById('time').textContent = m + ':' + (s < 10 ? '0' : '') + s;
89 - };
90 - audio.onended = () => { document.getElementById('play').innerHTML = '&#9654;'; };
91 - function seek(e) {
92 - if (!audio.duration) return;
93 - const rect = e.currentTarget.getBoundingClientRect();
94 - audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
95 - }
96 - </script>
69 + <script src="/static/embed-item-player.js?v=0623" defer></script>
97 70 </body>
98 71 </html>
@@ -52,40 +52,8 @@
52 52 </div>
53 53
54 54 {% if !item.is_free %}
55 - <script>
56 - function buy() {
57 - const btn = document.getElementById('buy-btn');
58 - btn.disabled = true;
59 - btn.textContent = 'Redirecting...';
60 -
61 - const body = {};
62 - {% if pwyw_enabled %}
63 - const amount = parseFloat(document.getElementById('pwyw_amount').value);
64 - body.amount_cents = Math.round(amount * 100);
65 - {% endif %}
66 -
67 - fetch('{{ host_url }}/api/checkout/guest/{{ item.id }}', {
68 - method: 'POST',
69 - headers: { 'Content-Type': 'application/json' },
70 - body: JSON.stringify(body),
71 - })
72 - .then(r => r.json())
73 - .then(data => {
74 - if (data.checkout_url) {
75 - window.location.href = data.checkout_url;
76 - } else {
77 - btn.disabled = false;
78 - btn.textContent = 'Buy Now';
79 - alert(data.error || 'Something went wrong');
80 - }
81 - })
82 - .catch(() => {
83 - btn.disabled = false;
84 - btn.textContent = 'Buy Now';
85 - alert('Something went wrong. Please try again.');
86 - });
87 - }
88 - </script>
55 + <div id="page-buy-cfg" hidden data-host-url="{{ host_url }}" data-item-id="{{ item.id }}" data-pwyw-enabled="{% if pwyw_enabled %}true{% else %}false{% endif %}"></div>
56 + <script src="/static/page-buy.js?v=0623" defer></script>
89 57 {% endif %}
90 58 </body>
91 59 </html>
@@ -169,55 +169,5 @@
169 169 {% endif %}
170 170 </div>
171 171
172 - <script>
173 - (function() {
174 - document.querySelectorAll('.pwyw-cart-input').forEach(function(input) {
175 - var debounce;
176 - input.addEventListener('change', function() {
177 - clearTimeout(debounce);
178 - var itemId = this.dataset.itemId;
179 - var dollars = parseFloat(this.value) || 0;
180 - var cents = Math.round(dollars * 100);
181 - debounce = setTimeout(function() {
182 - fetch('/api/cart/' + itemId, {
183 - method: 'PUT',
184 - headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
185 - body: JSON.stringify({ amount_cents: cents })
186 - }).then(function(r) {
187 - if (!r.ok) return r.json().then(function(d) { showToast(d.error || 'Invalid amount'); });
188 - }).catch(function() { showToast('Failed to update amount'); });
189 - }, 300);
190 - });
191 - });
192 - })();
193 -
194 - window.removeCartGroup = function(btn) {
195 - var name = btn.dataset.sellerName || 'this creator';
196 - if (!confirm('Remove all items from ' + name + '?')) return;
197 - var group = btn.closest('.cart-group');
198 - if (!group) return;
199 - var rows = group.querySelectorAll('tr[id^="cart-row-"]');
200 - if (rows.length === 0) return;
201 - btn.disabled = true;
202 - var headers = csrfHeaders();
203 - var pending = rows.length;
204 - var failed = false;
205 - function finish() {
206 - if (--pending !== 0) return;
207 - if (failed) {
208 - showToast('Some items could not be removed. Refreshing.');
209 - window.location.reload();
210 - } else {
211 - window.location.reload();
212 - }
213 - }
214 - rows.forEach(function(row) {
215 - var id = row.id.replace('cart-row-', '');
216 - fetch('/api/cart/' + id, { method: 'DELETE', headers: headers })
217 - .then(function(r) { if (!r.ok) failed = true; })
218 - .catch(function() { failed = true; })
219 - .finally(finish);
220 - });
221 - };
222 - </script>
172 + <script src="/static/page-cart.js?v=0623" defer></script>
223 173 {% endblock %}
@@ -247,120 +247,5 @@
247 247 {% endblock %}
248 248
249 249 {% block scripts %}
250 - <script>
251 - // Sync filter UI state on filter click: update active highlight and write
252 - // the selected filter value into the hidden form input so subsequent
253 - // search/sort submissions include it.
254 - document.body.addEventListener('htmx:beforeRequest', function(evt) {
255 - if (evt.detail.elt.classList.contains('filter-item')) {
256 - // Only deactivate siblings in the same filter section
257 - var section = evt.detail.elt.closest('.filter-list');
258 - if (section) {
259 - section.querySelectorAll('.filter-item').forEach(function(i) { i.classList.remove('is-selected'); });
260 - }
261 - evt.detail.elt.classList.add('is-selected');
262 -
263 - var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}');
264 - if ('item_type' in hxVals) {
265 - document.getElementById('type-input').value = hxVals.item_type || '';
266 - }
267 - if ('tag' in hxVals) {
268 - document.getElementById('tag-input').value = hxVals.tag || '';
269 - }
270 - if ('category' in hxVals) {
271 - document.getElementById('category-input').value = hxVals.category || '';
272 - }
273 - if ('label' in hxVals) {
274 - document.getElementById('label-input').value = hxVals.label || '';
275 - }
276 - if ('ai_tier' in hxVals) {
277 - document.getElementById('ai-tier-input').value = hxVals.ai_tier || '';
278 - }
279 - }
280 - });
281 -
282 - // View toggle (list vs grid) with localStorage persistence.
283 - // Re-applied after HTMX swaps because new content replaces the container.
284 - (function() {
285 - function applyView(view) {
286 - var container = document.getElementById('results-container-inner');
287 - if (container) {
288 - container.className = 'results-container results-' + view;
289 - }
290 - document.querySelectorAll('.view-btn').forEach(function(btn) {
291 - btn.classList.toggle('is-selected', btn.dataset.view === view);
292 - });
293 - }
294 -
295 - // Load saved preference on page load
296 - document.addEventListener('DOMContentLoaded', function() {
297 - var saved = safeStorageGet('discoverViewPref') || 'grid';
298 - applyView(saved);
299 - });
300 -
301 - // Handle view button clicks
302 - document.querySelectorAll('.view-btn').forEach(function(btn) {
303 - btn.addEventListener('click', function() {
304 - var view = btn.dataset.view;
305 - applyView(view);
306 - safeStorageSet('discoverViewPref', view);
307 - });
308 - });
309 -
310 - // Re-apply view preference after HTMX swaps new content
311 - document.body.addEventListener('htmx:afterSwap', function(evt) {
312 - if (evt.detail.target.id === 'results-container') {
313 - var saved = safeStorageGet('discoverViewPref') || 'grid';
314 - applyView(saved);
315 - }
316 - });
317 - })();
318 -
319 - // Search suggestions autocomplete
320 - (function() {
321 - var input = document.getElementById('search-input');
322 - var box = document.getElementById('search-suggestions');
323 - var timer = null;
324 - var selectedIdx = -1;
325 -
326 - input.addEventListener('input', function() {
327 - clearTimeout(timer);
328 - var q = input.value.trim();
329 - if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; }
330 - timer = setTimeout(function() {
331 - fetch('/discover/suggestions?q=' + encodeURIComponent(q))
332 - .then(function(r) { return r.json(); })
333 - .then(function(items) {
334 - if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; }
335 - selectedIdx = -1;
336 - box.innerHTML = items.map(function(s, i) {
337 - return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">'
338 - + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>'
339 - + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>';
340 - }).join('');
341 - box.style.display = 'block';
342 - });
343 - }, 200);
344 - });
345 -
346 - input.addEventListener('keydown', function(e) {
347 - var items = box.querySelectorAll('.suggestion-item');
348 - if (!items.length) return;
349 - if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); }
350 - else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); }
351 - else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); }
352 - else if (e.key === 'Escape') { box.style.display = 'none'; }
353 - });
354 -
355 - function updateHighlight(items) {
356 - items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); });
357 - }
358 -
359 - function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
360 -
361 - document.addEventListener('click', function(e) {
362 - if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; }
363 - });
364 - })();
365 - </script>
250 + <script src="/static/page-discover.js?v=0623" defer></script>
366 251 {% endblock %}
@@ -224,36 +224,5 @@
224 224 {% endblock %}
225 225
226 226 {% block scripts %}
227 - <script>
228 - function submitNotify(e) {
229 - e.preventDefault();
230 - var form = document.getElementById('notify-form');
231 - var status = document.getElementById('notify-status');
232 - var email = form.querySelector('input[name="email"]').value;
233 - var btn = form.querySelector('button');
234 - btn.disabled = true;
235 - btn.textContent = 'Sending...';
236 - status.textContent = '';
237 - fetch('/api/email-signup', {
238 - method: 'POST',
239 - headers: {'Content-Type': 'application/json'},
240 - body: JSON.stringify({email: email})
241 - }).then(function(r) {
242 - if (r.ok) {
243 - status.textContent = 'You\'re on the list.';
244 - status.className = 'notify-status success';
245 - form.querySelector('input[name="email"]').value = '';
246 - } else {
247 - return r.json().then(function(d) { throw new Error(d.error || 'Something went wrong'); });
248 - }
249 - }).catch(function(err) {
250 - status.textContent = err.message;
251 - status.className = 'notify-status error';
252 - }).finally(function() {
253 - btn.disabled = false;
254 - btn.textContent = 'Notify Me';
255 - });
256 - return false;
257 - }
258 - </script>
227 + <script src="/static/page-index.js?v=0623" defer></script>
259 228 {% endblock %}
@@ -260,21 +260,8 @@
260 260 <details>
261 261 <summary class="license-summary">View full license text</summary>
262 262 <pre class="license-text" id="license-text-content">Loading...</pre>
263 - <script>
264 - (function() {
265 - var details = document.currentScript.closest('details');
266 - var loaded = false;
267 - details.addEventListener('toggle', function() {
268 - if (details.open && !loaded) {
269 - loaded = true;
270 - fetch('/api/items/{{ item.id }}/license.txt')
271 - .then(function(r) { return r.text(); })
272 - .then(function(t) { document.getElementById('license-text-content').textContent = t; })
273 - .catch(function() { document.getElementById('license-text-content').textContent = 'Failed to load license text.'; });
274 - }
275 - });
276 - })();
277 - </script>
263 + <div id="page-item-1-cfg" hidden data-item-id="{{ item.id }}"></div>
264 + <script src="/static/page-item-1.js?v=0623" defer></script>
278 265 </details>
279 266 <p class="license-download">
280 267 <a href="/api/items/{{ item.id }}/license.txt" download="LICENSE.txt">Download LICENSE.txt</a>
@@ -343,82 +330,7 @@
343 330 {% endblock %}
344 331
345 332 {% block scripts %}
346 - <script>
347 - function switchSectionTab(btn, panelId) {
348 - document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.remove('is-selected'); });
349 - document.querySelectorAll('.section-panel').forEach(function(p) { p.classList.remove('active'); });
350 - btn.classList.add('is-selected');
351 - var panel = document.getElementById(panelId);
352 - if (panel) panel.classList.add('active');
353 - history.replaceState(null, '', '#' + panelId);
354 - }
355 -
356 - (function() {
357 - var hash = window.location.hash.replace('#', '');
358 - if (hash) {
359 - var panel = document.getElementById(hash);
360 - var tab = document.querySelector('[data-tab="' + hash + '"]');
361 - if (panel && tab) switchSectionTab(tab, hash);
362 - }
363 - })();
364 -
365 - (function() {
366 - var player = document.getElementById('item-player');
367 - if (player) {
368 - var loaded = false;
369 - player.addEventListener('play', function loadSrc() {
370 - if (!loaded) {
371 - loaded = true;
372 - player.pause();
373 - fetch('/api/stream/{{ item.id }}')
374 - .then(function(r) {
375 - if (!r.ok) throw new Error('Stream unavailable');
376 - return r.json();
377 - })
378 - .then(function(data) {
379 - player.src = data.stream_url;
380 - player.play();
381 - })
382 - .catch(function(err) {
383 - showToast(err.message || 'Could not load video');
384 - });
385 - }
386 - }, { once: true });
387 - }
388 - })();
389 - </script>
390 -
391 - <script>
392 - (function() {
393 - window.toggleWishlist = function(itemId) {
394 - var btn = document.getElementById('wishlist-btn');
395 - fetch('/api/wishlists/' + itemId, { method: 'POST', headers: csrfHeaders() })
396 - .then(function(r) { return r.json(); })
397 - .then(function(data) {
398 - if (data.wishlisted) {
399 - btn.textContent = 'Wishlisted';
400 - } else {
401 - btn.textContent = 'Add to Wishlist';
402 - }
403 - });
404 - };
405 -
406 - window.toggleCart = function(itemId) {
407 - var btn = document.getElementById('cart-btn');
408 - fetch('/api/cart/' + itemId, { method: 'POST', headers: csrfHeaders() })
409 - .then(function(r) { return r.json(); })
410 - .then(function(data) {
411 - if (data.in_cart) {
412 - btn.textContent = 'In Cart';
413 - showToast('Added to cart. Buying multiple items together saves the creator on processing fees.', 'info');
414 - } else {
415 - btn.textContent = 'Add to Cart';
416 - }
417 - })
418 - .catch(function(err) {
419 - showToast(err.message || 'Failed to update cart');
420 - });
421 - };
422 - })();
423 - </script>
333 + <div id="page-item-2-cfg" hidden data-item-id="{{ item.id }}"></div>
334 + <script src="/static/page-item-2.js?v=0623" defer></script>
335 + <script src="/static/page-item-3.js?v=0623" defer></script>
424 336 {% endblock %}
@@ -154,19 +154,5 @@
154 154 }
155 155 </script>
156 156 <script src="/static/media-player.js"></script>
157 - <script>
158 - function downloadVersion(versionId) {
159 - fetch('/api/versions/' + versionId + '/download')
160 - .then(function(res) {
161 - if (!res.ok) throw new Error('Failed to get download URL');
162 - return res.json();
163 - })
164 - .then(function(data) {
165 - window.location.href = data.download_url;
166 - })
167 - .catch(function(err) {
168 - showToast(err.message || 'Download failed');
169 - });
170 - }
171 - </script>
157 + <script src="/static/page-library-audio.js?v=0623" defer></script>
172 158 {% endblock %}
@@ -145,37 +145,5 @@
145 145 {% endblock %}
146 146
147 147 {% block scripts %}
148 - <script>
149 - function switchSectionTab(btn, panelId) {
150 - document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.remove('is-selected'); });
151 - document.querySelectorAll('.section-panel').forEach(function(p) { p.classList.remove('active'); });
152 - btn.classList.add('is-selected');
153 - var panel = document.getElementById(panelId);
154 - if (panel) panel.classList.add('active');
155 - history.replaceState(null, '', '#' + panelId);
156 - }
157 -
158 - (function() {
159 - var hash = window.location.hash.replace('#', '');
160 - if (hash) {
161 - var panel = document.getElementById(hash);
162 - var tab = document.querySelector('[data-tab="' + hash + '"]');
163 - if (panel && tab) switchSectionTab(tab, hash);
164 - }
165 - })();
166 -
167 - function downloadVersion(versionId) {
168 - fetch('/api/versions/' + versionId + '/download')
169 - .then(function(res) {
170 - if (!res.ok) throw new Error('Failed to get download URL');
171 - return res.json();
172 - })
173 - .then(function(data) {
174 - window.location.href = data.download_url;
175 - })
176 - .catch(function(err) {
177 - showToast(err.message || 'Download failed');
178 - });
179 - }
180 - </script>
148 + <script src="/static/page-library-downloads.js?v=0623" defer></script>
181 149 {% endblock %}
@@ -146,19 +146,5 @@
146 146 }
147 147 </script>
148 148 <script src="/static/media-player.js"></script>
149 - <script>
150 - function downloadVersion(versionId) {
151 - fetch('/api/versions/' + versionId + '/download')
152 - .then(function(res) {
153 - if (!res.ok) throw new Error('Failed to get download URL');
154 - return res.json();
155 - })
156 - .then(function(data) {
157 - window.location.href = data.download_url;
158 - })
159 - .catch(function(err) {
160 - showToast(err.message || 'Download failed');
161 - });
162 - }
163 - </script>
149 + <script src="/static/page-library-video.js?v=0623" defer></script>
164 150 {% endblock %}