window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => { return (unsafe || '').toString() .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }); // Throttled queue to capture the first frame of video files asynchronously without blocking the browser class VideoThumbnailQueue { constructor(concurrency = 3) { this.concurrency = concurrency; this.activeCount = 0; this.queue = []; } add(file, callback) { this.queue.push({ file, callback }); this.next(); } next() { if (this.activeCount >= this.concurrency || this.queue.length === 0) return; const { file, callback } = this.queue.shift(); this.activeCount++; this.capture(file) .then(dataUrl => callback(dataUrl)) .catch(err => { console.warn('[VideoThumbnailQueue] Error capturing thumbnail:', err); callback(null); }) .finally(() => { this.activeCount--; this.next(); }); } capture(file) { return new Promise((resolve, reject) => { const video = document.createElement('video'); const objectUrl = URL.createObjectURL(file); video.src = objectUrl; video.muted = true; video.playsInline = true; video.preload = 'metadata'; let cleanedUp = false; const cleanup = () => { if (cleanedUp) return; cleanedUp = true; video.src = ''; video.load(); URL.revokeObjectURL(objectUrl); }; video.onloadeddata = () => { video.currentTime = 0.1; }; video.onseeked = () => { try { const canvas = document.createElement('canvas'); const maxDim = 320; let width = video.videoWidth || 160; let height = video.videoHeight || 120; if (width > maxDim || height > maxDim) { if (width > height) { height = Math.round((height * maxDim) / width); width = maxDim; } else { width = Math.round((width * maxDim) / height); height = maxDim; } } canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, width, height); const dataUrl = canvas.toDataURL('image/jpeg', 0.8); cleanup(); resolve(dataUrl); } catch (e) { cleanup(); reject(e); } }; video.onerror = () => { cleanup(); reject(new Error('Video loading failed')); }; setTimeout(() => { if (!cleanedUp) { cleanup(); reject(new Error('Capture timeout')); } }, 8000); }); } } const videoThumbnailQueue = new VideoThumbnailQueue(3); window.initUploadForm = (selector) => { const form = (typeof selector === 'string') ? document.querySelector(selector) : selector; if (!form) return; // Prevent double-init if (form._f0ckInit) return form._f0ckUploader; form._f0ckInit = true; let isUploading = false; // Use querySelector to find elements within this specific form instance const fileInput = form.querySelector('.file-input'); const dropZone = form.querySelector('.drop-zone'); const filePreview = form.querySelector('.file-preview'); const dropZonePrompt = dropZone?.querySelector('.drop-zone-prompt'); const fileName = form.querySelector('.file-name'); const fileSize = form.querySelector('.file-size'); const removeFile = form.querySelector('.btn-remove'); const tagInput = form.querySelector('.tag-input'); const tagsList = form.querySelector('.tags-list'); const tagsHidden = form.querySelector('.tags-hidden'); const tagCount = form.querySelector('.tag-count'); const tagSuggestions = form.querySelector('.tag-suggestions'); const submitBtn = form.querySelector('.btn-upload'); const progressContainer = form.querySelector('.upload-progress'); const progressFill = form.querySelector('.progress-fill'); const progressText = form.querySelector('.progress-text'); const statusDiv = form.querySelector('.upload-status'); const thumbSection = form.querySelector('#custom-thumbnail-section'); const thumbInput = form.querySelector('#upload-thumbnail-input'); // Capture the SSR-translated initial button text so updateSubmitButton // always uses the correct language regardless of window.f0ckI18n state. const btnTextEl = submitBtn ? submitBtn.querySelector('.btn-text') : null; const ssrSelectFileText = btnTextEl ? btnTextEl.textContent.trim() : null; // Close modal when clicking a link in statusDiv (e.g. "View existing") if (statusDiv) { statusDiv.addEventListener('click', (e) => { const link = e.target.closest('a'); if (link) { const dragModal = form.closest('#upload-drag-modal'); if (dragModal) { dragModal.classList.remove('show'); document.body.classList.remove('modal-open'); if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') { form._f0ckUploader.reset(); } } } }); } // URL mode elements const urlInput = form.querySelector('#url-upload-input'); const urlBadge = form.querySelector('#url-type-badge'); const modeTabs = form.querySelectorAll('.upload-mode-tab'); const modeFile = form.querySelector('#mode-file'); const modeUrl = form.querySelector('#mode-url'); const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i; // Dynamically get min tags requirement from DOM const minTags = parseInt(form.getAttribute('data-min-tags') || '3'); const commentMaxLenAttr = form.getAttribute('data-comment-max-length'); const commentMaxLen = (commentMaxLenAttr && commentMaxLenAttr !== 'null') ? parseInt(commentMaxLenAttr) : null; const isShitpost = form.classList.contains('shitpost-mode-active') || !!window.f0ckShitpostMode; // Config-driven shitpost overrides const shitpostRequireRating = isShitpost && !!window.f0ckShitpostRequireRating; const shitpostMinTags = isShitpost ? (parseInt(window.f0ckShitpostMinTags) || 0) : 0; let tags = []; let autoTags = []; // Track tags suggested from metadata let selectedFiles = []; // Array of files for shitpost_mode let activeMode = 'file'; // 'file' or 'url' // Shared emoji cache for per-item pickers (fetched once, reused by all items) let _emojiCache = null; let _emojiCachePromise = null; const getEmojis = () => { if (_emojiCache) return Promise.resolve(_emojiCache); if (_emojiCachePromise) return _emojiCachePromise; _emojiCachePromise = fetch('/api/v2/emojis') .then(r => r.json()) .then(data => { if (data.success && data.emojis) { _emojiCache = {}; data.emojis.forEach(e => { _emojiCache[e.name] = e.url; }); } return _emojiCache || {}; }) .catch(() => ({})); return _emojiCachePromise; }; const setupItemEmojiPicker = (textarea, triggerBtn) => { // Always use standalone picker for per-item comments let picker = null; triggerBtn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); if (picker) { picker.style.display = picker.style.display === 'none' ? '' : 'none'; return; } const emojis = await getEmojis(); if (!emojis || !Object.keys(emojis).length) return; picker = document.createElement('div'); picker.className = 'emoji-picker item-emoji-picker'; Object.keys(emojis).forEach(name => { const img = document.createElement('img'); img.src = emojis[name]; img.title = `:${name}:`; img.loading = 'lazy'; img.onerror = () => { img.style.display = 'none'; }; img.onclick = (ev) => { ev.stopPropagation(); textarea.value += ` :${name}: `; textarea.dispatchEvent(new Event('input')); textarea.focus(); }; picker.appendChild(img); }); const container = triggerBtn.closest('.item-comment-container'); container.appendChild(picker); const closeHandler = (ev) => { if (!picker.contains(ev.target) && ev.target !== triggerBtn) { picker.style.display = 'none'; document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); }); }; // If shitpost mode is active, the global rating is hidden and per-item ratings are used. // We must remove the 'required' attribute from global radios to prevent browser from blocking submission. if (isShitpost) { form.querySelectorAll('input[name="rating"]').forEach(r => { r.required = false; }); } // --- Emoji picker for upload comment --- const uploadCommentContainer = form.querySelector('.upload-comment-input'); if (uploadCommentContainer) { const initUploadEmoji = async () => { // Reuse CommentSystem emoji picker if available if (window.commentSystem && typeof window.commentSystem.setupEmojiPicker === 'function') { // Ensure emojis are loaded (they won't be on upload page since no comments container) if (!CommentSystem.emojiCache || Object.keys(window.commentSystem.customEmojis).length === 0) { await window.commentSystem.loadEmojis(); } window.commentSystem.setupEmojiPicker(uploadCommentContainer); // Remove lock-thread button — doesn't belong on upload form const lockBtn = uploadCommentContainer.querySelector('#lock-thread-btn'); if (lockBtn) lockBtn.remove(); } else { // Standalone: load emojis and wire up trigger const textarea = uploadCommentContainer.querySelector('textarea'); if (!textarea) return; let emojis = null; try { const res = await fetch('/api/v2/emojis'); const data = await res.json(); if (data.success) { emojis = {}; data.emojis.forEach(e => { emojis[e.name] = e.url; }); } } catch (e) { console.error('Failed to load emojis', e); } if (!emojis || Object.keys(emojis).length === 0) return; const actions = uploadCommentContainer.querySelector('.input-actions'); if (!actions) return; const trigger = document.createElement('button'); trigger.type = 'button'; trigger.innerText = '☺'; trigger.className = 'emoji-trigger'; actions.prepend(trigger); let picker = null; trigger.addEventListener('click', (e) => { e.preventDefault(); if (picker) { picker.style.display = picker.style.display === 'none' ? '' : 'none'; return; } picker = document.createElement('div'); picker.className = 'emoji-picker'; Object.keys(emojis).forEach(name => { const img = document.createElement('img'); img.src = emojis[name]; img.title = `:${name}:`; img.loading = 'lazy'; img.onerror = () => { img.style.display = 'none'; }; img.onclick = (ev) => { ev.stopPropagation(); textarea.value += ` :${name}: `; textarea.focus(); }; picker.appendChild(img); }); uploadCommentContainer.appendChild(picker); const closeHandler = (ev) => { if (!picker.contains(ev.target) && ev.target !== trigger) { picker.style.display = 'none'; document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); }); } }; // Delay slightly to let CommentSystem init setTimeout(initUploadEmoji, 100); } // --- Mode tab switching --- if (modeTabs.length > 0) { modeTabs.forEach(tab => { tab.addEventListener('click', () => { const mode = tab.dataset.mode; if (mode === activeMode) return; activeMode = mode; modeTabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); if (modeFile) modeFile.style.display = mode === 'file' ? '' : 'none'; if (modeUrl) modeUrl.style.display = mode === 'url' ? '' : 'none'; // Reset status if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } if (progressContainer) progressContainer.style.display = 'none'; updateSubmitButton(); }); }); } // --- Keyboard shortcuts for Upload Modal --- const handleUploadShortcuts = (e) => { // Only run if the modal is visible or if we are on the dedicated upload page const dragModal = form.closest('#upload-drag-modal'); const isModalOpen = dragModal && dragModal.classList.contains('show'); const isUploadPage = window.location.pathname === '/upload'; if (!isModalOpen && !isUploadPage) return; // Don't trigger if user is typing in an input or textarea const tag = e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return; // One cycle per press if (e.repeat) return; const key = e.key.toLowerCase(); // 'p' cycles ratings: SFW -> NSFW -> (NSFL) -> SFW if (key === 'p') { e.preventDefault(); e.stopPropagation(); const ratings = Array.from(form.querySelectorAll('input[name="rating"]')); if (ratings.length > 0) { const checkedIndex = ratings.findIndex(r => r.checked); let nextIndex = (checkedIndex + 1) % ratings.length; if (checkedIndex === -1) nextIndex = 0; // Default to first if none checked ratings[nextIndex].checked = true; ratings[nextIndex].dispatchEvent(new Event('change')); } } // 't' toggles modes: File <-> URL if (key === 't') { e.preventDefault(); e.stopPropagation(); const currentActiveTab = form.querySelector('.upload-mode-tab.active'); const tabs = Array.from(form.querySelectorAll('.upload-mode-tab')); if (tabs.length > 1) { const currentIndex = tabs.indexOf(currentActiveTab); const nextIndex = (currentIndex + 1) % tabs.length; tabs[nextIndex].click(); // Focus URL input if we switched to URL mode if (tabs[nextIndex].dataset.mode === 'url' && urlInput) { setTimeout(() => urlInput.focus(), 50); } } } // 'Ctrl + .' focuses the comment input if (e.key === '.' && (e.ctrlKey || e.metaKey)) { const commentInput = form.querySelector('.upload-comment'); if (commentInput) { e.preventDefault(); e.stopPropagation(); commentInput.focus(); } } // 'i' focuses the tag input if (key === 'i') { const tagInput = form.querySelector('.tag-input'); if (tagInput) { e.preventDefault(); e.stopPropagation(); tagInput.focus(); } } // Block 'z' (Random) from triggering in the background if (key === 'z') { e.preventDefault(); e.stopPropagation(); } }; window.addEventListener('keydown', handleUploadShortcuts, true); // Use capture phase to block theme cycling and rating toggle on the item page // --- URL type detection & Metadata extraction --- let metaDebounce = null; let lastFetchedUrl = ''; // Cache: url -> string[] of suggestion fields (populated by fetchMetadata) const metaCache = new Map(); // Extracts a flat list of suggestion strings from a /api/v2/meta/fetch response meta object const extractFieldsFromMeta = (meta) => { const fields = []; const push = (v) => { if (v && typeof v === 'string' && v.trim()) fields.push(v.trim()); }; const pushArr = (arr) => { if (Array.isArray(arr)) arr.forEach(push); }; // Title (prefer og_title, strip site name suffix) let bestTitle = meta.og_title || meta.twitter_title || meta.ld_headline || meta.title || null; if (bestTitle && meta.site_name) { const siteRegex = new RegExp(`\\s*[-|·]\\s*${meta.site_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'); bestTitle = bestTitle.replace(siteRegex, '').trim(); } push(bestTitle); push(meta.site_name); push(meta.og_desc || meta.description || meta.twitter_desc || meta.ld_desc); push(meta.author || meta.article_author || meta.book_author || meta.music_musician || meta.ld_author || meta.ld_creator); push(meta.profile_name); push(meta.profile_username); pushArr(meta.keywords); pushArr(meta.news_keywords); pushArr(meta.ld_keywords); pushArr(meta.article_tags); push(meta.article_section); push(meta.category); push(meta.genre || meta.ld_genre); push(meta.og_type); pushArr(meta.video_tags); push(meta.music_album); push(meta.music_song); pushArr(meta.book_tags); pushArr(meta.twitter_labels); push(meta.twitter_creator); push(meta.ld_name); push(meta.ld_about); if (meta.price_amount) push(`${meta.price_amount}${meta.price_currency ? ' ' + meta.price_currency : ''}`); push(meta.language); return [...new Set(fields)]; }; if (urlInput) { const fetchMetadata = async (url) => { const currentVal = urlInput.value.trim(); if (!currentVal || currentVal !== url) return; const isDirectMedia = /\.(mp4|webm|mp3|ogg|opus|flac|m4a|mkv|jpg|jpeg|png|gif|webp|swf)$/i.test(currentVal); if (isDirectMedia) return; lastFetchedUrl = currentVal; // In shitpost mode: fetch silently (cache only, badge stays hidden) if (isShitpost) { try { const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`); const data = await resp.json(); if (data.success && data.meta) { const fields = extractFieldsFromMeta(data.meta); metaCache.set(currentVal, fields); fields.forEach(v => addMetaSuggestion(v)); } } catch (e) { console.error('[META FETCH ERROR]', e); } return; } if (urlBadge) { const originalText = urlBadge.textContent; const originalClass = urlBadge.className; const originalDisplay = urlBadge.style.display; urlBadge.innerHTML = ' Fetching...'; urlBadge.className = 'url-type-badge fetching'; urlBadge.style.display = 'flex'; try { const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`); const data = await resp.json(); if (data.success && data.meta) { const fields = extractFieldsFromMeta(data.meta); // Cache the fields for instant use when item is committed metaCache.set(currentVal, fields); fields.forEach(v => addMetaSuggestion(v)); urlBadge.innerHTML = '✓ Metadata Fetched'; urlBadge.className = 'url-type-badge success'; urlBadge.style.display = 'block'; setTimeout(() => { if (urlInput.value.trim() === currentVal) { urlBadge.textContent = originalText; urlBadge.className = originalClass; urlBadge.style.display = originalDisplay; } }, 2500); } else { // Restore on fail urlBadge.textContent = originalText; urlBadge.className = originalClass; urlBadge.style.display = originalDisplay; } } catch (e) { console.error('[META FETCH ERROR]', e); urlBadge.textContent = originalText; urlBadge.className = originalClass; urlBadge.style.display = originalDisplay; } } }; urlInput.addEventListener('input', () => { const val = urlInput.value.trim(); if (!val) { if (urlBadge) urlBadge.style.display = 'none'; lastFetchedUrl = ''; updateSubmitButton(); return; } // Update badge for URL type (non-shitpost only) if (urlBadge && !isShitpost) { if (ytRegex.test(val) && window.f0ckEnableYoutubeUpload !== false) { urlBadge.textContent = '▶ YouTube'; urlBadge.className = 'url-type-badge youtube'; urlBadge.style.display = 'block'; urlBadge.title = 'Click to re-fetch metadata'; } else if (/^https?:\/\//i.test(val)) { urlBadge.textContent = 'URL'; urlBadge.className = 'url-type-badge direct'; urlBadge.style.display = 'block'; urlBadge.title = 'Click to re-fetch metadata'; } else { urlBadge.style.display = 'none'; } } // Trigger metadata fetch if it looks like a valid URL and not already fetching/fetched const isDirectMedia = /\.(mp4|webm|mp3|ogg|opus|flac|m4a|mkv|jpg|jpeg|png|gif|webp|swf)$/i.test(val); if (/^https?:\/\//i.test(val) && val !== lastFetchedUrl && !isDirectMedia) { clearTimeout(metaDebounce); metaDebounce = setTimeout(() => fetchMetadata(val), 800); } updateSubmitButton(); }); // In shitpost mode: auto-commit each pasted URL to the list immediately urlInput.addEventListener('paste', (e) => { if (!isShitpost) return; e.preventDefault(); const pasted = (e.clipboardData || window.clipboardData).getData('text'); const lines = pasted.split(/[\n\r]+/).map(u => u.trim()).filter(u => /^https?:\/\//i.test(u)); if (!lines.length) { // Not a URL — let the browser insert it normally into the input urlInput.value = pasted.trim(); urlInput.dispatchEvent(new Event('input')); return; } lines.forEach(url => { if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) { selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false }); } }); urlInput.value = ''; if (urlBadge) urlBadge.style.display = 'none'; handleFile(); }); if (urlBadge && !isShitpost) { urlBadge.addEventListener('click', () => { const val = urlInput.value.trim(); if (val && /^https?:\/\//i.test(val)) { lastFetchedUrl = ''; // Force retry fetchMetadata(val); } }); } // Add URL button (shitpost mode) — commits the typed/pasted URL to the list const addUrlFn = () => { const val = urlInput.value.trim(); if (!val || !/^https?:\/\//i.test(val)) return; if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) { selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false }); } urlInput.value = ''; if (urlBadge) urlBadge.style.display = 'none'; handleFile(); }; const btnAddUrls = form.querySelector('.btn-add-urls'); if (btnAddUrls) { btnAddUrls.addEventListener('click', addUrlFn); } // Also commit on Enter key inside the URL input in shitpost mode if (isShitpost) { urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addUrlFn(); } }); } } const formatSize = (bytes) => { const units = ['B', 'KB', 'MB', 'GB']; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return bytes.toFixed(2) + ' ' + units[i]; }; const updateSubmitButton = () => { if (isUploading) { if (submitBtn) submitBtn.disabled = true; return; } const isShitpost = !!window.f0ckShitpostMode; const rating = form.querySelector('input[name="rating"]:checked'); // In Shitpost Mode, ratings are per-item. If require rating is true, every item must be rated. let hasRating = true; if (isShitpost && activeMode === 'file') { if (shitpostRequireRating) { hasRating = selectedFiles.length > 0 && selectedFiles.every(item => ['sfw', 'nsfw', 'nsfl'].includes(item.rating)); } } else { hasRating = (rating !== null); } let hasTags = true; if (!isShitpost) { hasTags = tags.length >= minTags; } else if (shitpostMinTags > 0 && activeMode === 'file') { // In shitpost file mode with min-tags enforced: every queued item must meet the threshold. hasTags = selectedFiles.length === 0 || selectedFiles.every(item => (item.tags || []).length >= shitpostMinTags); } // Toggle visibility of global rating/comment/tag sections const ratingSec = form.querySelector('.global-rating-section'); const commentSec = form.querySelector('.global-comment-section'); const tagsSec = form.querySelector('.global-tag-section'); const ocSec = form.querySelector('.global-oc-section'); const formActions = form.querySelector('.form-actions'); if (isShitpost) { if (formActions) { formActions.style.display = activeMode === 'url' ? 'none' : 'block'; } const hide = activeMode === 'file'; const disp = hide ? 'none' : 'block'; if (ratingSec) { ratingSec.style.display = disp; ratingSec.querySelectorAll('input').forEach(i => i.disabled = hide); } if (commentSec) { commentSec.style.display = disp; commentSec.querySelectorAll('textarea').forEach(i => i.disabled = hide); } if (tagsSec) { tagsSec.style.display = disp; tagsSec.querySelectorAll('input').forEach(i => i.disabled = hide); } if (ocSec) { ocSec.style.display = 'none'; ocSec.querySelectorAll('input').forEach(i => i.disabled = true); } } let hasContent = false; if (activeMode === 'file') { hasContent = selectedFiles.length > 0; } else { hasContent = urlInput && urlInput.value.trim().length > 0; } if (submitBtn) submitBtn.disabled = !(hasContent && hasRating && hasTags); if (submitBtn) { const btnText = submitBtn.querySelector('.btn-text'); if (btnText) { const i18n = window.f0ckI18n || {}; if (!hasContent) { btnText.textContent = activeMode === 'file' ? (ssrSelectFileText || i18n.select_file || 'Select a file') : (i18n.enter_url || 'Enter a URL'); } else if (!hasTags) { // non-shitpost or shitpost with min-tags if (isShitpost && shitpostMinTags > 0) { const remaining = shitpostMinTags - Math.min(...selectedFiles.map(item => (item.tags || []).length)); btnText.textContent = `${remaining} more tag${remaining !== 1 ? 's' : ''} required per item`; } else { const remaining = minTags - tags.length; const tpl = i18n.tags_required || '{n} more tag{s} required'; btnText.textContent = tpl .replace('{n}', remaining) .replace('{s}', remaining !== 1 ? 's' : ''); } } else if (!hasRating) { const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]'); if (isShitpost && shitpostRequireRating) { btnText.textContent = 'Select a rating for each item'; } else { if (nsflEnabled) { btnText.textContent = i18n.select_rating_nsfl || 'Select SFW, NSFW or NSFL'; } else { btnText.textContent = i18n.select_rating || 'Select SFW or NSFW'; } } } else { if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) { btnText.textContent = i18n.embed_youtube || 'Embed YouTube Video'; } else if (activeMode === 'url') { btnText.textContent = i18n.upload_from_url || 'Upload from URL'; } else { btnText.textContent = i18n.upload || 'Upload'; } } } } if (tagCount) { if (minTags > 0) { tagCount.textContent = '(' + tags.length + '/' + minTags + ' minimum)'; tagCount.classList.toggle('valid', tags.length >= minTags); } else { tagCount.style.display = 'none'; } } }; const handleFile = (files) => { const isShitpost = !!window.f0ckShitpostMode; // If files were provided, process them (append or replace) if (files && files.length > 0) { const filesToProcess = isShitpost ? Array.from(files) : [files[0]]; if (!isShitpost) { selectedFiles = []; // Reset for normal mode — replace, not append // Also wipe the preview DOM so the old card doesn't linger if (filePreview) filePreview.innerHTML = ''; } for (const file of filesToProcess) { if (!file) continue; // 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 { const mimesObj = JSON.parse(mimesSource || '{}'); allowedMimes = Object.keys(mimesObj); allowedExts = [...new Set(Object.values(mimesObj))]; } catch(e) { allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/webm', 'video/quicktime', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/flac', 'audio/x-flac', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'application/x-shockwave-flash', 'application/vnd.adobe.flash.movie']; } 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); if (allowedMimes.length > 0 && !mimeOk && !extOk) { const errorMsg = `File type ${file.type || '.' + fileExt} is not allowed.`; if (window.showFlash) window.showFlash(errorMsg, 'error'); else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; } continue; } const maxBytesAttr = form.getAttribute('data-max-bytes'); if (maxBytesAttr) { const maxBytes = parseInt(maxBytesAttr); if (file.size > maxBytes) { const errorMsg = `File is too large: ${formatSize(file.size)} (Limit: ${formatSize(maxBytes)})`; if (window.showFlash) window.showFlash(errorMsg, 'error'); else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; } continue; } } if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) { if (isShitpost) { selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false }); } else { selectedFiles.push(file); // Legacy single file mode uses raw File } } } } // Rebuild UI state if (selectedFiles.length === 0) { if (filePreview) { filePreview.style.display = 'none'; filePreview.innerHTML = ''; } if (dropZonePrompt) dropZonePrompt.style.display = 'block'; if (fileInput) { fileInput.value = ''; fileInput.style.display = 'inline-block'; } updateSubmitButton(); return false; } if (dropZonePrompt) dropZonePrompt.style.display = 'none'; if (fileInput) fileInput.style.display = 'none'; if (filePreview) { filePreview.style.display = 'flex'; // Do NOT clear innerHTML here — existing rendered items stay in place. // Only new (un-rendered) items will be appended below. } if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } // Force 'file' mode tab UI if (activeMode !== 'file' && modeTabs.length > 0) { modeTabs.forEach(t => t.classList.remove('active')); const fileTab = form.querySelector('.upload-mode-tab[data-mode="file"]'); if (fileTab) fileTab.classList.add('active'); if (modeFile) modeFile.style.display = ''; if (modeUrl) modeUrl.style.display = 'none'; activeMode = 'file'; } let lastNewLinkPreviewItem = null; // Build preview items — skip items already rendered (append-only) selectedFiles.forEach((item, index) => { if (item._rendered) return; // already in DOM, don't touch it const isUrl = isShitpost && item.type === 'url'; const file = isUrl ? null : (isShitpost ? item.file : item); const previewItem = document.createElement('div'); previewItem.className = 'file-preview-item' + (isUrl ? ' url-item' : ''); let mediaElem; // For URL items: wrap media + badge in a column div let mediaCol = null; let itemBadge = null; if (isUrl) { const isYtMatch = item.url.match(ytRegex); if (isYtMatch && window.f0ckEnableYoutubeUpload !== false) { const videoId = isYtMatch[1]; mediaElem = document.createElement('iframe'); mediaElem.src = `https://www.youtube.com/embed/${videoId}?autoplay=0&mute=1`; mediaElem.width = "100%"; mediaElem.height = "100%"; mediaElem.frameBorder = "0"; mediaElem.allow = "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"; mediaElem.allowFullscreen = true; mediaElem.style.borderRadius = '4px'; mediaElem.style.pointerEvents = 'auto'; // Badge looks like the URL-tab YouTube badge itemBadge = document.createElement('div'); itemBadge.className = 'url-type-badge youtube'; itemBadge.textContent = '▶ YouTube'; itemBadge.title = window.f0ckI18n?.refetch_metadata || 'Click to re-fetch metadata'; itemBadge.style.display = 'block'; itemBadge.style.cursor = 'pointer'; itemBadge.style.marginTop = '6px'; } else { mediaElem = document.createElement('div'); mediaElem.className = 'generic-file-icon'; mediaElem.innerHTML = ''; // Badge looks like the URL-tab direct badge itemBadge = document.createElement('div'); itemBadge.className = 'url-type-badge direct'; itemBadge.textContent = 'URL'; itemBadge.title = window.f0ckI18n?.refetch_metadata || 'Click to re-fetch metadata'; itemBadge.style.display = 'block'; itemBadge.style.cursor = 'pointer'; itemBadge.style.marginTop = '6px'; } // Wrap media + badge in a column mediaCol = document.createElement('div'); mediaCol.className = 'item-media-col'; } else { const fileExt = file.name.split('.').pop().toLowerCase(); if (file.type.startsWith('image/')) { mediaElem = document.createElement('img'); mediaElem.src = URL.createObjectURL(file); } else if (file.type.startsWith('video/')) { mediaElem = document.createElement('video'); mediaElem.src = URL.createObjectURL(file); mediaElem.muted = true; mediaElem.controls = true; mediaElem.loop = true; if (isShitpost) { mediaElem.autoplay = false; mediaElem.preload = 'none'; mediaElem.classList.add('video-thumbnail-loading'); videoThumbnailQueue.add(file, (dataUrl) => { if (dataUrl) { mediaElem.poster = dataUrl; mediaElem.classList.remove('video-thumbnail-loading'); } else { mediaElem.classList.remove('video-thumbnail-loading'); } }); } else { mediaElem.autoplay = true; } } else if (file.type.startsWith('audio/')) { mediaElem = document.createElement('audio'); mediaElem.src = URL.createObjectURL(file); mediaElem.controls = true; 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 = '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'; mediaElem.innerHTML = ''; } else { mediaElem = document.createElement('div'); mediaElem.className = 'generic-file-icon'; mediaElem.innerHTML = ''; } } // preview-media-small sizing only applies to standalone (non-col) media if (!mediaCol) { mediaElem.classList.add('preview-media-small'); } // If URL item: finish building the media column (media + badge below) if (mediaCol) { mediaCol.appendChild(mediaElem); if (itemBadge) mediaCol.appendChild(itemBadge); } const infoRow = document.createElement('div'); infoRow.className = 'file-meta-row-small'; let ratingSwitch = ''; let tagsUI = ''; let ocUI = ''; let commentUI = ''; let titleUI = ''; if (isShitpost) { const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]'); // Build per-item rating HTML const ratingValue = item.rating; ratingSwitch = `
${nsflEnabled ? ` ` : ''}
`; const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...'; const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : ''; tagsUI = `
`; if (window.f0ckEnableItemTitle !== false) { titleUI = `
`; } const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'AddComment...'; const maxLenHtml = (commentMaxLen !== null && !isNaN(commentMaxLen)) ? ` maxlength="${commentMaxLen}"` : ''; commentUI = `
`; } const fileNameStr = isUrl ? item.url : file.name; const fileSizeStr = isUrl ? ((item.url.match(ytRegex) && window.f0ckEnableYoutubeUpload !== false) ? 'YouTube' : 'URL') : formatSize(file.size); infoRow.innerHTML = `
${window.escapeHtmlUpload(fileNameStr)} ${fileSizeStr}
${titleUI} ${ratingSwitch} ${tagsUI} ${commentUI} `; if (isShitpost) { // Handle Rating infoRow.querySelectorAll('.item-rating-option input').forEach(radio => { radio.onchange = () => { item.rating = radio.value; updateSubmitButton(); }; }); // Handle Comment const commentInput = infoRow.querySelector('.item-comment-input'); const emojiTrigger = infoRow.querySelector('.item-emoji-trigger'); if (commentInput) { commentInput.oninput = () => { item.comment = commentInput.value; }; if (emojiTrigger) setupItemEmojiPicker(commentInput, emojiTrigger); } // Handle Title const titleInput = infoRow.querySelector('.item-title-input'); if (titleInput) { titleInput.oninput = () => { item.title = titleInput.value.trim(); }; } // Handle Tags const tagList = infoRow.querySelector('.item-tags-list'); const tagInput = infoRow.querySelector('.item-tag-input'); const tagSuggestions = infoRow.querySelector('.tag-suggestions'); const suggCont = infoRow.querySelector('.item-meta-suggestions'); const renderTags = () => { if (!tagList) return; tagList.innerHTML = ''; item.tags.forEach((t, i) => { const chip = document.createElement('span'); chip.className = 'item-tag-chip'; chip.innerHTML = `${window.escapeHtmlUpload(t)} `; chip.querySelector('.item-tag-remove').onclick = () => { item.tags.splice(i, 1); renderTags(); updateSubmitButton(); }; tagList.appendChild(chip); }); }; renderTags(); const addItemTag = (val) => { val = val.trim(); if (val && !item.tags.includes(val)) { item.tags.push(val); renderTags(); updateSubmitButton(); } }; // Initialize autocomplete for THIS item if (tagInput && tagSuggestions) { window.f0ckInitTagAutocomplete(tagInput, tagSuggestions, addItemTag, () => item.tags); } // Metadata Suggestions for THIS item const addMetaSuggestionToItem = (val) => { if (!suggCont) return; suggCont.style.display = 'flex'; const sugg = document.createElement('div'); sugg.className = 'item-meta-suggestion'; sugg.innerHTML = ` ${window.escapeHtmlUpload(val)}`; sugg.addEventListener('mouseup', (ev) => { const sel = window.getSelection?.()?.toString().trim(); if (!sel || sel === val) return; sugg.addEventListener('click', (e) => e.stopImmediatePropagation(), { once: true, capture: true }); ev.stopPropagation(); window._showSelTagPopover?.(sel, sugg, (confirmed) => { window.getSelection?.()?.removeAllRanges(); addItemTag(confirmed); }); }); sugg.onclick = () => { addItemTag(val); sugg.classList.add('selected'); const icon = sugg.querySelector('i'); if (icon) icon.className = 'fa fa-check-circle'; }; suggCont.appendChild(sugg); }; // Trigger Extraction for this item if (isUrl) { // Helper: populate suggestions for this item from a fields array const populateSuggestionsFromFields = (fields) => { fields.forEach(val => addMetaSuggestionToItem(val)); }; // The itemBadge IS the refetch button — same badge as the URL tab const isYt = ytRegex.test(item.url) && window.f0ckEnableYoutubeUpload !== false; const badgeLabel = isYt ? '▶ YouTube' : 'URL'; const badgeClass = isYt ? 'url-type-badge youtube' : 'url-type-badge direct'; // Helper: drive the badge through fetching → success → restore const setBadgeFetching = () => { if (!itemBadge) return; itemBadge.innerHTML = ' Fetching...'; itemBadge.className = 'url-type-badge fetching'; itemBadge.style.display = 'flex'; itemBadge.style.pointerEvents = 'none'; }; const setBadgeSuccess = () => { if (!itemBadge) return; itemBadge.innerHTML = '✓ Metadata Fetched'; itemBadge.className = 'url-type-badge success'; itemBadge.style.display = 'block'; setTimeout(() => { if (itemBadge) { itemBadge.textContent = badgeLabel; itemBadge.className = badgeClass; itemBadge.style.pointerEvents = 'auto'; } }, 2500); }; const setBadgeRestored = () => { if (!itemBadge) return; itemBadge.textContent = badgeLabel; itemBadge.className = badgeClass; itemBadge.style.display = 'block'; itemBadge.style.pointerEvents = 'auto'; }; // Helper: fetch metadata for this item const fetchItemMeta = async (forceFetch = false) => { if (suggCont) suggCont.style.display = 'flex'; // Cache hit → instant, no network call needed if (!forceFetch && metaCache.has(item.url)) { populateSuggestionsFromFields(metaCache.get(item.url)); setBadgeSuccess(); return; } // Dim existing suggestions in-place — no removal, no layout jump const staleChips = suggCont ? Array.from(suggCont.querySelectorAll('.item-meta-suggestion')) : []; staleChips.forEach(s => { s.style.opacity = '0.3'; s.style.pointerEvents = 'none'; }); setBadgeFetching(); try { const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(item.url)}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.success && data.meta) { const fields = extractFieldsFromMeta(data.meta); metaCache.set(item.url, fields); // Now it's safe to clear — new content is ready to fill the space staleChips.forEach(s => s.remove()); populateSuggestionsFromFields(fields); setBadgeSuccess(); } else { // Restore stale chips on failure staleChips.forEach(s => { s.style.opacity = ''; s.style.pointerEvents = ''; }); setBadgeRestored(); } } catch (e) { console.warn('[URL ITEM META SYNC ERROR]', e.message); staleChips.forEach(s => { s.style.opacity = ''; s.style.pointerEvents = ''; }); setBadgeRestored(); } }; // Badge click = re-fetch if (itemBadge) { itemBadge.addEventListener('click', (e) => { e.stopPropagation(); metaCache.delete(item.url); fetchItemMeta(true); }); } // Populate suggestions (instant if cached, otherwise network) fetchItemMeta(false); } else { // Add filename as first suggestion if (file.name) { let baseName = file.name; const lastDotIndex = baseName.lastIndexOf('.'); if (lastDotIndex > 0) baseName = baseName.substring(0, lastDotIndex); baseName = baseName.trim(); if (baseName) addMetaSuggestionToItem(baseName); } if (files && Array.from(files).includes(file)) { (async () => { const chunkSize = (file.type && file.type.startsWith('image/')) ? 512 * 1024 : 4 * 1024 * 1024; const chunk = file.slice(0, chunkSize); const formData = new FormData(); formData.append('file', chunk, file.name); try { const resp = await fetch('/api/v2/meta/extract-file', { method: 'POST', headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '', 'X-Requested-With': 'XMLHttpRequest' }, body: formData }); const data = await resp.json(); if (data.success && data.fields) { data.fields.forEach(val => addMetaSuggestionToItem(val)); } if (data.hasGpsData) showGpsPrivacyWarning(file); } catch (e) { console.warn('[ITEM META SYNC ERROR]', e.message); } })(); } } } const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'btn-remove-small'; removeBtn.innerHTML = '✕'; removeBtn.onclick = (e) => { e.stopPropagation(); // Remove by reference (index may have shifted after prior removals) 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) { // Reset to empty state if (filePreview) { filePreview.style.display = 'none'; filePreview.innerHTML = ''; } if (dropZonePrompt) dropZonePrompt.style.display = 'block'; if (fileInput) { fileInput.value = ''; fileInput.style.display = 'inline-block'; } } else { // Ensure the "Add more" button is still last const addMoreEl = filePreview?.querySelector('.add-more-item'); if (addMoreEl) filePreview.appendChild(addMoreEl); } updateSubmitButton(); }; item._rendered = true; // Append media column (URL items) or raw media element (file items) if (mediaCol) { previewItem.appendChild(mediaCol); } else { previewItem.appendChild(mediaElem); } previewItem.appendChild(infoRow); previewItem.appendChild(removeBtn); if (filePreview) filePreview.appendChild(previewItem); if (isUrl) { lastNewLinkPreviewItem = previewItem; } }); // "Add more" button for Shitpost Mode — reuse existing or create once, always move to end if (isShitpost && filePreview) { let addMoreItem = filePreview.querySelector('.add-more-item'); if (!addMoreItem) { addMoreItem = document.createElement('div'); addMoreItem.className = 'file-preview-item add-more-item'; addMoreItem.innerHTML = `${window.f0ckI18n?.upload_add_more || 'Add more'}`; addMoreItem.onclick = () => fileInput && fileInput.click(); } filePreview.appendChild(addMoreItem); // moves it to end if already present } // Legacy Global Meta Sync (Non-Shitpost Mode) if (!isShitpost && selectedFiles.length > 0 && files && files.length > 0) { const primaryFile = selectedFiles[0]; autoTags = []; const metaCont = form.querySelector('.meta-suggestions-container'); const metaList = form.querySelector('.meta-suggestions-list'); if (metaList) metaList.innerHTML = ''; if (metaCont) metaCont.style.display = 'none'; form.querySelector('.gps-privacy-warning')?.remove(); if (primaryFile.name) { let baseName = primaryFile.name; const lastDotIndex = baseName.lastIndexOf('.'); if (lastDotIndex > 0) baseName = baseName.substring(0, lastDotIndex); baseName = baseName.trim(); if (baseName && !autoTags.includes(baseName)) { autoTags.push(baseName); addMetaSuggestion(baseName); } } const isMedia = (primaryFile.type && primaryFile.type.startsWith('video/')) || (primaryFile.type && primaryFile.type.startsWith('audio/')) || (primaryFile.type && primaryFile.type.startsWith('image/')) || /\.(mp4|webm|mp3|ogg|wav|m4a|flac|jpg|jpeg|png|gif|webp|tiff?)$/i.test(primaryFile.name); if (isMedia) { const extractTags = async () => { const chunkSize = (primaryFile.type && primaryFile.type.startsWith('image/')) ? 512 * 1024 : 4 * 1024 * 1024; const chunk = primaryFile.slice(0, chunkSize); const formData = new FormData(); formData.append('file', chunk, primaryFile.name); const status = form.querySelector('.tag-input-container .sync-spinner'); if (status) status.classList.add('active'); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); const resp = await fetch('/api/v2/meta/extract-file', { method: 'POST', headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '', 'X-Requested-With': 'XMLHttpRequest' }, body: formData, signal: controller.signal }); clearTimeout(timeoutId); const data = await resp.json(); if (data.success && data.fields) { data.fields.forEach(val => { if (!autoTags.includes(val)) { autoTags.push(val); addMetaSuggestion(val); } }); } if (data.hasGpsData) showGpsPrivacyWarning(primaryFile); } catch (e) { console.warn('[META SYNC ERROR]', e.message); } finally { if (status) status.classList.remove('active'); } }; extractTags(); } } // Hide thumbSection for SWF (snapshot button now lives inside each file-preview-item) if (thumbSection) { thumbSection.style.display = 'none'; } updateSubmitButton(); form.dispatchEvent(new CustomEvent('fileReady', { detail: { files: selectedFiles } })); if (lastNewLinkPreviewItem) { setTimeout(() => { lastNewLinkPreviewItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 100); } return true; }; if (dropZone) { const preventDefaults = (e) => { e.preventDefault(); e.stopPropagation(); }; ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, preventDefaults, false); }); ['dragenter', 'dragover'].forEach(eventName => { dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false); }); ['dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false); }); dropZone.addEventListener('drop', (e) => { const dt = e.dataTransfer; const files = dt.files; handleFile(files); }); } if (fileInput) { fileInput.addEventListener('change', (e) => handleFile(e.target.files)); } if (removeFile) { 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 = ''; if (dropZonePrompt) dropZonePrompt.style.display = 'block'; if (fileInput) fileInput.style.display = 'inline-block'; if (filePreview) filePreview.style.display = 'none'; const media = filePreview?.querySelector('.preview-media'); if (media) media.remove(); if (thumbSection) { thumbSection.style.display = 'none'; thumbSection.querySelector('.btn-ruffle-snapshot')?.remove(); thumbSection.querySelector('.ruffle-snapshot-preview')?.remove(); } if (thumbInput) thumbInput.value = ''; updateSubmitButton(); }); } const addTag = (tagName) => { tagName = tagName.trim(); if (!tagName || tags.some(t => t.toLowerCase() === tagName.toLowerCase())) return; if (['sfw', 'nsfw', 'nsfl'].includes(tagName.toLowerCase())) return; if (/^https?:\/\//i.test(tagName)) { if (typeof window.showFlash === 'function') { window.showFlash('Post that in the comments', 'error'); } if (tagInput) tagInput.value = ''; return; } tags.push(tagName); const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.style.cursor = 'pointer'; chip.title = 'Click to edit prefix or tag'; chip.innerHTML = `${window.escapeHtmlUpload(tagName)}`; // Remove button logic chip.querySelector('button').addEventListener('click', (e) => { e.stopPropagation(); tags = tags.filter(t => t !== tagName); chip.remove(); syncMetaSuggestions(); updateSubmitButton(); }); // Edit logic: clicking the text moves it back to input chip.querySelector('.tag-text').addEventListener('click', () => { tags = tags.filter(t => t !== tagName); chip.remove(); if (tagInput) { tagInput.value = tagName; tagInput.focus(); } syncMetaSuggestions(); updateSubmitButton(); }); if (tagsList) tagsList.appendChild(chip); if (tagsHidden) tagsHidden.value = tags.join(','); if (tagInput) tagInput.value = ''; if (tagSuggestions) { tagSuggestions.innerHTML = ''; tagSuggestions.style.display = 'none'; } syncMetaSuggestions(); updateSubmitButton(); }; const showGpsPrivacyWarning = (originalFile) => { // Remove any existing warning const existing = form.querySelector('.gps-privacy-warning'); if (existing) existing.remove(); const warn = document.createElement('div'); warn.className = 'gps-privacy-warning'; warn.innerHTML = ` Location data detected. This image contains GPS coordinates that reveal where it was taken. `; const stripBtn = warn.querySelector('.gps-strip-btn'); const dismissBtn = warn.querySelector('.gps-dismiss-btn'); dismissBtn.addEventListener('click', () => warn.remove()); stripBtn.addEventListener('click', async () => { stripBtn.disabled = true; stripBtn.textContent = 'Stripping…'; try { const formData = new FormData(); formData.append('file', originalFile, originalFile.name); const resp = await fetch('/api/v2/meta/strip-gps', { method: 'POST', headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '', 'X-Requested-With': 'XMLHttpRequest', }, body: formData, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); const cleanFile = new File([blob], originalFile.name, { type: originalFile.type || blob.type }); // Replace the selected file in the array so the upload uses the clean version const isShitpost = !!window.f0ckShitpostMode; const idx = selectedFiles.findIndex(f => (f.file || f) === originalFile); if (idx !== -1) { if (isShitpost && typeof selectedFiles[idx] === 'object') { selectedFiles[idx].file = cleanFile; } else { selectedFiles[idx] = cleanFile; } } warn.innerHTML = ` GPS data stripped. Location will not be embedded in the uploaded image.`; warn.classList.add('gps-stripped'); setTimeout(() => warn.remove(), 5000); } catch (e) { console.warn('[GPS STRIP ERROR]', e.message); stripBtn.disabled = false; stripBtn.textContent = 'Strip GPS data'; if (typeof window.showFlash === 'function') window.showFlash('Failed to strip GPS data', 'error'); console.error('[GPS STRIP ERROR]', err); } }); // Insert above the tag controls area const tagControls = form.querySelector('.tag-controls') || form.querySelector('.tag-input-container'); if (tagControls) { tagControls.parentNode.insertBefore(warn, tagControls); } else { form.appendChild(warn); } }; const addMetaSuggestion = (text) => { text = text.trim(); if (!text) return; const metaCont = form.querySelector('.meta-suggestions-container'); const metaList = form.querySelector('.meta-suggestions-list'); if (!metaCont || !metaList) return; // Don't suggest duplicates in the suggestions list if (Array.from(metaList.children).some(el => el.getAttribute('data-text') === text)) return; metaCont.style.display = 'block'; const sug = document.createElement('div'); sug.className = 'meta-suggestion'; sug.setAttribute('data-text', text); sug.innerHTML = ` ${window.escapeHtmlUpload(text)}`; sug.addEventListener('mouseup', (ev) => { const sel = window.getSelection?.()?.toString().trim(); if (!sel) return; sug.addEventListener('click', (e) => e.stopImmediatePropagation(), { once: true, capture: true }); ev.stopPropagation(); window._showSelTagPopover?.(sel, sug, (confirmed) => { window.getSelection?.()?.removeAllRanges(); addTag(confirmed); }); }); sug.addEventListener('click', () => { addTag(text); }); metaList.appendChild(sug); syncMetaSuggestions(); }; const syncMetaSuggestions = () => { const metaList = form.querySelector('.meta-suggestions-list'); if (!metaList) return; Array.from(metaList.children).forEach(sug => { const text = sug.getAttribute('data-text'); if (tags.some(t => t.toLowerCase() === text.toLowerCase())) { sug.classList.add('selected'); sug.querySelector('i').className = 'fa fa-check-circle'; } else { sug.classList.remove('selected'); sug.querySelector('i').className = 'fa fa-plus-circle'; } }); }; /** * Reusable Tag Autocomplete Logic */ window.f0ckInitTagAutocomplete = (tagInput, tagSuggestions, onAdd, getExistingTags) => { if (!tagInput || !tagSuggestions) return; let currentFocus = -1; let debounceTimer; const addActive = (x) => { if (!x) return false; removeActive(x); if (currentFocus >= x.length) currentFocus = 0; if (currentFocus < 0) currentFocus = (x.length - 1); x[currentFocus].classList.add("active"); x[currentFocus].scrollIntoView({ block: 'nearest' }); // Optional: update input value to highlighted tag // const tagName = x[currentFocus].querySelector('.tag-suggestion-name').textContent; // tagInput.value = tagName; }; const removeActive = (x) => { for (let i = 0; i < x.length; i++) { x[i].classList.remove("active"); } }; tagInput.addEventListener('keydown', (e) => { const x = tagSuggestions.getElementsByClassName("tag-suggestion-item"); if (e.key === 'ArrowDown') { currentFocus++; addActive(x); } else if (e.key === 'ArrowUp') { currentFocus--; addActive(x); } else if ((e.key === 'Enter' || e.key === 'Tab') && !e.ctrlKey && !e.metaKey) { if (currentFocus > -1) { e.preventDefault(); if (x && x[currentFocus] && x[currentFocus]._f0ckSelect) { x[currentFocus]._f0ckSelect(); } } else if (e.key === 'Enter' && tagInput.value.trim().length > 0) { e.preventDefault(); onAdd(tagInput.value); tagInput.value = ''; } } else if (e.key === 'Escape') { if (tagSuggestions.style.display === 'block') { e.preventDefault(); e.stopPropagation(); } tagSuggestions.style.display = 'none'; currentFocus = -1; } }); tagInput.addEventListener('input', () => { clearTimeout(debounceTimer); const query = tagInput.value.trim(); currentFocus = -1; if (query.length < 1) { tagSuggestions.style.display = 'none'; return; } debounceTimer = setTimeout(async () => { try { const res = await fetch('/api/v2/tags/suggest?q=' + encodeURIComponent(query)); const data = await res.json(); if (data.success && data.suggestions && data.suggestions.length > 0) { const existing = getExistingTags(); const filtered = (data.suggestions || []).filter(s => s && s.tag && !existing.some(t => t.toLowerCase() === s.tag.toLowerCase())); let html = ''; const maxSuggestions = Math.min(8, filtered.length); for (let i = 0; i < maxSuggestions; i++) { const s = filtered[i]; const scoreStr = typeof s.score === 'number' ? s.score.toFixed(2) : '0.00'; html += `
${window.escapeHtmlUpload(s.tag)} ${s.tagged || 0}× · ${scoreStr}
`; } if (maxSuggestions > 0) { tagSuggestions.innerHTML = html; tagSuggestions.style.display = 'block'; const items = tagSuggestions.querySelectorAll('.tag-suggestion-item'); items.forEach((el, idx) => { const select = (ev) => { if (ev) { // Only stop if we actually have a selection (handled in mouseup) const sel = window.getSelection?.()?.toString().trim(); if (sel && sel !== filtered[idx].tag) { ev.preventDefault(); ev.stopPropagation(); return; } ev.stopPropagation(); } onAdd(filtered[idx].tag); tagInput.value = ''; tagSuggestions.style.display = 'none'; tagInput.focus(); }; // Partial Selection Support el.addEventListener('mouseup', (ev) => { const sel = window.getSelection?.()?.toString().trim(); if (!sel || sel === filtered[idx].tag) return; // Block the immediate click action if we have a selection el.addEventListener('click', (e) => e.stopImmediatePropagation(), { once: true, capture: true }); ev.stopPropagation(); window._showSelTagPopover?.(sel, el, (confirmed) => { window.getSelection?.()?.removeAllRanges(); onAdd(confirmed); tagSuggestions.style.display = 'none'; tagInput.value = ''; tagInput.focus(); }); }); el.addEventListener('click', select); // Mobile support let touchStartX = 0, touchStartY = 0; el.addEventListener('touchstart', (ev) => { touchStartX = ev.touches[0].clientX; touchStartY = ev.touches[0].clientY; }, { passive: true }); el.addEventListener('touchend', (ev) => { const dx = Math.abs(ev.changedTouches[0].clientX - touchStartX); const dy = Math.abs(ev.changedTouches[0].clientY - touchStartY); if (dx < 10 && dy < 10) { select(ev); } }); el._f0ckSelect = select; }); } else { tagSuggestions.style.display = 'none'; } } else { tagSuggestions.style.display = 'none'; } } catch (err) { console.error('[UPLOAD_TAGS] Error fetching suggestions:', err); } }, 200); }); // Close when clicking outside const closeSuggestions = (e) => { if (!tagInput.contains(e.target) && !tagSuggestions.contains(e.target)) { tagSuggestions.style.display = 'none'; } }; document.addEventListener('mousedown', closeSuggestions); document.addEventListener('touchstart', closeSuggestions, { passive: true }); }; if (tagInput && tagSuggestions) { window.f0ckInitTagAutocomplete(tagInput, tagSuggestions, addTag, () => tags); } // Prevent Enter in the title input from submitting the form and // accidentally flushing whatever is currently typed in the tag input as a tag. const titleInputEl = form.querySelector('.upload-title-input'); if (titleInputEl) { titleInputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') e.preventDefault(); }); } form.querySelectorAll('input[name="rating"]').forEach(radio => { radio.addEventListener('change', updateSubmitButton); }); const performUpload = async (e) => { if (e && e.preventDefault) e.preventDefault(); // If already uploading, don't start again if (isUploading) { return; } const isFileMode = activeMode === 'file'; const globalRatingEl = form.querySelector('input[name="rating"]:checked'); // Validation if (isShitpost && isFileMode) { if (selectedFiles.length === 0) { if (window.showFlash) window.showFlash('No files selected', 'error'); return; } // No tag or rating requirement in shitpost mode — untagged items are allowed } else { if (!globalRatingEl) { if (window.showFlash) window.showFlash('Please select a rating', 'error'); return; } if (tags.length < minTags) { if (window.showFlash) window.showFlash(`At least ${minTags} tags are required`, 'error'); return; } } const dragModal = form.closest('#upload-drag-modal'); const comment = form.querySelector('.upload-comment')?.value.trim() || ''; const isOc = form.querySelector('#upload-oc-checkbox')?.checked || false; const titleVal = form.querySelector('.upload-title-input')?.value.trim() || ''; const setBtnLoading = (text) => { isUploading = true; if (!submitBtn) return; submitBtn.disabled = true; const btnText = submitBtn.querySelector('.btn-text'); const btnLoading = submitBtn.querySelector('.btn-loading'); if (btnText) btnText.style.display = 'none'; if (btnLoading) { btnLoading.style.display = 'inline'; btnLoading.textContent = text; } }; const restoreBtn = () => { isUploading = false; if (!submitBtn) return; submitBtn.disabled = false; const btnText = submitBtn.querySelector('.btn-text'); const btnLoading = submitBtn.querySelector('.btn-loading'); if (btnText) btnText.style.display = 'inline'; if (btnLoading) btnLoading.style.display = 'none'; updateSubmitButton(); }; if (activeMode === 'url') { // --- URL Upload --- let urls = [urlInput?.value.trim()]; if (isShitpost && urlInput?.tagName === 'TEXTAREA') { urls = urlInput.value.split('\n').map(u => u.trim()).filter(u => u.length > 0); } if (urls.length === 0 || !urls[0]) return; setBtnLoading(isShitpost ? `Processing 1/${urls.length}...` : 'Processing...'); if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } let successCount = 0; let lastData = null; for (let i = 0; i < urls.length; i++) { const url = urls[i]; if (isShitpost) { const statusMsg = window.f0ckI18n?.upload_shitposting_status || 'Shitposting'; setBtnLoading(`[${i + 1}/${urls.length}] ${statusMsg}...`); } try { const resp = await fetch('/api/v2/upload-url', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ url, rating: globalRatingEl.value, tags: tags.join(','), comment: comment, is_oc: isOc, title: titleVal || undefined }) }); const text = await resp.text(); let data; try { data = JSON.parse(text); } catch (e) { const statusText = resp.statusText || 'Unknown Error'; throw new Error(`Server returned non-JSON response (Status: ${resp.status} ${statusText})`); } if (data.success) { successCount++; lastData = data; if (data.itemid) { try { const ts = Date.now(); const busted = JSON.parse(localStorage.getItem('bustedThumbs') || '{}'); busted[data.itemid] = ts; const keys = Object.keys(busted); if (keys.length > 50) delete busted[keys[0]]; localStorage.setItem('bustedThumbs', JSON.stringify(busted)); } catch(e) {} } } else if (!isShitpost) { throw new Error(data.msg || 'Upload failed'); } } catch (err) { console.error('[UPLOAD ERROR]', err); if (!isShitpost) { statusDiv.textContent = '✕ ' + err.message; statusDiv.className = 'upload-status error'; restoreBtn(); return; } } } if (successCount > 0) { if (dragModal) dragModal.classList.remove('show'); if (window.resetGlobalScrollState) window.resetGlobalScrollState(); if (window.hideAllModals) window.hideAllModals(); form._f0ckUploader.reset(); if (isShitpost) { if (lastData?.manual_approval && typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning'); } } else { if (lastData?.manual_approval) { if (typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning'); } } else if (!dragModal && statusDiv) { statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful'); statusDiv.className = 'upload-status success'; } } setTimeout(() => { if (typeof window.loadPageAjax === 'function') window.loadPageAjax('/'); else window.location.href = '/'; }, dragModal ? 0 : 1000); } else { restoreBtn(); } } else { // --- File Upload --- if (selectedFiles.length === 0) return; setBtnLoading(isShitpost ? `Uploading 1/${selectedFiles.length}...` : 'Uploading...'); if (progressContainer) progressContainer.style.display = 'flex'; if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } let successCount = 0; let lastData = null; for (let i = 0; i < selectedFiles.length; i++) { const item = selectedFiles[i]; const isUrlItem = isShitpost && item.type === 'url'; const file = !isUrlItem ? (isShitpost ? item.file : item) : null; const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw'); const fileTags = isShitpost ? item.tags : tags; const fileComment = isShitpost ? item.comment : comment; const fileTitle = isShitpost ? (item.title || '') : titleVal; if (isShitpost) { const statusMsg = window.f0ckI18n?.upload_shitposting_status || 'Shitposting'; setBtnLoading(`[${i + 1}/${selectedFiles.length}] ${statusMsg}...`); } const formData = new FormData(); if (isUrlItem) { formData.append('url', item.url); } else { formData.append('file', file); } formData.append('rating', fileRating); formData.append('tags', fileTags.join(',')); formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false'); if (isShitpost) formData.append('is_shitpost', 'true'); if (fileTitle) formData.append('title', fileTitle); // Add custom thumbnail if provided (only for single SWF files) if (selectedFiles.length === 1 && thumbInput && thumbInput.files && thumbInput.files[0]) { formData.append('thumbnail', thumbInput.files[0]); } // Send the specific comment for this file if (fileComment) { formData.append('comment', fileComment); } try { const res = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); if (progressFill) progressFill.style.width = percent + '%'; if (progressText) progressText.textContent = (isShitpost ? `[${i+1}/${selectedFiles.length}] ` : '') + percent + '%'; } }); xhr.onload = () => { try { const data = JSON.parse(xhr.responseText); resolve(data); } catch(e) { let msg = 'Server error'; if (xhr.status === 413) msg = 'File too large'; try { const errData = JSON.parse(xhr.responseText); if (errData.msg) msg = errData.msg; } catch(e2) {} reject(new Error(msg)); } }; xhr.onerror = () => reject(new Error('Connection error')); const endpoint = isUrlItem ? '/api/v2/upload-url' : '/api/v2/upload'; xhr.open('POST', endpoint); const csrf = window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || ''; xhr.setRequestHeader('X-CSRF-Token', csrf); if (isUrlItem) { xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({ url: item.url, rating: fileRating, tags: fileTags.join(','), is_oc: (isShitpost ? item.is_oc : isOc), comment: fileComment, is_shitpost: isShitpost ? true : undefined, title: fileTitle || undefined })); } else { xhr.send(formData); } }); if (res.success) { successCount++; lastData = res; if (res.pending) { // Background URL download — show i18n toast if (typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_url_queued_background || res.msg, 4000, 'info'); } } if (res.itemid) { try { const ts = Date.now(); const busted = JSON.parse(localStorage.getItem('bustedThumbs') || '{}'); busted[res.itemid] = ts; const keys = Object.keys(busted); if (keys.length > 50) delete busted[keys[0]]; localStorage.setItem('bustedThumbs', JSON.stringify(busted)); } catch(e) {} } } 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); if (!isShitpost) { statusDiv.textContent = '✕ ' + err.message; statusDiv.className = 'upload-status error'; 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'); } } } } if (successCount > 0) { if (dragModal) dragModal.classList.remove('show'); form._f0ckUploader.reset(); if (isShitpost) { if (lastData?.manual_approval && typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning'); } } else { if (lastData?.manual_approval) { if (typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning'); } } else if (!dragModal && statusDiv) { statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful'); statusDiv.className = 'upload-status success'; } } setTimeout(() => { if (typeof window.loadPageAjax === 'function') window.loadPageAjax('/'); else window.location.href = '/'; }, dragModal ? 0 : 1000); } else { restoreBtn(); if (progressContainer) progressContainer.style.display = 'none'; } } }; form.addEventListener('submit', performUpload); updateSubmitButton(); // Return an object with methods to control the form externally if needed form._f0ckUploader = { handleFile: handleFile, performUpload: performUpload, reset: () => { isUploading = false; form.reset(); tags = []; selectedFiles = []; if (tagsList) tagsList.innerHTML = ''; if (tagsHidden) tagsHidden.value = ''; if (fileInput) fileInput.style.display = 'inline-block'; if (dropZonePrompt) dropZonePrompt.style.display = 'block'; if (filePreview) { filePreview.style.display = "none"; filePreview.innerHTML = ""; } if (urlInput) urlInput.value = ''; if (urlBadge) urlBadge.style.display = 'none'; if (progressContainer) { progressContainer.style.display = 'none'; } if (progressFill) { progressFill.style.width = '0%'; } if (progressText) { progressText.textContent = '0%'; } if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } if (thumbSection) thumbSection.style.display = 'none'; if (thumbInput) thumbInput.value = ''; if (submitBtn) { const btnText = submitBtn.querySelector('.btn-text'); const btnLoading = submitBtn.querySelector('.btn-loading'); if (btnText) btnText.style.display = 'inline'; if (btnLoading) { btnLoading.style.display = 'none'; } } // Reset mode to 'file' activeMode = 'file'; if (modeTabs.length > 0) { modeTabs.forEach(t => { if (t.dataset.mode === 'file') t.classList.add('active'); else t.classList.remove('active'); }); } if (modeFile) modeFile.style.display = ''; if (modeUrl) modeUrl.style.display = 'none'; // Clear GPS warning and meta suggestions form.querySelector('.gps-privacy-warning')?.remove(); const metaContReset = form.querySelector('.meta-suggestions-container'); const metaListReset = form.querySelector('.meta-suggestions-list'); if (metaListReset) metaListReset.innerHTML = ''; if (metaContReset) metaContReset.style.display = 'none'; autoTags = []; updateSubmitButton(); } }; return form._f0ckUploader; }; // Global Keyboard Shortcut: Ctrl+Enter document.addEventListener('keydown', (e) => { if (e.key !== 'Enter' || (!e.ctrlKey && !e.metaKey)) return; // If the focused element is a textarea or input that is NOT inside an upload form, // do not interfere — it belongs to comments, chat, or another feature. const active = document.activeElement; if (active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT')) { if (!active.closest('.upload-form')) return; } // 1. Try the element that currently has focus // 2. Fall back to the modal form if it's visible // 3. Fall back to any visible upload form on the page let targetForm = active?.closest('.upload-form'); if (!targetForm || !targetForm._f0ckUploader) { const modal = document.getElementById('upload-drag-modal'); if (modal && modal.classList.contains('show')) { targetForm = modal.querySelector('.upload-form'); } } if (!targetForm || !targetForm._f0ckUploader) { targetForm = document.querySelector('.upload-form'); } if (!targetForm || !targetForm._f0ckUploader) return; e.preventDefault(); e.stopPropagation(); const uploader = targetForm._f0ckUploader; const isShitpost = targetForm.classList.contains('shitpost-mode-active') || !!window.f0ckShitpostMode; const isUrlTab = targetForm.querySelector('.upload-mode-tab[data-mode="url"]')?.classList.contains('active'); if (isShitpost && isUrlTab) { const btnAddUrls = targetForm.querySelector('.btn-add-urls'); if (btnAddUrls) btnAddUrls.click(); } else { uploader.performUpload(); } }); // Global multi-instance auto-init window.autoInitUploadForms = () => { document.querySelectorAll('.upload-form').forEach(form => { window.initUploadForm(form); }); }; document.addEventListener('DOMContentLoaded', window.autoInitUploadForms);