From f613ae309e129a55bbeda7dc261433331a132b6e Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 13 May 2026 05:49:11 +0200 Subject: [PATCH] Feature: Shitpost Mode -> upload multiple files at once --- config_example.json | 1 + public/s/css/f0ckm.css | 6 +- public/s/css/upload.css | 408 ++++++++- public/s/js/f0ck_upload_init.js | 6 +- public/s/js/tag_autocomplete.js | 33 +- public/s/js/upload-common.js | 2 +- public/s/js/upload.js | 1364 ++++++++++++++++++++----------- src/inc/locales/de.json | 9 +- src/inc/locales/en.json | 10 +- src/inc/locales/nl.json | 9 +- src/inc/locales/zange.json | 6 +- src/inc/queue.mjs | 21 +- src/inc/routes/admin.mjs | 4 +- src/inc/routes/apiv2/upload.mjs | 53 +- src/inc/settings.mjs | 2 + src/index.mjs | 4 +- src/upload_handler.mjs | 32 +- views/admin.html | 1 + views/snippets/footer.html | 4 + views/snippets/header.html | 2 +- views/snippets/upload-form.html | 25 +- 21 files changed, 1463 insertions(+), 539 deletions(-) diff --git a/config_example.json b/config_example.json index f7e28a4..5dd623b 100644 --- a/config_example.json +++ b/config_example.json @@ -65,6 +65,7 @@ "enable_youtube_upload": true, "web_meta_extraction": true, "bypass_duplicate_check": true, + "shitpost_mode": false, "protect_files": false, "allowed_comment_images": [ "i.imgur.com", diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index fcdffa2..2f542af 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -5134,9 +5134,9 @@ div.posts>a[data-mode="nsfl"]>p::before { background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(227, 7, 7, 0) 85%, rgb(231, 3, 3) 100%); } -div.posts>a[data-mode="null"]>p:before { - background-color: #dcd512; - /* untagged */ +div.posts > a[data-mode="null"] > p::before { + background: #000000; + background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(63, 196, 61, 0) 85%, rgb(244, 222, 0) 100%); } div#footbar { diff --git a/public/s/css/upload.css b/public/s/css/upload.css index 9aeed59..f9d0c5f 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -178,23 +178,375 @@ background: #fa5252; } -/* Ratings */ -@media(max-width: 700px) { - .rating-options { - display: grid; - grid-template-columns: repeat(2, 1fr); +/* Default/Non-Shitpost Mode: Centered Card */ +.upload-form:not(.shitpost-mode-active) .file-preview-item { + flex-direction: column; + align-items: center; + text-align: center; + gap: 16px; + padding: 24px; +} + +/* Shitpost Mode: Two-Column Row */ +.upload-form.shitpost-mode-active .file-preview-item { + flex-direction: row; + align-items: flex-start; + gap: 16px; + padding: 12px; +} + +.file-preview-item { + display: flex; + width: 100%; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(5px); + border: 1px solid rgba(255, 255, 255, 0.08); + position: relative; + border-radius: 8px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + animation: previewItemIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.file-preview-item:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.15); +} + +@keyframes previewItemIn { + from { opacity: 0; transform: translateX(-15px); } + to { opacity: 1; transform: translateX(0); } +} + +.upload-form:not(.shitpost-mode-active) .preview-media-small { + width: 100%; + max-width: 400px; + min-height: auto; + height: auto; + aspect-ratio: 16 / 9; + object-fit: contain; +} + +.upload-form.shitpost-mode-active .preview-media-small { + width: 120px; + height: 80px; + object-fit: cover; + min-height: auto; +} + +.preview-media-small { + flex-shrink: 0; + border-radius: 6px; + background: #000; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); + transition: transform 0.2s; +} + + +.upload-form:not(.shitpost-mode-active) .file-meta-row-small { + padding-right: 0; + align-items: center; +} + +.upload-form.shitpost-mode-active .file-meta-row-small { + padding-right: 30px; /* Space for X button */ +} + +.file-meta-row-small { + flex: 1; + display: flex; + flex-direction: column; gap: 10px; - } + overflow: hidden; + min-width: 0; +} - .rating-option:nth-child(3) { - grid-column: 1 / span 2; - } +.file-info-small { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + width: 100%; + text-align: left; +} - .tag-suggestions { - left: 0px; - right: 0px; - } +.file-name-small { + font-size: 0.95rem; + font-weight: 500; + overflow: hidden; + color: #fff; + max-width: 100%; + display: block; + word-break: break-all; +} +.file-size-small { + font-size: 0.7rem; + opacity: 0.4; + font-family: 'Outfit', sans-serif; + letter-spacing: 0.5px; +} + +.btn-remove-small { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.5); + color: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(4px); + z-index: 10; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.75rem; + border-radius: 6px; + transition: all 0.2s; +} + +.btn-remove-small:hover { + background: #ff6b6b; + color: white; + border-color: #ff6b6b; + transform: rotate(90deg); +} +.btn-add-urls { + margin-top: 10px; + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + padding: 10px; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; +} + +.btn-add-urls:hover { + background: var(--accent); + color: var(--bg); +} + +.add-more-item { + justify-content: center; + align-items: center; + gap: 10px; + padding: 15px; + border: 1.5px dashed rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.02); + cursor: pointer; + color: rgba(255, 255, 255, 0.3); + font-weight: 600; + font-size: 0.9rem; + transition: all 0.2s; + backdrop-filter: none; + border-radius: 8px; +} +.add-more-item i { + font-size: 1.1rem; + opacity: 0.8; +} + +.add-more-item:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(var(--accent-rgb), 0.05); + border-style: solid; + transform: none; +} + +.upload-form.shitpost-mode-active .global-rating-section, +.upload-form.shitpost-mode-active .global-comment-section, +.upload-form.shitpost-mode-active .global-tag-section { + display: none !important; +} + +/* Per-item Rating Switch */ +.item-rating-container { + display: flex; + gap: 5px; + margin-top: 5px; +} + +.item-rating-option { + position: relative; + cursor: pointer; +} + +.item-rating-option input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.item-rating-label { + display: inline-block; + padding: 2px 6px; + font-size: 0.65rem; + font-weight: 700; + border-radius: 3px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.4); + transition: all 0.2s; + text-transform: uppercase; +} + +.item-rating-option input:checked + .item-rating-label.sfw { + background: #40c057; + color: #fff; + border-color: #40c057; +} + +.item-rating-option input:checked + .item-rating-label.nsfw { + background: #fd7e14; + color: #fff; + border-color: #fd7e14; +} + +.item-rating-option input:checked + .item-rating-label.nsfl { + background: #fa5252; + color: #fff; + border-color: #fa5252; +} + +.item-rating-label:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* Bigger Previews in Shitpost Mode */ +.file-preview-item { + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 15px; + gap: 0; + width: 100%; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.preview-media-small { + flex: 0 0 50%; + width: 50% !important; + height: auto !important; + min-height: 120px; + max-height: 350px; + object-fit: contain; + border-radius: 8px; + background: rgba(0,0,0,0.3); +} + +/* Per-item Tags */ +.item-tags-container { + margin-top: 10px; + width: 100%; +} + +.item-tags-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 5px; +} + +.item-tag-chip { + background: rgba(255, 255, 255, 0.1); + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + display: flex; + align-items: center; + gap: 5px; +} + +.item-tag-remove { + cursor: pointer; + opacity: 0.5; +} + +.item-tag-remove:hover { + opacity: 1; +} + +.item-tag-input { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 6px 10px; + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.2s; +} + +.item-tag-input:focus { + border-color: var(--accent); +} + +.item-meta-suggestions { + margin-top: 5px; + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.item-meta-suggestion { + background: rgba(255, 255, 255, 0.05); + padding: 1px 6px; + border-radius: 3px; + font-size: 0.65rem; + cursor: pointer; + border: 1px dashed rgba(255, 255, 255, 0.1); +} + +.item-meta-suggestion:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--accent); +} + +.item-meta-suggestion.selected { + background: rgba(255, 255, 255, 0.08); + border-color: var(--accent); + border-style: solid; + color: rgba(255, 255, 255, 0.9); +} + +.item-meta-suggestion.selected i { + color: var(--accent); +} +.item-comment-input { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 6px 10px; + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.2s; + margin-top: 10px; + resize: vertical; + min-height: 40px; + font-family: inherit; +} + +.item-comment-input:focus { + border-color: var(--accent); } .rating-options { @@ -398,6 +750,10 @@ scrollbar-width: thin !important; } +.upload-form.shitpost-mode-active .tag-suggestions { + position: relative !important; +} + @keyframes tagDropIn { from { opacity: 0; @@ -409,7 +765,7 @@ } } -#upload-form .tag-suggestion-item { +.tag-suggestion-item { display: flex !important; justify-content: space-between !important; align-items: center !important; @@ -418,7 +774,7 @@ cursor: pointer !important; transition: background 0.12s !important; box-sizing: border-box !important; - user-select: none !important; + user-select: text !important; } #upload-form .tag-suggestion-item:not(:last-child) { @@ -446,7 +802,7 @@ /* Submit Button */ .btn-upload { background: var(--accent); - color: #000; + color: var(--bg); border: none; padding: 1rem 2rem; border-radius: 0; @@ -464,8 +820,7 @@ } .btn-upload:not(:disabled):hover { - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); + filter: brightness(1.1); } /* Progress */ @@ -747,12 +1102,9 @@ } .meta-suggestion.selected { - opacity: 0.4; - cursor: default; - pointer-events: none; - background: rgba(255, 255, 255, 0.02); - border-color: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.4) !important; + background: rgba(255, 255, 255, 0.08); + border-color: var(--accent); + color: rgba(255, 255, 255, 0.9); } .meta-suggestion.selected i { @@ -809,3 +1161,11 @@ } .gps-privacy-warning.gps-stripped i, .gps-privacy-warning.gps-stripped span { color: #4caf50; } + +.item-meta-suggestion span { + user-select: text !important; +} + +.item-comment-input { + min-height: 60px; +} diff --git a/public/s/js/f0ck_upload_init.js b/public/s/js/f0ck_upload_init.js index 6573ae2..700271e 100644 --- a/public/s/js/f0ck_upload_init.js +++ b/public/s/js/f0ck_upload_init.js @@ -79,7 +79,7 @@ const files = e.dataTransfer.files; if (files && files.length > 0) { if (uploader && uploader.handleFile) { - const ok = uploader.handleFile(files[0]); + const ok = uploader.handleFile(files); if (ok !== false) { showModal(); } @@ -151,12 +151,12 @@ if (targetUploader && targetUploader.handleFile) { if (isUploadPage || isModalOpen) { - targetUploader.handleFile(file); + targetUploader.handleFile([file]); e.preventDefault(); } else if (!isTyping) { e.preventDefault(); showModal(); - targetUploader.handleFile(file); + targetUploader.handleFile([file]); } } return; diff --git a/public/s/js/tag_autocomplete.js b/public/s/js/tag_autocomplete.js index 3398969..d475a33 100644 --- a/public/s/js/tag_autocomplete.js +++ b/public/s/js/tag_autocomplete.js @@ -123,7 +123,7 @@ window.TagAutocomplete = (() => { const name = document.createElement('span'); name.className = 'tag-suggestion-name'; name.textContent = entry.tag; - + const meta = document.createElement('span'); meta.className = 'tag-suggestion-meta'; const scoreStr = typeof entry.score === 'number' ? entry.score.toFixed(2) : '0.00'; @@ -132,13 +132,36 @@ window.TagAutocomplete = (() => { row.appendChild(name); row.appendChild(meta); - // Desktop: mousedown fires before focusout, preventing premature close - row.addEventListener('mousedown', (e) => { - e.preventDefault(); + // Partial Selection Support + row.addEventListener('mouseup', (ev) => { + const sel = window.getSelection?.()?.toString().trim(); + if (!sel || sel === entry.tag) return; + + row.addEventListener('click', (e) => e.stopImmediatePropagation(), { once: true, capture: true }); + ev.stopPropagation(); + + window._showSelTagPopover?.(sel, row, (confirmed) => { + window.getSelection?.()?.removeAllRanges(); + input.value = confirmed; + dropdown.style.display = 'none'; + if (form.requestSubmit) form.requestSubmit(); + else form.submit(); + }); + }); + + // Desktop: click listener handles the actual selection + row.addEventListener('click', (e) => { + const sel = window.getSelection?.()?.toString().trim(); + if (sel && sel !== entry.tag) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.stopPropagation(); input.value = entry.tag; dropdown.style.display = 'none'; - form.requestSubmit(); + if (form.requestSubmit) form.requestSubmit(); + else form.submit(); }); // Mobile: distinguish tap from scroll using touch distance diff --git a/public/s/js/upload-common.js b/public/s/js/upload-common.js index 9f0508f..0804599 100644 --- a/public/s/js/upload-common.js +++ b/public/s/js/upload-common.js @@ -28,7 +28,7 @@ window.F0ckUpload = class { const tagInput = this.form.querySelector('.tag-input'); if (tagInput) { tagInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); this.addTag(tagInput.value); tagInput.value = ''; diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 278e183..89713fc 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -71,11 +71,20 @@ window.initUploadForm = (selector) => { // 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 selectedFile = null; + 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) { @@ -419,6 +428,31 @@ window.initUploadForm = (selector) => { } }); } + + const btnAddUrls = form.querySelector('.btn-add-urls'); + if (btnAddUrls) { + btnAddUrls.addEventListener('click', () => { + const val = urlInput.value.trim(); + if (!val) return; + const lines = val.split('\n').map(u => u.trim()).filter(u => u.length > 0); + lines.forEach(url => { + if (/^https?:\/\//i.test(url)) { + if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) { + selectedFiles.push({ + type: 'url', + url: url, + rating: '', + tags: [], + comment: '', + is_oc: false + }); + } + } + }); + urlInput.value = ''; + handleFile(); + }); + } } const formatSize = (bytes) => { @@ -432,13 +466,52 @@ window.initUploadForm = (selector) => { }; const updateSubmitButton = () => { + const isShitpost = !!window.f0ckShitpostMode; const rating = form.querySelector('input[name="rating"]:checked'); - const hasRating = rating !== null; - const hasTags = tags.length >= minTags; + + // 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 = selectedFile !== null; + hasContent = selectedFiles.length > 0; } else { hasContent = urlInput && urlInput.value.trim().length > 0; } @@ -454,13 +527,19 @@ window.initUploadForm = (selector) => { ? (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) { - btnText.textContent = i18n.select_rating || 'Select SFW or NSFW'; + 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'; @@ -483,71 +562,90 @@ window.initUploadForm = (selector) => { } }; - const handleFile = (file) => { - if (!file) return; + 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 - // Note: Allowed mimes still come from a global or container-specific attribute - 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) { - // Fallback for modal - 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']; + 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 + } + } + } } - // Check by MIME type or by file extension (browsers may report empty/wrong MIME for some formats) - 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 (typeof window.showFlash === 'function') { - window.showFlash(errorMsg, 'error'); - } else { - statusDiv.textContent = errorMsg; - statusDiv.className = 'upload-status error'; + // 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; } - // --- File Size Check --- - 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 (typeof window.showFlash === 'function') { - window.showFlash(errorMsg, 'error'); - } else if (statusDiv) { - statusDiv.textContent = errorMsg; - statusDiv.className = 'upload-status error'; - } - updateSubmitButton(); - return false; - } + if (dropZonePrompt) dropZonePrompt.style.display = 'none'; + if (fileInput) fileInput.style.display = 'none'; + if (filePreview) { + filePreview.style.display = 'flex'; + filePreview.innerHTML = ''; } - selectedFile = file; - if (fileName) fileName.textContent = file.name; - if (fileSize) fileSize.textContent = formatSize(file.size); - if (dropZonePrompt) dropZonePrompt.style.display = 'none'; - - // Hide input so it doesn't intercept clicks on preview/remove button - if (fileInput) fileInput.style.display = 'none'; - - if (filePreview) filePreview.style.display = 'flex'; if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } - // Ensure we're on file tab + // 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"]'); @@ -557,129 +655,355 @@ window.initUploadForm = (selector) => { activeMode = 'file'; } - // Media Preview - const existingPreview = filePreview ? filePreview.querySelector('.preview-media') : null; - if (existingPreview) existingPreview.remove(); - - let previewElem; - if (file.type.startsWith('image/')) { - previewElem = document.createElement('img'); - previewElem.src = URL.createObjectURL(file); - } else if (file.type.startsWith('video/')) { - previewElem = document.createElement('video'); - previewElem.src = URL.createObjectURL(file); - previewElem.controls = true; - previewElem.autoplay = true; - previewElem.muted = true; - previewElem.loop = true; - } else if (file.type.startsWith('audio/')) { - previewElem = document.createElement('audio'); - previewElem.src = URL.createObjectURL(file); - previewElem.controls = true; - } else if (file.type === 'application/x-shockwave-flash' || file.type === 'application/vnd.adobe.flash.movie' || fileExt === 'swf') { - previewElem = document.createElement('div'); - previewElem.className = 'generic-file-icon swf-preview-icon'; - previewElem.innerHTML = '
SWF'; - } else if (file.type === 'application/pdf' || fileExt === 'pdf') { - previewElem = document.createElement('div'); - previewElem.className = 'generic-file-icon pdf-preview-icon'; - previewElem.innerHTML = '
'; - } else { - previewElem = document.createElement('div'); - previewElem.className = 'generic-file-icon'; - previewElem.innerHTML = '📁'; - } - - previewElem.classList.add('preview-media'); - if (filePreview) filePreview.prepend(previewElem); - - // Flash-specific: show custom thumbnail option - if (thumbSection) { - if (file.type === 'application/x-shockwave-flash' || file.type === 'application/vnd.adobe.flash.movie' || fileExt === 'swf') { - thumbSection.style.display = 'block'; - } else { - thumbSection.style.display = 'none'; - if (thumbInput) thumbInput.value = ''; - } - } - - selectedFile = file; - updateSubmitButton(); - autoTags = []; // Reset auto tags - - // Clear previous metadata suggestions and GPS warning - 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 (file.name) { - let baseName = file.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); - } - } - - // --- Media Metadata Sync (Hybrid) --- - const isMedia = file.type.startsWith('video/') || file.type.startsWith('audio/') || file.type.startsWith('image/') || - (file.name && /\.(mp4|webm|mp3|ogg|wav|m4a|flac|jpg|jpeg|png|gif|webp|tiff?)$/i.test(file.name)); - if (isMedia) { - const extractTitle = async () => { - const chunkSize = file.type.startsWith('image/') ? 512 * 1024 : 4 * 1024 * 1024; // 512KB for images (EXIF at start), 4MB for video - const chunk = file.slice(0, chunkSize); - const formData = new FormData(); - formData.append('file', chunk, file.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); - - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - - if (data.success && data.fields && Array.isArray(data.fields)) { - data.fields.forEach(val => { - if (!autoTags.includes(val)) autoTags.push(val); - addMetaSuggestion(val); - }); - } - - // Privacy: GPS data found — offer to strip it - if (data.hasGpsData) { - showGpsPrivacyWarning(file); - } - } catch (e) { - console.warn('[TITLE SYNC ERROR]', e.name === 'AbortError' ? 'Timeout' : e.message); - } finally { - if (status) status.classList.remove('active'); + // 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 = ''; } + } 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 = ''; + } 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 = ''; + } + } + 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 = ` +
+ + + ${nsflEnabled ? ` + + ` : ''} +
+ `; + + const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...'; + tagsUI = ` +
+
+ + + +
+ `; + + + const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'Comment (optional)...'; + commentUI = ` + + `; + } + + const fileNameStr = isUrl ? item.url : file.name; + const fileSizeStr = isUrl ? 'URL' : formatSize(file.size); + + infoRow.innerHTML = ` +
+ ${window.escapeHtmlUpload(fileNameStr)} + ${fileSizeStr} +
+ ${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)} `; + chip.querySelector('.item-tag-remove').onclick = () => { + item.tags.splice(i, 1); + renderTags(); + updateSubmitButton(); + }; + tagList.appendChild(chip); + }); + }; + renderTags(); + + const addItemTag = (val) => { + val = val.trim().toLowerCase(); + 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) { + (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 }; - extractTitle(); + + 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 = 'Add more'; + addMoreItem.onclick = () => fileInput && fileInput.click(); + if (filePreview) filePreview.appendChild(addMoreItem); } - // Custom event for global drag-drop completion - form.dispatchEvent(new CustomEvent('fileReady', { detail: { file } })); + // 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) { @@ -703,19 +1027,19 @@ window.initUploadForm = (selector) => { dropZone.addEventListener('drop', (e) => { const dt = e.dataTransfer; const files = dt.files; - handleFile(files[0]); + handleFile(files); }); } if (fileInput) { - fileInput.addEventListener('change', (e) => handleFile(e.target.files[0])); + fileInput.addEventListener('change', (e) => handleFile(e.target.files)); } if (removeFile) { removeFile.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - selectedFile = null; + selectedFiles = []; form.querySelector('.gps-privacy-warning')?.remove(); if (fileInput) fileInput.value = ''; if (dropZonePrompt) dropZonePrompt.style.display = 'block'; @@ -825,16 +1149,22 @@ window.initUploadForm = (selector) => { const blob = await resp.blob(); const cleanFile = new File([blob], originalFile.name, { type: originalFile.type || blob.type }); - // Replace the selected file so the upload uses the clean version - selectedFile = cleanFile; + // 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(), 4000); - - if (typeof window.showFlash === 'function') window.showFlash('GPS data removed from image', 'success'); - - } catch (err) { + 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'); @@ -904,62 +1234,70 @@ window.initUploadForm = (selector) => { }); }; - let currentFocus = -1; + /** + * Reusable Tag Autocomplete Logic + */ + window.f0ckInitTagAutocomplete = (tagInput, tagSuggestions, onAdd, getExistingTags) => { + if (!tagInput || !tagSuggestions) return; - 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' }); - }; + let currentFocus = -1; + let debounceTimer; - const removeActive = (x) => { - for (let i = 0; i < x.length; i++) { - x[i].classList.remove("active"); - } - }; + 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"); + } + }; - if (tagInput) { tagInput.addEventListener('keydown', (e) => { - const x = tagSuggestions ? tagSuggestions.getElementsByClassName("tag-suggestion-item") : []; + 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') { + } 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 (tagInput.value.trim().length > 0) { + } else if (e.key === 'Enter' && tagInput.value.trim().length > 0) { e.preventDefault(); - addTag(tagInput.value); + onAdd(tagInput.value); + tagInput.value = ''; } } else if (e.key === 'Escape') { - if (tagSuggestions && tagSuggestions.style.display === 'block') { + if (tagSuggestions.style.display === 'block') { e.preventDefault(); e.stopPropagation(); } - if (tagSuggestions) tagSuggestions.style.display = 'none'; + tagSuggestions.style.display = 'none'; currentFocus = -1; } }); - } - let debounceTimer; - if (tagInput) { tagInput.addEventListener('input', () => { clearTimeout(debounceTimer); const query = tagInput.value.trim(); currentFocus = -1; if (query.length < 1) { - if (tagSuggestions) tagSuggestions.style.display = 'none'; + tagSuggestions.style.display = 'none'; return; } @@ -968,7 +1306,8 @@ window.initUploadForm = (selector) => { 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 filtered = (data.suggestions || []).filter(s => s && s.tag && !tags.some(t => t.toLowerCase() === s.tag.toLowerCase())); + 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++) { @@ -981,349 +1320,414 @@ window.initUploadForm = (selector) => { `; } - window.f0ckDebug('[UPLOAD] Rendering ' + filtered.length + ' suggestions'); - if (tagSuggestions) { + + if (maxSuggestions > 0) { tagSuggestions.innerHTML = html; tagSuggestions.style.display = 'block'; const items = tagSuggestions.querySelectorAll('.tag-suggestion-item'); items.forEach((el, idx) => { - const select = (e) => { - if (e) { - e.preventDefault(); - e.stopPropagation(); + 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(); } - addTag(filtered[idx].tag); + onAdd(filtered[idx].tag); + tagInput.value = ''; + tagSuggestions.style.display = 'none'; tagInput.focus(); }; - // Desktop: mousedown fires before blur, so dropdown stays open - el.addEventListener('mousedown', select); - // Mobile: use touchend to detect a tap (vs scroll) + + // 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', (e) => { - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; + el.addEventListener('touchstart', (ev) => { + touchStartX = ev.touches[0].clientX; + touchStartY = ev.touches[0].clientY; }, { passive: true }); - el.addEventListener('touchend', (e) => { - const dx = Math.abs(e.changedTouches[0].clientX - touchStartX); - const dy = Math.abs(e.changedTouches[0].clientY - touchStartY); + 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(e); + select(ev); } }); - el._f0ckSelect = select; // Store for keyboard use + el._f0ckSelect = select; }); + } else { + tagSuggestions.style.display = 'none'; } } else { - if (tagSuggestions) tagSuggestions.style.display = 'none'; + tagSuggestions.style.display = 'none'; } } catch (err) { console.error('[UPLOAD_TAGS] Error fetching suggestions:', err); - if (typeof window.showFlash === 'function') { - window.showFlash('Tag suggestions unavailable', 'error'); - } } }, 200); }); - } - // Use mousedown (not click) so item's mousedown fires first on desktop. - // Use touchstart for mobile — touchend on the item fires before this touchstart on document. - const closeSuggestions = (e) => { - if (form.contains(document.body) === false && !document.body.contains(form)) return; - if (tagInput && tagSuggestions && !tagInput.contains(e.target) && !tagSuggestions.contains(e.target)) { - tagSuggestions.style.display = 'none'; - } + // 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 }); }; - 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); }); - form.addEventListener('submit', async (e) => { - e.preventDefault(); + const performUpload = async (e) => { + if (e && e.preventDefault) e.preventDefault(); - const rating = form.querySelector('input[name="rating"]:checked'); - if (!rating || tags.length < minTags) return; + // 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 --- - const url = urlInput?.value.trim(); - if (!url) return; - - if (submitBtn) { - 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 = 'Processing...'; - } + 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'; } - try { - const resp = await fetch('/api/v2/upload-url', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': window.f0ckSession?.csrf_token || '', - 'X-Requested-With': 'XMLHttpRequest' - }, - body: JSON.stringify({ - url, - rating: rating.value, - tags: tags.join(','), - comment: form.querySelector('.upload-comment')?.value.trim() || '', - is_oc: form.querySelector('#upload-oc-checkbox')?.checked || false - }) - }); + let successCount = 0; + let lastData = null; - 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})`); + 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}...`); } - if (data.success) { - const dragModal = form.closest('#upload-drag-modal'); + 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 + }) + }); - if (data.pending) { - // For async uploads, don't redirect, just show status and reset - if (dragModal) dragModal.classList.remove('show'); - - if (typeof window.flashMessage === 'function') { - window.flashMessage(data.msg || 'Processing in background...'); - } else if (typeof window.showFlash === 'function') { - window.showFlash(data.msg || 'Processing in background...', 'info'); + 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) {} } - - if (!dragModal) { - statusDiv.innerHTML = '✓ ' + data.msg; - statusDiv.className = 'upload-status success'; - } - - form._f0ckUploader.reset(); + } 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 (dragModal) dragModal.classList.remove('show'); - if (window.resetGlobalScrollState) window.resetGlobalScrollState(); - if (window.hideAllModals) window.hideAllModals(); - form._f0ckUploader.reset(); - if (!dragModal) { - statusDiv.innerHTML = '✓ ' + data.msg; + 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 (data.itemid) { - try { - const ts = Date.now(); - const bustedStr = localStorage.getItem('bustedThumbs'); - const busted = bustedStr ? JSON.parse(bustedStr) : {}; - 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) {} - } - - if (data.manual_approval && typeof window.showFlash === 'function') { + if (lastData?.manual_approval && typeof window.showFlash === 'function') { window.showFlash('Upload awaits approval, please be patient', 'info'); } - const navDelay = dragModal ? 0 : 1000; - setTimeout(() => { - if (typeof window.loadPageAjax === 'function') { - window.loadPageAjax('/'); - } else { - window.location.href = '/'; - } - }, navDelay); - } else { - statusDiv.textContent = '✕ ' + (data.msg || 'Upload failed'); - statusDiv.className = 'upload-status error'; - if (data.repost) { - statusDiv.innerHTML += ' View existing'; - } - submitBtn.disabled = false; - submitBtn.querySelector('.btn-text').style.display = 'inline'; - submitBtn.querySelector('.btn-loading').style.display = 'none'; } - } catch (err) { - console.error('[UPLOAD ERROR]', err); - statusDiv.textContent = '✕ Upload failed: ' + err.message; - statusDiv.className = 'upload-status error'; - submitBtn.disabled = false; - submitBtn.querySelector('.btn-text').style.display = 'inline'; - submitBtn.querySelector('.btn-loading').style.display = 'none'; + + setTimeout(() => { + if (typeof window.loadPageAjax === 'function') window.loadPageAjax('/'); + else window.location.href = '/'; + }, dragModal ? 0 : 1000); + } else { + restoreBtn(); } } else { // --- File Upload --- - if (!selectedFile) return; + if (selectedFiles.length === 0) return; - if (submitBtn) { - 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 = 'Uploading...'; - } - } + setBtnLoading(isShitpost ? `Uploading 1/${selectedFiles.length}...` : 'Uploading...'); if (progressContainer) progressContainer.style.display = 'flex'; - - // Reset status if (statusDiv) { statusDiv.textContent = ''; statusDiv.className = 'upload-status'; } - const formData = new FormData(); - formData.append('file', selectedFile); - formData.append('rating', rating.value); - formData.append('tags', tags.join(',')); - formData.append('is_oc', form.querySelector('#upload-oc-checkbox')?.checked ? 'true' : 'false'); + let successCount = 0; + let lastData = null; - // Add custom thumbnail if provided (only for SWF files) - if (thumbInput && thumbInput.files && thumbInput.files[0]) { - formData.append('thumbnail', thumbInput.files[0]); - } - const commentEl = form.querySelector('.upload-comment'); - if (commentEl && commentEl.value.trim()) { - formData.append('comment', commentEl.value.trim()); - } + 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; - try { - const xhr = new XMLHttpRequest(); + if (isShitpost) { + const statusMsg = window.f0ckI18n?.upload_shitposting_status || 'Shitposting'; + setBtnLoading(`[${i + 1}/${selectedFiles.length}] ${statusMsg}...`); + } - 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 = percent + '%'; - } - }); + 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'); - xhr.onload = () => { - let res; - try { - res = JSON.parse(xhr.responseText); - } catch(e) { - if (xhr.status === 413) { - res = { success: false, msg: 'File too large (rejected by proxy)' }; + // 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 + })); } else { - res = { success: false, msg: 'Internal server error (invalid JSON)' }; - console.error('[UPLOAD ERROR] Failed to parse response:', xhr.responseText); + xhr.send(formData); } - } + }); if (res.success) { - const dragModal = form.closest('#upload-drag-modal'); - if (dragModal) dragModal.classList.remove('show'); - form._f0ckUploader.reset(); - if (!dragModal && statusDiv) { - statusDiv.innerHTML = '✓ ' + res.msg; - statusDiv.className = 'upload-status success'; - } - + successCount++; + lastData = res; if (res.itemid) { try { const ts = Date.now(); - const bustedStr = localStorage.getItem('bustedThumbs'); - const busted = bustedStr ? JSON.parse(bustedStr) : {}; + 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) {} } - - if (res.manual_approval && typeof window.showFlash === 'function') { - window.showFlash('Upload awaits approval', 'info'); - } - const navDelay = dragModal ? 0 : 1000; - setTimeout(() => { - if (typeof window.loadPageAjax === 'function') { - window.loadPageAjax('/'); - } else { - window.location.href = '/'; - } - }, navDelay); - } else { - if (statusDiv) { - statusDiv.textContent = '✕ ' + res.msg; - statusDiv.className = 'upload-status error'; - if (res.repost) { - statusDiv.innerHTML += ' View existing'; - } - } - if (submitBtn) { - 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 (progressContainer) progressContainer.style.display = 'none'; + } else if (!isShitpost) { + throw new Error(res.msg || 'Upload failed'); } - }; - - xhr.onerror = () => { - if (statusDiv) { - statusDiv.textContent = '✕ Upload failed. The connection was reset or interrupted.'; + } 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 (submitBtn) { - 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 (progressContainer) progressContainer.style.display = 'none'; - }; - - xhr.open('POST', '/api/v2/upload'); - xhr.setRequestHeader('X-CSRF-Token', window.f0ckSession?.csrf_token || ''); - xhr.send(formData); - - } catch (err) { - console.error(err); - if (statusDiv) { - statusDiv.textContent = '✕ Upload failed: ' + err.message; - statusDiv.className = 'upload-status error'; } - if (submitBtn) submitBtn.disabled = false; + } + + 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 = []; - selectedFile = null; + 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'; - const media = filePreview?.querySelector('.preview-media'); - if (media) media.remove(); + if (filePreview) { + filePreview.style.display = "none"; + filePreview.innerHTML = ""; + } if (urlInput) urlInput.value = ''; if (urlBadge) urlBadge.style.display = 'none'; if (progressContainer) { progressContainer.style.display = 'none'; } @@ -1365,20 +1769,46 @@ window.initUploadForm = (selector) => { } }; - // --- Keyboard Shortcuts --- - // Control+Enter to Submit - form.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - if (submitBtn && !submitBtn.disabled) { - e.preventDefault(); - submitBtn.click(); - } - } - }); - 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 => { diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index 556edd0..ed996a2 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -61,7 +61,13 @@ "uploading": "Wird hochgeladen...", "pending_approval_patient": "Upload wartet auf Freigabe, bitte haben Sie etwas Geduld", "remove_file": "Datei entfernen", - "cancel_upload": "Upload abbrechen" + "cancel_upload": "Upload abbrechen", + "shitpost_success": "{n} Beiträge erfolgreich gepostet!", + "shitposting_status": "Wird gepostet", + "item_comment_placeholder": "Kommentar (optional)...", + "item_tags_placeholder": "Tags...", + "btn_add_urls": "URL(s) hinzufügen", + "tags_required_shitpost": "Alle Beiträge benötigen Tags" }, "auth": { "registering": "Wird registriert...", @@ -305,6 +311,7 @@ "enter_url": "URL eingeben", "tags_required": "{n} Tag(s) noch erforderlich", "select_rating": "SFW oder NSFW auswählen", + "select_rating_nsfl": "SFW, NSFW oder NSFL auswählen", "embed_youtube": "YouTube-Video einbetten", "upload_from_url": "Von URL hochladen", "upload": "Hochladen" diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index 635fc58..a4d3ee0 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -43,6 +43,7 @@ "url_tab_yt": "URL / YouTube", "url_placeholder": "Paste a URL to download...", "url_placeholder_yt": "Paste a URL or YouTube link...", + "url_placeholder_shitpost": "Paste multiple URLs here (one per line)...", "drop_here": "Drop your file here", "admin_boost": "Admin Boost", "custom_thumbnail": "Custom Thumbnail", @@ -61,7 +62,13 @@ "uploading": "Uploading...", "pending_approval_patient": "Upload awaits approval, please be patient", "remove_file": "Remove File", - "cancel_upload": "Cancel Upload" + "cancel_upload": "Cancel Upload", + "shitpost_success": "Successfully shitposted {n} items!", + "shitposting_status": "Shitposting", + "item_comment_placeholder": "Comment (optional)...", + "item_tags_placeholder": "Tags...", + "btn_add_urls": "Add URL(s)", + "tags_required_shitpost": "All items need tags" }, "auth": { "registering": "Registering...", @@ -305,6 +312,7 @@ "enter_url": "Enter a URL", "tags_required": "{n} more tag{s} required", "select_rating": "Select SFW or NSFW", + "select_rating_nsfl": "Select SFW, NSFW or NSFL", "embed_youtube": "Embed YouTube Video", "upload_from_url": "Upload from URL", "upload": "Upload" diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index c84f0fc..6569354 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -61,7 +61,13 @@ "uploading": "Uploaden...", "pending_approval_patient": "Upload wacht op goedkeuring, even geduld alstublieft", "remove_file": "Bestand Verwijderen", - "cancel_upload": "Upload Annuleren" + "cancel_upload": "Upload Annuleren", + "shitpost_success": "{n} items succesvol gepost!", + "shitposting_status": "Lekker shitposten", + "item_comment_placeholder": "Opmerking (optioneel)...", + "item_tags_placeholder": "Etiketten...", + "btn_add_urls": "URL(s) toevoegen", + "tags_required_shitpost": "Alle items hebben tags nodig" }, "auth": { "registering": "Registreren...", @@ -305,6 +311,7 @@ "enter_url": "Voer een URL in", "tags_required": "{n} extra tag{s} vereist", "select_rating": "Selecteer SFW of NSFW", + "select_rating_nsfl": "Selecteer SFW, NSFW of NSFL", "embed_youtube": "YouTube-video Insluiten", "upload_from_url": "Uploaden via URL", "upload": "Uploaden" diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index 392f96c..20cc56c 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -61,7 +61,11 @@ "uploading": "Wird aufladiert...", "pending_approval_patient": "Die Ladung harrt der Absegnung, bitte haben Sie Geduld", "remove_file": "Datei entfernen", - "cancel_upload": "Aufladierung abbrechen" + "cancel_upload": "Aufladierung abbrechen", + "shitpost_success": "{n} Fetzen erfolgreich gepfeffert!", + "shitposting_status": "Wird gepfeffert", + "item_comment_placeholder": "Senf dazugeben (optional)...", + "item_tags_placeholder": "Etiketten..." }, "auth": { "registering": "Registrierung wird in die Wege geleitet...", diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs index 9c099ad..7b0ac8b 100644 --- a/src/inc/queue.mjs +++ b/src/inc/queue.mjs @@ -253,6 +253,16 @@ export default new class queue { const tmpFile = path.join(os.tmpdir(), itemid + '.png'); const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg'); + // Resolve real path if it's a symlink (important for reposts) + let sourcePath = path.join(bDir, filename); + try { + const lstat = await fs.promises.lstat(sourcePath); + if (lstat.isSymbolicLink()) { + sourcePath = await fs.promises.realpath(sourcePath); + console.log(`[QUEUE] Resolved symlink for thumbnailing: ${filename} -> ${sourcePath}`); + } + } catch (e) {} + if (mime === 'video/youtube') { const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null; if (videoId) { @@ -271,7 +281,7 @@ export default new class queue { else if (mime.startsWith('video/') || mime == 'image/gif') { const seeks = ['20%', '40%', '60%', '80%']; for (const seek of seeks) { - await this.spawn('ffmpegthumbnailer', ['-i', path.join(bDir, filename), '-s', '1024', '-t', seek, '-o', tmpFile]); + await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', '1024', '-t', seek, '-o', tmpFile]); try { const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']); if (parseFloat(stdout.trim()) > 0.05) break; @@ -279,9 +289,10 @@ export default new class queue { } } else if (mime.startsWith('image/') && mime != 'image/gif') - await this.spawn('magick', [path.join(bDir, filename) + '[0]', tmpFile]); + await this.spawn('magick', [sourcePath + '[0]', tmpFile]); else if (mime.startsWith('audio/')) { let coverExtracted = false; + this._lastCoverExtracted = false; // Reset state for this call if (link.match(/soundcloud/)) { const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : []; let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop(); @@ -301,7 +312,7 @@ export default new class queue { } if (!coverExtracted) { try { - await this.spawn('ffmpeg', ['-i', path.join(bDir, filename), '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]); + await this.spawn('ffmpeg', ['-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]); const size = (await fs.promises.stat(tmpJpg)).size; if (size > 0) { await this.spawn('magick', [tmpJpg, tmpFile]); @@ -313,7 +324,7 @@ export default new class queue { } else { // Try extracting embedded cover art (video stream in audio file) try { - await this.spawn('ffmpeg', ['-i', path.join(bDir, filename), '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]); + await this.spawn('ffmpeg', ['-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]); const size = (await fs.promises.stat(tmpJpg)).size; if (size > 0) { await this.spawn('magick', [tmpJpg, tmpFile]); @@ -367,7 +378,7 @@ export default new class queue { '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', '-dLastPage=1', '-sOutputFile=' + tmpFile, - path.join(bDir, filename) + sourcePath ]); } catch (err) { console.warn(`[QUEUE] PDF extraction failed for ${itemid}, using fallback icon.`); diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 6ab406c..e99df57 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -12,7 +12,7 @@ import cfg from "../config.mjs"; import security from "../security.mjs"; import crypto from "crypto"; import path from "path"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate } from "../settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs"; export default (router, tpl) => { router.get(/^\/login(\/)?$/, async (req, res) => { @@ -285,6 +285,7 @@ export default (router, tpl) => { log_user_ips: getLogUserIps(), hash_user_ips: getHashUserIps(), enable_cleanup: getEnableCleanup(), + shitpost_mode: getShitpostMode(), enable_cleanup_config: cfg.websrv.enable_cleanup !== false, tmp: null }, req) @@ -625,6 +626,7 @@ export default (router, tpl) => { setRegistrationOpen(registration_open === 'true'); } + await db`INSERT INTO site_settings (key, value) VALUES ('min_tags', ${min_tags.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; await db`INSERT INTO site_settings (key, value) VALUES ('trusted_uploads', ${trusted_uploads.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 13cc389..b35e295 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -145,6 +145,57 @@ export default router => { return [...new Set(tags)]; }; + group.get(/\/meta\/extract-url$/, lib.loggedin, async (req, res) => { + const url = req.url.qs?.url; + if (!url) return res.json({ success: false, msg: 'URL required' }, 400); + + try { + const results = []; + const seen = new Set(); + const addResult = (val) => { + if (!val) return; + const clean = String(val).replace(/<[^>]*>/g, '').replace(/[\x00-\x1F\x7F]/g, '').trim(); + if (clean && clean.length > 1 && clean.length <= 255 && !seen.has(clean.toLowerCase())) { + seen.add(clean.toLowerCase()); + results.push(clean); + } + }; + + // Add domain and auto-tags + const auto = autoTagsFromUrl(url); + auto.forEach(t => addResult(t)); + + // Try to get title via yt-dlp for supported sites + try { + const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : []; + const { stdout } = await queue.spawn('yt-dlp', [ + ...proxyArgs, + '--get-title', + '--get-description', + '--no-playlist', + '--skip-download', + url + ], { quiet: true, timeout: 5000 }); + + if (stdout) { + const lines = stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0); + if (lines[0]) addResult(lines[0]); // Title + if (lines[1]) { + // Description often has garbage, take only first line or short snippet + const desc = lines[1].split(/[.\n]/)[0].trim(); + if (desc.length > 3) addResult(desc); + } + } + } catch (e) { + // Fallback or ignore + } + + return res.json({ success: true, fields: results }); + } catch (err) { + return res.json({ success: false, msg: err.message }, 500); + } + }); + group.post(/\/upload-url$/, lib.loggedin, async (req, res) => { try { if (!cfg.websrv.web_url_upload) { @@ -515,7 +566,7 @@ export default router => { if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired); } catch (err) { const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t; - await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {}); + await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {}); } const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)); diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index 250d761..b1b8c74 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -12,6 +12,8 @@ let enable_pdf = false; let enable_cleanup = false; let cleanup_start_date = ''; let cleanup_end_date = ''; +export const getShitpostMode = () => !!cfg.websrv.shitpost_mode; +export const setShitpostMode = (val) => {}; // No-op, strictly config-based export const getEnableCleanup = () => { if (cfg.websrv.enable_cleanup === false) return false; diff --git a/src/index.mjs b/src/index.mjs index 5c773e1..08d6ffd 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs"; import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs"; import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps } from "./inc/settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { createI18n } from "./inc/i18n.mjs"; import security from "./inc/security.mjs"; @@ -660,6 +660,7 @@ process.on('uncaughtException', err => { console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message); } + // Set enable_pdf from config (pure config setting) setEnablePdf(!!cfg.enable_pdf); console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`); @@ -766,6 +767,7 @@ process.on('uncaughtException', err => { get registration_open() { return getRegistrationOpen(); }, registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false, get trusted_uploads() { return getTrustedUploads(); }, + get shitpost_mode() { return getShitpostMode(); }, get about_text() { return getAboutText(); }, get rules_text() { return getRulesText(); }, get terms_text() { return getTermsText(); }, diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 86c2ce1..af3cc14 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -79,21 +79,27 @@ export const handleUpload = async (req, res, self) => { const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1'); + const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1'); + if (!file || !file.data) { return sendJson(res, { success: false, msg: 'No file provided' }, 400); } - if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) { + // In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged) + const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : (is_shitpost ? null : null); + + if (!is_shitpost && !effectiveRating) { return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400); } - if (rating === 'nsfl' && !cfg.enable_nsfl) { + if (effectiveRating === 'nsfl' && !cfg.enable_nsfl) { return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400); } const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : []; const minTags = getMinTags(); - if (tags.length < minTags) { + // In shitpost mode, tags are optional — items without tags enter as untagged + if (!is_shitpost && tags.length < minTags) { return sendJson(res, { success: false, msg: `At least ${minTags} tags are required` }, 400); } @@ -363,7 +369,7 @@ export const handleUpload = async (req, res, self) => { } // Generate blurred thumbnail for NSFW/NSFL - if (rating === 'nsfw' || rating === 'nsfl') { + if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') { await queue.genBlurredThumbnail(itemid, isPending); } @@ -382,11 +388,13 @@ export const handleUpload = async (req, res, self) => { } } - // Tags - const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)); - await db` - insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} - `; + // Tags — rating tag only assigned if a rating was selected + if (effectiveRating) { + const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)); + await db` + insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} + `; + } for (const tagName of tags) { let tagRow = await db` @@ -476,7 +484,7 @@ export const handleUpload = async (req, res, self) => { if (actualMime.startsWith('audio')) { await moveSafe(path.join(cfg.paths.pending, 'ca', `${itemid}.webp`), coverDest); } - if (rating === 'nsfw' || rating === 'nsfl') { + if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') { await moveSafe(path.join(cfg.paths.pending, 't', `${itemid}_blur.webp`), blurDest); } } @@ -508,7 +516,7 @@ export const handleUpload = async (req, res, self) => { } // Ensure blurred thumbnail exists if needed - if (rating === 'nsfw' || rating === 'nsfl') { + if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') { const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; const blurPath = path.join(tDir, `${itemid}_blur.webp`); const blurExists = await fs.access(blurPath).then(() => true).catch(() => false); @@ -555,7 +563,7 @@ export const handleUpload = async (req, res, self) => { mime: actualMime, username: req.session.user, display_name: req.session.display_name || null, - tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)), + tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: !!is_oc })})`; } catch (err) { diff --git a/views/admin.html b/views/admin.html index e0e4dc1..c727f98 100644 --- a/views/admin.html +++ b/views/admin.html @@ -59,6 +59,7 @@ @endif +
diff --git a/views/snippets/footer.html b/views/snippets/footer.html index ee75fb1..021540f 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -411,6 +411,10 @@ uploading: "{{ t('upload.uploading') }}", processing: "{{ t('toast.processing') }}", upload_await_approval: "{{ t('upload.pending_approval_patient') }}", + upload_shitpost_success: "{{ t('upload.shitpost_success') || 'Successfully shitposted {n} items!' }}", + upload_shitposting_status: "{{ t('upload.shitposting_status') || 'Shitposting' }}", + upload_comment_placeholder: "{{ t('upload.item_comment_placeholder') || 'Comment (optional)...' }}", + upload_tags_placeholder: "{{ t('upload.item_tags_placeholder') || 'Tags...' }}", // timeago timeago_just_now: "{{ t('timeago.just_now') }}", timeago_year: "{{ t('timeago.year') }}", diff --git a/views/snippets/header.html b/views/snippets/header.html index a9bbab8..b8cf00a 100644 --- a/views/snippets/header.html +++ b/views/snippets/header.html @@ -46,7 +46,7 @@ @endif @endif - + @if(!private_society || session) diff --git a/views/snippets/upload-form.html b/views/snippets/upload-form.html index e9e6eb7..43f3fa6 100644 --- a/views/snippets/upload-form.html +++ b/views/snippets/upload-form.html @@ -1,4 +1,4 @@ -
+
@if(web_url_upload)
@@ -16,7 +16,7 @@
- +

{{ t('upload.drop_here') }}

(max {{ max_file_size }})@if(session.admin) {{ t('upload.admin_boost') }}@endif

@@ -39,7 +39,16 @@ @@ -55,7 +64,7 @@
-
+
-
-
- - {{ t('upload.original_content') }} -
-
-
+
-
+