diff --git a/make_post_sell/static/js/shop-settings.js b/make_post_sell/static/js/shop-settings.js new file mode 100644 index 0000000..91dd3fb --- /dev/null +++ b/make_post_sell/static/js/shop-settings.js @@ -0,0 +1,78 @@ +// AJAX shop settings — progressive enhancement. +// When JS is available, intercepts settings form POSTs and submits +// via fetch so the page does not reload. Flash messages are rendered +// inline into the #alerts div. When JS is disabled, forms fall back +// to the normal POST + redirect flow. +(function() { + var forms = document.querySelectorAll('form[action*="/settings"]'); + if (!forms.length) return; + + forms.forEach(function(form) { + // Skip file upload forms — they POST directly to S3, not /settings + if (form.querySelector('input[type="file"]')) return; + + form.addEventListener('submit', function(e) { + e.preventDefault(); + var formData = new FormData(form); + var submitBtn = form.querySelector('input[type="submit"], button[type="submit"]'); + if (!submitBtn) return; + var originalLabel = submitBtn.value; + submitBtn.disabled = true; + submitBtn.value = 'Saving...'; + + fetch(form.action, { + method: 'POST', + body: formData, + redirect: 'manual', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(response) { + if (!response.ok) { + // Auth redirect or server error — fall back to regular form submit + submitBtn.disabled = false; + submitBtn.value = originalLabel; + form.submit(); + return; + } + return response.json(); + }) + .then(function(data) { + if (!data) return; + showFlashMessages(data.messages); + }) + .catch(function() { + // Network error — fall back to regular form submit + submitBtn.disabled = false; + submitBtn.value = originalLabel; + form.submit(); + }) + .finally(function() { + submitBtn.disabled = false; + submitBtn.value = originalLabel; + }); + }); + }); + + function showFlashMessages(messages) { + var alerts = document.getElementById('alerts'); + if (!alerts) return; + alerts.innerHTML = ''; + if (!messages || !messages.length) return; + messages.forEach(function(msg) { + var text = msg[0], level = msg[1]; + var div = document.createElement('div'); + div.className = 'alert alert-' + level; + div.setAttribute('onclick', "this.style.display='none'"); + div.innerHTML = '
' + + ''; + alerts.appendChild(div); + }); + alerts.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function escapeHtml(str) { + var div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } +})(); diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index 3048e36..6612dc0 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -25,6 +25,7 @@ var currentThumbnailUrl = null; var urlRefreshTimer = null; var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes + var lastUrlRefreshTs = Date.now(); var PROGRESS_KEY = 'mps_watch_progress'; var PROGRESS_SAVE_INTERVAL = 7000; var lastProgressSave = 0; @@ -660,6 +661,7 @@ currentThumbnailUrl = data.thumbnail_url || null; markWatched(data.product_id); syncRingPosition(data.product_id); + lastUrlRefreshTs = Date.now(); scheduleUrlRefresh(); // Reset karaoke state for the new product @@ -1529,6 +1531,10 @@ // Skip refresh during crossfade — the new media already has a fresh URL if (djCrossfadeActive) { scheduleUrlRefresh(); return; } + // Skip refresh while paused — no point re-buffering idle media. + // The play listener will refresh stale URLs when the user resumes. + if (activeMedia.paused) { scheduleUrlRefresh(); return; } + fetchWatchData(currentProductId).then(function(data) { if (!data || !data.media_url) { scheduleUrlRefresh(); return; } // Only refresh if still on the same product @@ -1550,6 +1556,7 @@ else if (karaokeMode === 2 && karaokeUrls.vocals) freshSrc = karaokeUrls.vocals; else freshSrc = data.media_url; activeMedia.src = freshSrc; + lastUrlRefreshTs = Date.now(); activeMedia.addEventListener('loadeddata', function onLoaded() { activeMedia.removeEventListener('loadeddata', onLoaded); @@ -1651,6 +1658,15 @@ } } + // --- Stale URL check on play: if paused long enough for the presigned URL + // to expire, refresh before resuming playback --- + function onMediaPlay() { + if (Date.now() - lastUrlRefreshTs > URL_REFRESH_MS) { + activeMedia.pause(); + refreshMediaUrl(); + } + } + // --- Media event setup --- function setupMediaEvents() { if (!activeMedia) return; @@ -1659,16 +1675,19 @@ activeMedia.removeEventListener('ended', onMediaEnded); activeMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata); activeMedia.removeEventListener('timeupdate', onMediaTimeUpdate); + activeMedia.removeEventListener('play', onMediaPlay); if (standbyMedia) { standbyMedia.removeEventListener('ended', onMediaEnded); standbyMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata); standbyMedia.removeEventListener('timeupdate', onMediaTimeUpdate); + standbyMedia.removeEventListener('play', onMediaPlay); } // Add fresh listeners to active element only activeMedia.addEventListener('ended', onMediaEnded); activeMedia.addEventListener('loadedmetadata', onMediaLoadedMetadata); activeMedia.addEventListener('timeupdate', onMediaTimeUpdate); + activeMedia.addEventListener('play', onMediaPlay); } // Initial setup diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index 3ecfb9f..824e65a 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -1156,5 +1156,6 @@ function toggleCryptoWallets() { wallets.style.display = checkbox.checked ? 'block' : 'none'; } + {%- endblock -%} diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index cca383e..5b1dbb0 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -41,6 +41,7 @@ from ..lib.phone_numbers import is_phone_number_valid from ..lib.currency import dollars_to_cents, cents_to_dollars from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response # feel free to come up with a better plan, GPT-4 made this regex. DOMAIN_NAME_REGEX = re.compile( @@ -1021,8 +1022,14 @@ def shop_settings(request): except (ValueError, TypeError): request.session.flash(("Invalid high risk threshold", "error")) - # If we processed any form submission, redirect to prevent re-submission + # If we processed any form submission, respond accordingly if form_section: + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + messages = list(request.session.pop_flash()) + return Response( + json={"status": "ok", "messages": messages}, + content_type="application/json", + ) return HTTPFound(f"/s/{shop.id}/settings") # TODO: Dry out this block, it's a copy pasta from views/product.py