From 69e90f8d2db099444260fa88ec312518b78a5474 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 8 Jun 2026 16:37:15 +0200 Subject: [PATCH] overhaul rethumbing flashs --- public/s/css/f0ckm.css | 2 +- public/s/css/upload.css | 139 ++++++++++++++++++- public/s/js/f0ckm.js | 235 +++++++++++++++++++++++++++----- public/s/js/upload.js | 204 +++++++++++++++++++++++++-- src/upload_handler.mjs | 8 ++ views/snippets/upload-form.html | 12 +- 6 files changed, 540 insertions(+), 60 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 33ef8b6..6d2e4a3 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -10017,7 +10017,7 @@ body.modal-open { .modal-actions { display: flex; - justify-content: flex-end; + justify-content: center; gap: 10px; margin-top: 15px; } diff --git a/public/s/css/upload.css b/public/s/css/upload.css index fd89eb4..21a3175 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -219,7 +219,6 @@ .upload-form:not(.shitpost-mode-active) .preview-media-small { width: 100%; - max-width: 400px; min-height: auto; height: auto; aspect-ratio: 16 / 9; @@ -257,6 +256,7 @@ flex-direction: column; overflow: hidden; min-width: 0; + width: 100%; } .file-info-small { @@ -442,7 +442,7 @@ .preview-media-small { flex: 0 0 50%; - width: 50% !important; + width: 100% !important; height: auto !important; min-height: 120px; max-height: 350px; @@ -1345,3 +1345,138 @@ 50% { opacity: 0.8; } 100% { opacity: 0.4; } } + +/* ── Ruffle (Flash) Upload Preview ─────────────────────────────────────────── */ + +/* The container: CSS Grid with 3 rows — player | snapshot preview | button. + All rows are auto so the container grows naturally; no overflow clipping. */ +.swf-upload-preview { + position: relative; + display: grid; + grid-template-rows: auto auto auto; + max-height: none !important; + background: #000; + border-radius: 8px; +} + +/* Normal (non-shitpost) mode: full-width, player drives container height via aspect-ratio */ +.upload-form:not(.shitpost-mode-active) .swf-upload-preview.preview-media-small { + width: 100% !important; + height: auto !important; + max-height: none !important; + min-height: auto !important; +} + +.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-player, +.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-object { + aspect-ratio: 16 / 9; + width: 100% !important; + height: auto !important; +} + +/* Shitpost mode: player row is a fixed height so the snapshot row doesn't steal its space */ +.upload-form.shitpost-mode-active .swf-upload-preview.preview-media-small { + width: 45% !important; + flex: 0 0 45% !important; + height: auto !important; + max-height: none !important; + min-height: auto !important; + aspect-ratio: unset; +} + +.upload-form.shitpost-mode-active .swf-upload-preview ruffle-player, +.upload-form.shitpost-mode-active .swf-upload-preview ruffle-object { + width: 100% !important; + height: 220px !important; + min-height: 220px; +} + +/* Placeholder shown while Ruffle is loading */ +.swf-upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + min-height: 200px; + background: rgba(0, 0, 0, 0.6); + animation: thumbPulse 1.8s ease-in-out infinite; +} + +.swf-upload-placeholder-icon { + font-size: 2.5rem; + line-height: 1; + filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.7)); +} + +.swf-upload-placeholder-text { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 0.5px; +} + +/* ── Ruffle Snapshot Button ─────────────────────────────────────────────────── */ + +/* The snapshot button lives inside .swf-upload-preview, pinned to the bottom */ +.swf-upload-preview { + position: relative; + display: flex; + flex-direction: column; +} + +.btn-ruffle-snapshot { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 0; + padding: 8px 16px; + background: rgba(255, 200, 0, 0.08); + border: none; + border-top: 1px solid rgba(255, 200, 0, 0.2); + border-radius: 0 0 6px 6px; + color: rgba(255, 200, 0, 0.9); + font-size: 0.85rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all 0.2s ease; + letter-spacing: 0.3px; + flex-shrink: 0; +} + +.btn-ruffle-snapshot:hover:not(:disabled) { + background: rgba(255, 200, 0, 0.15); + border-top-color: rgba(255, 200, 0, 0.4); + color: #ffd700; +} + +.btn-ruffle-snapshot:active:not(:disabled) { + background: rgba(255, 200, 0, 0.22); +} + +.btn-ruffle-snapshot:disabled { + opacity: 0.6; + cursor: not-allowed; +} + + +/* Snapshot preview: sits in its own grid row, below the player */ +.swf-upload-preview .ruffle-snapshot-preview { + display: block; + width: 100%; + height: auto; + object-fit: contain; + object-position: center; + background: rgba(0, 0, 0, 0.6); + border-top: 1px solid rgba(81, 207, 102, 0.3); + animation: snapPreviewIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes snapPreviewIn { + from { opacity: 0; transform: scale(0.96) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 4ad5953..50715b9 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1658,7 +1658,8 @@ window.cancelAnimFrame = (function () { "unmuteOverlay": "hidden", "letterbox": "on", "warnOnUnsupportedContent": false, - "contextMenu": "on" + "contextMenu": "on", + "splashScreen": false }; let ruffleKeepAliveApplied = false; @@ -1743,15 +1744,46 @@ window.cancelAnimFrame = (function () { applyRuffleKeepAlive(); + // Detect whether the item is currently behind a blur overlay. + // If so, load the SWF with autoplay:"off" so it stays paused until the user reveals it. + const mediaObj = container.closest('.media-object'); + let blurActive = false; + if (mediaObj && !mediaObj.classList.contains('revealed') && localStorage.getItem('blurDetail') !== 'false') { + const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; + const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; + const blurSfw = localStorage.getItem('blurSfw') === 'true'; + const blurUntagged = localStorage.getItem('blurUntagged') === 'true'; + const mode = mediaObj.getAttribute('data-mode'); + blurActive = + (mode === 'nsfw' && blurNsfw) || + (mode === 'nsfl' && blurNsfl) || + (mode === 'sfw' && blurSfw) || + (mode === 'untagged' && blurUntagged); + } + // Update config with latest session preferences just before creation if (window.RufflePlayer) { window.RufflePlayer.config = window.RuffleConfig = { ...window.RuffleConfig, + "autoplay": blurActive ? "off" : "on", "pageVisibility": window.f0ckSession?.ruffle_background === false, - "backgroundExecution": window.f0ckSession?.ruffle_background === false ? undefined : "Unthrottled" + "backgroundExecution": window.f0ckSession?.ruffle_background === false ? undefined : "Unthrottled", + "splashScreen": false }; } + // Patch WebGL getContext to force preserveDrawingBuffer:true so canvas + // frames can be captured after Ruffle renders (WebGL clears buffers by default) + const _origGetCtx = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attrs) { + if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') { + attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true }); + } + return _origGetCtx.call(this, type, attrs); + }; + // Restore after Ruffle has created its context + setTimeout(() => { HTMLCanvasElement.prototype.getContext = _origGetCtx; }, 8000); + function createPlayer() { if (!window.RufflePlayer) return; if (typeof window.RufflePlayer.newest !== 'function') return; // WASM still initializing @@ -3683,7 +3715,158 @@ window.cancelAnimFrame = (function () { e.preventDefault(); const reBtn = target.closest('#a_rethumb'); const itemId = reBtn.dataset.itemId; - + + // Helper: POST a blob as thumbnail + const submitRethumb = (blob, currentItemId) => { + const fd = new FormData(); + fd.append('file', blob, 'thumbnail.jpg'); + window.flashMessage(i18n.uploading || 'Uploading...'); + fetch(`/api/v2/items/${currentItemId}/thumbnail`, { + method: 'POST', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }, + body: fd + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + window.flashMessage(data.msg); + if (window.refreshItemThumbnails) window.refreshItemThumbnails(currentItemId); + } else { + window.flashError(data.msg || 'Upload failed'); + } + }) + .catch(err => { window.flashError('Upload error'); console.error(err); }); + }; + + // For Flash items: show a capture modal instead of a file picker + const rufflePlayer = document.querySelector('#ruffle-container ruffle-player'); + if (rufflePlayer) { + + const tryFindCanvas = (root) => { + if (!root) return null; + const c = root.querySelector('canvas'); + if (c) return c; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { const found = tryFindCanvas(el.shadowRoot); if (found) return found; } + } + return null; + }; + + // POST blob to thumbnail API and close modal + const submitRethumb = (blob) => { + const fd = new FormData(); + fd.append('file', blob, 'thumbnail.jpg'); + window.flashMessage(i18n.uploading || 'Uploading...'); + fetch(`/api/v2/items/${itemId}/thumbnail`, { + method: 'POST', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }, + body: fd + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + window.flashMessage(data.msg); + if (window.refreshItemThumbnails) window.refreshItemThumbnails(itemId); + } else { + window.flashError(data.msg || 'Upload failed'); + } + }) + .catch(err => { window.flashError('Upload error'); console.error(err); }); + }; + + // Capture current Ruffle frame → return blob via callback + const captureFrame = (cb) => { + const canvas = tryFindCanvas(rufflePlayer.shadowRoot || rufflePlayer) || tryFindCanvas(rufflePlayer); + if (!canvas || canvas.width === 0 || canvas.height === 0) { + cb(null); + return; + } + const out = document.createElement('canvas'); + const MAX = 640; + const w = canvas.width, h = canvas.height; + if (w > MAX || h > MAX) { + const ratio = Math.min(MAX / w, MAX / h); + out.width = Math.round(w * ratio); + out.height = Math.round(h * ratio); + } else { + out.width = w || 320; + out.height = h || 240; + } + out.getContext('2d').drawImage(canvas, 0, 0, out.width, out.height); + out.toBlob(cb, 'image/jpeg', 0.92); + }; + + // Build & show modal using site CSS classes + const showCaptureModal = (initialBlob) => { + document.getElementById('rethumb-capture-modal')?.remove(); + + let currentBlob = initialBlob; + + const overlay = document.createElement('div'); + overlay.id = 'rethumb-capture-modal'; + overlay.className = 'modal-overlay'; + overlay.style.zIndex = '99999'; + + const box = document.createElement('div'); + box.className = 'modal-content'; + box.style.cssText = 'max-width:480px;width:90%;text-align:left;position:relative;'; + + const title = document.createElement('h3'); + title.textContent = 'Thumbnail Preview'; + title.style.marginTop = '0'; + + const img = document.createElement('img'); + img.src = URL.createObjectURL(initialBlob); + img.style.cssText = 'width:100%;height:auto;display:block;border-radius:0;margin-bottom:4px;'; + + const actions = document.createElement('div'); + actions.className = 'modal-actions'; + + const btnUse = document.createElement('button'); + btnUse.textContent = 'Use as Thumbnail'; + btnUse.className = 'btn-danger'; + + const btnRecapture = document.createElement('button'); + btnRecapture.textContent = 'Capture Again'; + btnRecapture.className = 'btn-secondary'; + + const btnCancel = document.createElement('button'); + btnCancel.textContent = 'Cancel'; + btnCancel.className = 'btn-secondary'; + + const close = () => overlay.remove(); + + btnUse.onclick = () => { submitRethumb(currentBlob); close(); }; + btnRecapture.onclick = () => { + captureFrame((blob) => { + if (!blob) { window.flashError('Ruffle not ready'); return; } + URL.revokeObjectURL(img.src); + currentBlob = blob; + img.src = URL.createObjectURL(blob); + }); + }; + btnCancel.onclick = close; + overlay.onclick = (ev) => { if (ev.target === overlay) close(); }; + + actions.append(btnUse, btnRecapture, btnCancel); + box.append(title, img, actions); + overlay.appendChild(box); + document.body.appendChild(overlay); + }; + + captureFrame((blob) => { + if (blob) { + showCaptureModal(blob); + } else { + window.flashError('Ruffle not ready — play the Flash first'); + } + }); + + return; // never open file picker for Flash items + } + + + // Non-Flash items: use the file picker as before let fileInput = document.getElementById('rethumb-file-input'); if (!fileInput) { fileInput = document.createElement('input'); @@ -3692,52 +3875,24 @@ window.cancelAnimFrame = (function () { fileInput.accept = 'image/png, image/jpeg, image/gif, image/webp'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); - + fileInput.addEventListener('change', function(evt) { const file = evt.target.files[0]; if (!file) return; - if (file.size > 5 * 1024 * 1024) { window.flashError('File is too large (max 5MB)'); fileInput.value = ''; return; } - const currentItemId = fileInput.dataset.itemId; - const fd = new FormData(); - fd.append('file', file); - - window.flashMessage(i18n.uploading || 'Uploading...'); - - fetch(`/api/v2/items/${currentItemId}/thumbnail`, { - method: 'POST', - headers: { - 'X-CSRF-Token': window.f0ckSession?.csrf_token - }, - body: fd - }) - .then(r => r.json()) - .then(data => { - fileInput.value = ''; - if (data.success) { - window.flashMessage(data.msg); - if (window.refreshItemThumbnails) { - window.refreshItemThumbnails(currentItemId); - } - } else { - window.flashError(data.msg || 'Upload failed'); - } - }) - .catch(err => { - fileInput.value = ''; - window.flashError('Upload error'); - console.error(err); - }); + submitRethumb(file, currentItemId); + fileInput.value = ''; }); } - + fileInput.dataset.itemId = itemId; fileInput.click(); + } else if (target.closest('button#a_toggle')) { e.preventDefault(); const toggleBtn = target.closest('button#a_toggle'); @@ -9849,12 +10004,18 @@ document.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); mediaObj.classList.add('revealed'); - + // Start audio/video playback cleanly on reveal const videoElem = mediaObj.querySelector('video') || mediaObj.querySelector('audio'); if (videoElem) { videoElem.play().catch(() => {}); } + + // Start Ruffle playback if this is a Flash item — it was loaded with autoplay:"off" + const rufflePlayer = mediaObj.querySelector('ruffle-player'); + if (rufflePlayer) { + try { rufflePlayer.play(); } catch(err) {} + } return; } } diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 74dbdc2..f35561e 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -782,6 +782,7 @@ window.initUploadForm = (selector) => { // Basic validation (MIME/Extension/Size) const container = form.closest('.upload-container'); const mimesSource = form.getAttribute('data-mimes') || (container ? container.getAttribute('data-mimes') : null); + const swfEnabled = form.getAttribute('data-enable-swf') !== '0'; let allowedMimes = []; let allowedExts = []; try { @@ -793,6 +794,19 @@ window.initUploadForm = (selector) => { } const fileExt = file.name.split('.').pop().toLowerCase(); + const isSwfFile = fileExt === 'swf' || + file.type === 'application/x-shockwave-flash' || + file.type === 'application/vnd.adobe.flash.movie'; + + // Reject SWF when Flash uploads are disabled + if (isSwfFile && !swfEnabled) { + const errorMsg = 'Flash (.swf) uploads are disabled.'; + if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error'); + else if (window.showFlash) window.showFlash(errorMsg, 'error'); + else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; } + continue; + } + const mimeOk = !file.type || allowedMimes.includes(file.type); const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt); @@ -952,8 +966,147 @@ window.initUploadForm = (selector) => { mediaElem.style.width = '100%'; } else if (file.type === 'application/x-shockwave-flash' || file.type === 'application/vnd.adobe.flash.movie' || fileExt === 'swf') { mediaElem = document.createElement('div'); - mediaElem.className = 'generic-file-icon swf-preview-icon'; - mediaElem.innerHTML = ''; + mediaElem.className = 'swf-upload-preview'; + mediaElem.dataset.swfFile = 'pending'; + // Placeholder shown while Ruffle loads + mediaElem.innerHTML = `
Loading Flash preview…
`; + // Load Ruffle asynchronously once the element is in the DOM + const swfObjectUrl = URL.createObjectURL(file); + const ensureRuffleUpload = (cb) => { + if (window.RufflePlayer && typeof window.RufflePlayer.newest === 'function') { cb(); return; } + if (document.querySelector('script[src*="/s/ruffle/ruffle.js"]')) { + // Script is loading, poll for it + let attempts = 0; + const poll = setInterval(() => { + attempts++; + if ((window.RufflePlayer && typeof window.RufflePlayer.newest === 'function') || attempts >= 80) { + clearInterval(poll); + cb(); + } + }, 100); + return; + } + const s = document.createElement('script'); + s.src = '/s/ruffle/ruffle.js'; + s.onload = () => cb(); + s.onerror = () => cb(); // proceed even if fail + document.head.appendChild(s); + }; + // Defer init until next microtask so mediaElem is appended to DOM first + Promise.resolve().then(() => { + ensureRuffleUpload(() => { + if (!mediaElem.isConnected) { URL.revokeObjectURL(swfObjectUrl); return; } + const ruffle = window.RufflePlayer && window.RufflePlayer.newest ? window.RufflePlayer.newest() : null; + if (!ruffle) { + mediaElem.innerHTML = `
Flash preview unavailable
`; + return; + } + try { + // Patch getContext BEFORE creating the player so that Ruffle's WebGL + // context is created with preserveDrawingBuffer:true. + // Without this, WebGL clears the drawing buffer after each frame + // presentation, making canvas readback produce solid black. + const _origGetCtx = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attrs) { + if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') { + attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true }); + } + return _origGetCtx.call(this, type, attrs); + }; + const player = ruffle.createPlayer(); + player.style.cssText = 'width:100%;height:100%;display:block;border-radius:8px;'; + const placeholder = mediaElem.querySelector('.swf-upload-placeholder'); + if (placeholder) placeholder.remove(); + mediaElem.appendChild(player); + player.load({ url: swfObjectUrl, config: { volume: 0.5 } }); + // Restore getContext after Ruffle's WASM finishes creating its GL context + // (typically within ~2s of load; 6s is a safe upper bound) + setTimeout(() => { HTMLCanvasElement.prototype.getContext = _origGetCtx; }, 6000); + mediaElem._rufflePlayer = player; + mediaElem._swfObjectUrl = swfObjectUrl; + + // Inject snapshot button directly below the Ruffle player + // (inside the .swf-upload-preview, so it travels with each file-preview-item) + if (!mediaElem.querySelector('.btn-ruffle-snapshot')) { + const snapBtn = document.createElement('button'); + snapBtn.type = 'button'; + snapBtn.className = 'btn-ruffle-snapshot'; + snapBtn.textContent = 'Capture Thumbnail'; + snapBtn.title = 'Capture the current frame of the Flash preview as the thumbnail'; + mediaElem.appendChild(snapBtn); + } + + // Wire snapshot button — scoped to this previewItem / mediaElem + const wireSnapshot = () => { + const snapBtn = mediaElem.querySelector('.btn-ruffle-snapshot'); + if (!snapBtn) return; + snapBtn.onclick = async (e) => { + e.preventDefault(); + try { + // Try to find Ruffle's internal canvas via shadow DOM + let canvas = null; + const tryFindCanvas = (root) => { + if (!root) return null; + const c = root.querySelector('canvas'); + if (c) return c; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const found = tryFindCanvas(el.shadowRoot); + if (found) return found; + } + } + return null; + }; + canvas = tryFindCanvas(player.shadowRoot || player); + if (!canvas) canvas = tryFindCanvas(player); + if (canvas && canvas.width > 0 && canvas.height > 0) { + const out = document.createElement('canvas'); + const MAX = 640; + const w = canvas.width, h = canvas.height; + if (w > MAX || h > MAX) { + const ratio = Math.min(MAX / w, MAX / h); + out.width = Math.round(w * ratio); + out.height = Math.round(h * ratio); + } else { + out.width = w || 320; + out.height = h || 240; + } + const ctx = out.getContext('2d'); + ctx.drawImage(canvas, 0, 0, out.width, out.height); + out.toBlob((blob) => { + if (!blob) { snapBtn.textContent = '❌ Capture failed'; return; } + const snapFile = new File([blob], 'ruffle-snapshot.jpg', { type: 'image/jpeg' }); + const dt = new DataTransfer(); + dt.items.add(snapFile); + if (thumbInput) { + thumbInput.files = dt.files; + thumbInput.dispatchEvent(new Event('change', { bubbles: true })); + } + // Replace snapshot preview in its grid row (between player and button) + const existingPrev = mediaElem.querySelector('.ruffle-snapshot-preview'); + if (existingPrev) existingPrev.remove(); + const prevImg = document.createElement('img'); + prevImg.src = URL.createObjectURL(blob); + prevImg.className = 'ruffle-snapshot-preview'; + mediaElem.insertBefore(prevImg, snapBtn); + }, 'image/jpeg', 0.92); + } else { + snapBtn.textContent = 'Capture Thumbnail'; + } + } catch(err) { + console.warn('[Ruffle snapshot]', err); + snapBtn.textContent = 'Capture Thumbnail'; + } + }; + }; + // Wire after a short delay (Ruffle may not be fully ready) + setTimeout(wireSnapshot, 200); + } catch(err) { + console.warn('[Ruffle upload preview]', err); + mediaElem.innerHTML = `
Flash preview error
`; + } + }); + }); } else if (file.type === 'application/pdf' || fileExt === 'pdf') { mediaElem = document.createElement('div'); mediaElem.className = 'generic-file-icon pdf-preview-icon'; @@ -1281,6 +1434,12 @@ window.initUploadForm = (selector) => { const idx = selectedFiles.indexOf(item); if (idx !== -1) selectedFiles.splice(idx, 1); item._rendered = false; + // Clean up Ruffle player and blob URL if this was a SWF preview + const swfPreview = previewItem.querySelector('.swf-upload-preview'); + if (swfPreview) { + if (swfPreview._rufflePlayer) { try { swfPreview._rufflePlayer.pause(); swfPreview._rufflePlayer.remove(); } catch {} swfPreview._rufflePlayer = null; } + if (swfPreview._swfObjectUrl) { URL.revokeObjectURL(swfPreview._swfObjectUrl); swfPreview._swfObjectUrl = null; } + } previewItem.remove(); if (selectedFiles.length === 0) { @@ -1393,13 +1552,9 @@ window.initUploadForm = (selector) => { } } - // Toggle custom thumbnail for single SWF batch + // Hide thumbSection for SWF (snapshot button now lives inside each file-preview-item) if (thumbSection) { - const firstItem = selectedFiles[0]; - const firstFile = (firstItem && firstItem.file) || firstItem; - const isSingleSwf = firstFile && selectedFiles.length === 1 && - (firstFile.type === 'application/x-shockwave-flash' || firstFile.type === 'application/vnd.adobe.flash.movie' || (firstFile.name && firstFile.name.endsWith('.swf'))); - thumbSection.style.display = isSingleSwf ? 'block' : 'none'; + thumbSection.style.display = 'none'; } updateSubmitButton(); @@ -1447,6 +1602,11 @@ window.initUploadForm = (selector) => { removeFile.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); + // Clean up any Ruffle preview players and blob URLs + filePreview?.querySelectorAll('.swf-upload-preview').forEach(el => { + if (el._rufflePlayer) { try { el._rufflePlayer.pause(); el._rufflePlayer.remove(); } catch {} el._rufflePlayer = null; } + if (el._swfObjectUrl) { URL.revokeObjectURL(el._swfObjectUrl); el._swfObjectUrl = null; } + }); selectedFiles = []; form.querySelector('.gps-privacy-warning')?.remove(); if (fileInput) fileInput.value = ''; @@ -1456,7 +1616,11 @@ window.initUploadForm = (selector) => { const media = filePreview?.querySelector('.preview-media'); if (media) media.remove(); - if (thumbSection) thumbSection.style.display = 'none'; + if (thumbSection) { + thumbSection.style.display = 'none'; + thumbSection.querySelector('.btn-ruffle-snapshot')?.remove(); + thumbSection.querySelector('.ruffle-snapshot-preview')?.remove(); + } if (thumbInput) thumbInput.value = ''; updateSubmitButton(); @@ -2108,8 +2272,19 @@ window.initUploadForm = (selector) => { localStorage.setItem('bustedThumbs', JSON.stringify(busted)); } catch(e) {} } - } else if (!isShitpost) { - throw new Error(res.msg || 'Upload failed'); + } else { + // Server returned an error — always surface it visibly + const errMsg = res.msg || 'Upload failed'; + if (isShitpost) { + // In shitpost mode there's no persistent statusDiv — use flash + if (typeof window.flashMessage === 'function') { + window.flashMessage(`✕ ${errMsg}`, 5000, 'error'); + } else if (typeof window.showFlash === 'function') { + window.showFlash(errMsg, 'error'); + } + } else { + throw new Error(errMsg); + } } } catch (err) { console.error('[UPLOAD ERROR]', err); @@ -2119,6 +2294,13 @@ window.initUploadForm = (selector) => { if (progressContainer) progressContainer.style.display = 'none'; restoreBtn(); return; + } else { + // Shitpost mode: show via flash toast + if (typeof window.flashMessage === 'function') { + window.flashMessage(`✕ ${err.message}`, 5000, 'error'); + } else if (typeof window.showFlash === 'function') { + window.showFlash(err.message, 'error'); + } } } } diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 055a7d9..01a8ef2 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -180,6 +180,14 @@ export const handleUpload = async (req, res, self) => { : Object.keys(cfg.mimes); let mime = file.contentType; + // Browsers often don't know the SWF MIME type and send application/octet-stream or nothing. + // Normalize it here based on extension so the allowedMimes check doesn't spuriously reject. + // The server-side `file --mime-type` check on line ~248 is the authoritative validation. + if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') && + file.filename && file.filename.toLowerCase().endsWith('.swf')) { + mime = 'application/x-shockwave-flash'; + } + if (!allowedMimes.includes(mime)) { return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400); } diff --git a/views/snippets/upload-form.html b/views/snippets/upload-form.html index 3ce11d0..27b704c 100644 --- a/views/snippets/upload-form.html +++ b/views/snippets/upload-form.html @@ -1,4 +1,4 @@ -
+
@if(web_url_upload)
@@ -48,14 +48,8 @@
@endif - - + +
@if(!shitpost_mode && enable_item_title)