Files
f0ckm/public/s/js/upload.js

1849 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
return (unsafe || '').toString()
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
});
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;
// 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 isShitpost = form.classList.contains('shitpost-mode-active') || !!window.f0ckShitpostMode;
let tags = [];
let autoTags = []; // Track tags suggested from metadata
let selectedFiles = []; // Array of files for shitpost_mode
let activeMode = 'file'; // 'file' or 'url'
// 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 = '';
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;
if (urlBadge) {
const originalText = urlBadge.textContent;
const originalClass = urlBadge.className;
const originalDisplay = urlBadge.style.display;
urlBadge.innerHTML = '<span class="loading-spinner"></span> 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 meta = data.meta;
const suggest = (v) => { if (v && typeof v === 'string' && v.trim()) addMetaSuggestion(v.trim()); };
const suggestArr = (arr) => { if (Array.isArray(arr)) arr.forEach(suggest); };
// 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();
}
suggest(bestTitle);
// Site / source
suggest(meta.site_name);
// Description / summary (og or standard — offer as single suggestion)
suggest(meta.og_desc || meta.description || meta.twitter_desc || meta.ld_desc);
// Author
suggest(meta.author || meta.article_author || meta.book_author || meta.music_musician || meta.ld_author || meta.ld_creator);
suggest(meta.profile_name);
suggest(meta.profile_username);
// Keywords (each separate)
suggestArr(meta.keywords);
suggestArr(meta.news_keywords);
suggestArr(meta.ld_keywords);
// Article / content classification
suggestArr(meta.article_tags);
suggest(meta.article_section);
suggest(meta.category);
suggest(meta.genre || meta.ld_genre);
suggest(meta.og_type);
// Video tags
suggestArr(meta.video_tags);
// Music
suggest(meta.music_album);
suggest(meta.music_song);
// Book tags
suggestArr(meta.book_tags);
// Twitter labels (e.g. "Rating: 5 stars")
suggestArr(meta.twitter_labels);
// Twitter handles
suggest(meta.twitter_creator);
// JSON-LD extras
suggest(meta.ld_name);
suggest(meta.ld_about);
// Price
if (meta.price_amount) suggest(`${meta.price_amount}${meta.price_currency ? ' ' + meta.price_currency : ''}`);
// Language
suggest(meta.language);
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
if (urlBadge) {
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: '', is_oc: false });
}
});
urlInput.value = '';
if (urlBadge) urlBadge.style.display = 'none';
handleFile();
});
if (urlBadge) {
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: '', 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 = () => {
const isShitpost = !!window.f0ckShitpostMode;
const rating = form.querySelector('input[name="rating"]:checked');
// In Shitpost Mode, ratings are per-item (optional) and tags are optional — just need files
const hasRating = (isShitpost && activeMode === 'file') ? true : (rating !== null);
let hasTags = true;
if (!isShitpost) {
hasTags = tags.length >= minTags;
}
// In shitpost file mode: hasTags is always true (untagged is allowed)
// 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 only
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 (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
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);
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 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: '', 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';
filePreview.innerHTML = '';
}
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';
}
// Build preview items
selectedFiles.forEach((item, index) => {
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;
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'; // Ensure we can interact with it
} else {
mediaElem = document.createElement('div');
mediaElem.className = 'generic-file-icon';
mediaElem.innerHTML = '<i class="fa-solid fa-link"></i>';
}
} 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.autoplay = true;
mediaElem.controls = true;
mediaElem.loop = 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 = 'generic-file-icon swf-preview-icon';
mediaElem.innerHTML = '<span style="font-size:1.5em;">⚡</span>';
} else if (file.type === 'application/pdf' || fileExt === 'pdf') {
mediaElem = document.createElement('div');
mediaElem.className = 'generic-file-icon pdf-preview-icon';
mediaElem.innerHTML = '<i class="fa-solid fa-file-pdf"></i>';
} else {
mediaElem = document.createElement('div');
mediaElem.className = 'generic-file-icon';
mediaElem.innerHTML = '<i class="fa-solid fa-file"></i>';
}
}
mediaElem.classList.add('preview-media-small');
const infoRow = document.createElement('div');
infoRow.className = 'file-meta-row-small';
let ratingSwitch = '';
let tagsUI = '';
let ocUI = '';
let commentUI = '';
if (isShitpost) {
const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]');
ratingSwitch = `
<div class="item-rating-container">
<label class="item-rating-option">
<input type="radio" name="rating_${index}" value="sfw" ${item.rating === 'sfw' ? 'checked' : ''}>
<span class="item-rating-label sfw">SFW</span>
</label>
<label class="item-rating-option">
<input type="radio" name="rating_${index}" value="nsfw" ${item.rating === 'nsfw' ? 'checked' : ''}>
<span class="item-rating-label nsfw">NSFW</span>
</label>
${nsflEnabled ? `
<label class="item-rating-option">
<input type="radio" name="rating_${index}" value="nsfl" ${item.rating === 'nsfl' ? 'checked' : ''}>
<span class="item-rating-label nsfl">NSFL</span>
</label>
` : ''}
</div>
`;
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
tagsUI = `
<div class="item-tags-container">
<div class="item-tags-list"></div>
<input type="text" class="item-tag-input" placeholder="${window.escapeHtmlUpload(tagsPlaceholder)}" enterkeyhint="done">
<div class="tag-suggestions" style="display:none;"></div>
<div class="item-meta-suggestions" style="display:none; margin-top:5px; font-size:0.7rem; opacity:0.6;"></div>
</div>
`;
const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'Comment (optional)...';
commentUI = `
<textarea class="item-comment-input" placeholder="${window.escapeHtmlUpload(commentPlaceholder)}">${window.escapeHtmlUpload(item.comment || '')}</textarea>
`;
}
const fileNameStr = isUrl ? item.url : file.name;
const fileSizeStr = isUrl ? 'URL' : formatSize(file.size);
infoRow.innerHTML = `
<div class="file-info-small">
<span class="file-name-small" title="${window.escapeHtmlUpload(fileNameStr)}">${window.escapeHtmlUpload(fileNameStr)}</span>
<span class="file-size-small">${fileSizeStr}</span>
</div>
${ratingSwitch}
${tagsUI}
${commentUI}
`;
if (isShitpost) {
// Handle Rating
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
radio.onchange = () => { item.rating = radio.value; };
});
// Handle Comment
const commentInput = infoRow.querySelector('.item-comment-input');
if (commentInput) {
commentInput.oninput = () => { item.comment = commentInput.value; };
}
// 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)} <span class="item-tag-remove">✕</span>`;
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 = `<i class="fa fa-plus-circle" style="user-select:none"></i> <span style="user-select:text">${window.escapeHtmlUpload(val)}</span>`;
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) {
(async () => {
try {
const resp = await fetch('/api/v2/meta/extract-url?url=' + encodeURIComponent(item.url), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.success && data.fields) {
data.fields.forEach(val => addMetaSuggestionToItem(val));
}
} catch (e) { console.warn('[URL ITEM META SYNC ERROR]', e.message); }
})();
} 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();
selectedFiles.splice(index, 1);
handleFile(); // Rebuild UI
};
previewItem.appendChild(mediaElem);
previewItem.appendChild(infoRow);
previewItem.appendChild(removeBtn);
if (filePreview) filePreview.appendChild(previewItem);
});
// "Add more" button for Shitpost Mode
if (isShitpost) {
const addMoreItem = document.createElement('div');
addMoreItem.className = 'file-preview-item add-more-item';
addMoreItem.innerHTML = `<span>${window.f0ckI18n?.upload_add_more || 'Add more'}</span>`;
addMoreItem.onclick = () => fileInput && fileInput.click();
if (filePreview) filePreview.appendChild(addMoreItem);
}
// 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();
}
}
// Toggle custom thumbnail for single SWF batch
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';
}
updateSubmitButton();
form.dispatchEvent(new CustomEvent('fileReady', { detail: { files: selectedFiles } }));
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();
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';
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 = `<span class="tag-text">${window.escapeHtmlUpload(tagName)}</span><button type="button">&times;</button>`;
// 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 = `
<i class="fa-solid fa-location-dot"></i>
<span><strong>Location data detected.</strong> This image contains GPS coordinates that reveal where it was taken.</span>
<button type="button" class="gps-strip-btn">Strip GPS data</button>
<button type="button" class="gps-dismiss-btn" title="Dismiss">×</button>
`;
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 = `<i class="fa-solid fa-check"></i> <span>GPS data stripped. Location will not be embedded in the uploaded image.</span>`;
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 = `<i class="fa fa-plus-circle" style="user-select:none"></i> <span>${window.escapeHtmlUpload(text)}</span>`;
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 += `
<div class="tag-suggestion-item">
<span class="tag-suggestion-name">${window.escapeHtmlUpload(s.tag)}</span>
<span class="tag-suggestion-meta">${s.tagged || 0}× · ${scoreStr}</span>
</div>
`;
}
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);
}
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 (submitBtn && submitBtn.disabled && submitBtn.querySelector('.btn-loading')?.style.display === 'inline') {
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 setBtnLoading = (text) => {
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 = () => {
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';
};
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
})
});
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) {
// Flash message removed as requested
} else {
if (!dragModal && statusDiv) {
statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful');
statusDiv.className = 'upload-status success';
}
if (lastData?.manual_approval && typeof window.showFlash === 'function') {
window.showFlash('Upload awaits approval, please be patient', 'info');
}
}
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;
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');
// 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
}));
} 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 if (!isShitpost) {
throw new Error(res.msg || 'Upload failed');
}
} 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;
}
}
}
if (successCount > 0) {
if (dragModal) dragModal.classList.remove('show');
form._f0ckUploader.reset();
if (isShitpost) {
// Flash message removed as requested
} 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: () => {
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;
// 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 = document.activeElement?.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);