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 @@
-