const clickOnElementBinding = selector => () => (elem = document.querySelector(selector)) ? elem.click() : null;
const clickOnNavBinding = (directionOrSelector) => () => {
+ if (window._currentActiveAlbumGallery && window._currentActiveAlbumGallery.isHovered) {
+ if (directionOrSelector === 'prev' || directionOrSelector === '#prev') {
+ window._currentActiveAlbumGallery.prev();
+ return;
+ } else if (directionOrSelector === 'next' || directionOrSelector === '#next') {
+ window._currentActiveAlbumGallery.next();
+ return;
+ }
+ }
let el;
if (directionOrSelector === 'prev' || directionOrSelector === '#prev') {
el = document.querySelector(".steuerung .nav-prev:not([href='#']), .nav-prev:not([href='#']), #prev:not([href='#'])") || document.querySelector('.nav-prev') || document.getElementById('prev');
@@ -6252,6 +6773,8 @@ window.cancelAnimFrame = (function () {
"7": () => seekToPercentage(0.7),
"8": () => seekToPercentage(0.8),
"9": () => seekToPercentage(0.9),
+ "[": () => window._currentActiveAlbumGallery?.prev(),
+ "]": () => window._currentActiveAlbumGallery?.next(),
"ArrowLeft": clickOnNavBinding("prev"),
"a": clickOnNavBinding("prev"),
"ArrowRight": clickOnNavBinding("next"),
@@ -14018,6 +14541,9 @@ document.addEventListener('click', (e) => {
const infoBtn = e.target.closest('#a_info');
if (infoBtn) {
e.preventDefault();
+ if (window._currentActiveAlbumGallery && typeof window._currentActiveAlbumGallery.updateInfoModal === 'function') {
+ window._currentActiveAlbumGallery.updateInfoModal();
+ }
const modal = document.getElementById('info-modal');
if (modal) {
modal.style.display = 'flex';
diff --git a/public/s/js/upload.js b/public/s/js/upload.js
index 4697711..301dca1 100644
--- a/public/s/js/upload.js
+++ b/public/s/js/upload.js
@@ -500,6 +500,34 @@ window.initUploadForm = (selector) => {
let autoTags = []; // Track tags suggested from metadata
let selectedFiles = []; // Array of files for shitpost_mode
let activeMode = 'file'; // 'file' or 'url'
+
+ // Album mode state and helpers
+ const albumChoiceContainer = form.querySelector('#album-choice-container');
+ let albumChoiceMode = 'album'; // 'album' | 'batch'
+
+ const isAlbumModeActive = () => {
+ if (activeMode === 'album') {
+ return selectedFiles.length > 0;
+ }
+ return activeMode === 'file' && selectedFiles.length > 1 && (albumChoiceMode === 'album' || !isShitpost);
+ };
+
+ if (albumChoiceContainer) {
+ const btns = albumChoiceContainer.querySelectorAll('.album-choice-btn');
+ btns.forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const choice = btn.getAttribute('data-choice');
+ if (choice === albumChoiceMode) return;
+ albumChoiceMode = choice;
+ btns.forEach(b => b.classList.toggle('active', b === btn));
+ selectedFiles.forEach(item => { delete item._rendered; });
+ if (filePreview) filePreview.innerHTML = '';
+ handleFile();
+ updateSubmitButton();
+ });
+ });
+ }
// Shared emoji cache for per-item pickers (fetched once, reused by all items)
let _emojiCache = null;
let _emojiCachePromise = null;
@@ -649,14 +677,47 @@ window.initUploadForm = (selector) => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
if (mode === activeMode) return;
+ const prevMode = activeMode;
activeMode = mode;
modeTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
- if (modeFile) modeFile.style.display = mode === 'file' ? '' : 'none';
+ if (modeFile) modeFile.style.display = (mode === 'file' || mode === 'album') ? '' : 'none';
if (modeUrl) modeUrl.style.display = mode === 'url' ? '' : 'none';
+ if (mode === 'album') {
+ albumChoiceMode = 'album';
+ form.classList.add('album-mode-active');
+ if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
+ if (fileInput) {
+ try {
+ const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
+ fileInput.accept = Object.keys(mimesObj).join(',');
+ } catch {}
+ }
+ if (selectedFiles.length > 0) {
+ renderAlbumStaging();
+ }
+ } else if (mode === 'file') {
+ if (fileInput) {
+ try {
+ const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
+ fileInput.accept = Object.keys(mimesObj).join(',');
+ } catch {}
+ }
+ if (!isAlbumModeActive()) {
+ form.classList.remove('album-mode-active');
+ if (prevMode === 'album' && isShitpost && selectedFiles.length > 0) {
+ selectedFiles.forEach(item => { delete item._rendered; });
+ if (filePreview) filePreview.innerHTML = '';
+ handleFile();
+ }
+ }
+ } else {
+ form.classList.remove('album-mode-active');
+ }
+
// Reset status
if (statusDiv) {
statusDiv.textContent = '';
@@ -1013,11 +1074,17 @@ window.initUploadForm = (selector) => {
}
const isShitpost = !!window.f0ckShitpostMode;
+ const isAlbum = isAlbumModeActive();
+ const isAlbumTab = activeMode === 'album';
+ const isAlbumActive = isAlbum || isAlbumTab;
+
+ form.classList.toggle('album-mode-active', isAlbumActive);
+
const rating = form.querySelector('input[name="rating"]:checked');
- // In Shitpost Mode, ratings are per-item. If require rating is true, every item must be rated.
+ // In Shitpost Mode, ratings are per-item unless album mode is active
let hasRating = true;
- if (isShitpost && activeMode === 'file') {
+ if (isShitpost && !isAlbumActive && activeMode === 'file') {
if (shitpostRequireRating) {
hasRating = selectedFiles.length > 0 && selectedFiles.every(item => ['sfw', 'nsfw', 'nsfl'].includes(item.rating));
}
@@ -1026,7 +1093,7 @@ window.initUploadForm = (selector) => {
}
let hasTags = true;
- if (!isShitpost) {
+ if (!isShitpost || isAlbumActive) {
hasTags = tags.length >= minTags;
} else if (shitpostMinTags > 0 && activeMode === 'file') {
// In shitpost file mode with min-tags enforced: every queued item must meet the threshold.
@@ -1038,17 +1105,21 @@ window.initUploadForm = (selector) => {
const commentSec = form.querySelector('.global-comment-section');
const tagsSec = form.querySelector('.global-tag-section');
const ocSec = form.querySelector('.global-oc-section');
+ const titleSec = form.querySelector('.global-title-section');
const formActions = form.querySelector('.form-actions');
if (isShitpost) {
if (formActions) {
- formActions.style.display = activeMode === 'url' ? 'none' : 'block';
+ formActions.style.display = (activeMode === 'url' && !isAlbumActive) ? 'none' : 'block';
}
- const hide = activeMode === 'file';
+ const hide = activeMode === 'file' && !isAlbumActive;
const disp = hide ? 'none' : 'block';
if (ratingSec) {
ratingSec.style.display = disp;
- ratingSec.querySelectorAll('input').forEach(i => i.disabled = hide);
+ ratingSec.querySelectorAll('input').forEach(i => {
+ i.disabled = hide;
+ i.required = !hide;
+ });
}
if (commentSec) {
commentSec.style.display = disp;
@@ -1059,13 +1130,19 @@ window.initUploadForm = (selector) => {
tagsSec.querySelectorAll('input').forEach(i => i.disabled = hide);
}
if (ocSec) {
- ocSec.style.display = 'none';
- ocSec.querySelectorAll('input').forEach(i => i.disabled = true);
+ ocSec.style.display = isAlbumActive ? 'block' : 'none';
+ ocSec.querySelectorAll('input').forEach(i => i.disabled = !isAlbumActive);
+ }
+ if (titleSec) {
+ titleSec.style.display = isAlbumActive ? 'block' : 'none';
+ titleSec.querySelectorAll('input').forEach(i => i.disabled = !isAlbumActive);
}
}
let hasContent = false;
- if (activeMode === 'file') {
+ if (activeMode === 'album') {
+ hasContent = selectedFiles.length >= 2;
+ } else if (activeMode === 'file') {
hasContent = selectedFiles.length > 0;
} else {
hasContent = urlInput && urlInput.value.trim().length > 0;
@@ -1077,13 +1154,19 @@ window.initUploadForm = (selector) => {
const btnText = submitBtn.querySelector('.btn-text');
if (btnText) {
const i18n = window.f0ckI18n || {};
- if (!hasContent) {
+ if (activeMode === 'album' && selectedFiles.length === 0) {
+ btnText.textContent = i18n.album_select_pictures || 'Select files for album';
+ submitBtn.disabled = true;
+ } else if (activeMode === 'album' && selectedFiles.length === 1) {
+ btnText.textContent = i18n.album_min_pictures || 'Add at least 2 items for an album';
+ submitBtn.disabled = true;
+ } else if (!hasContent) {
btnText.textContent = activeMode === 'file'
? (ssrSelectFileText || i18n.select_file || 'Select a file')
: (i18n.enter_url || 'Enter a URL');
} else if (!hasTags) {
// non-shitpost or shitpost with min-tags
- if (isShitpost && shitpostMinTags > 0) {
+ if (isShitpost && !isAlbumActive && shitpostMinTags > 0) {
const remaining = shitpostMinTags - Math.min(...selectedFiles.map(item => (item.tags || []).length));
btnText.textContent = `${remaining} more tag${remaining !== 1 ? 's' : ''} required per item`;
} else {
@@ -1095,7 +1178,7 @@ window.initUploadForm = (selector) => {
}
} else if (!hasRating) {
const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]');
- if (isShitpost && shitpostRequireRating) {
+ if (isShitpost && !isAlbumActive && shitpostRequireRating) {
btnText.textContent = 'Select a rating for each item';
} else {
if (nsflEnabled) {
@@ -1105,7 +1188,10 @@ window.initUploadForm = (selector) => {
}
}
} else {
- if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
+ if (isAlbumActive) {
+ const tpl = i18n.upload_album || 'Upload Album (%s subf0cks)';
+ btnText.textContent = tpl.replace('%s', selectedFiles.length);
+ } else if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
btnText.textContent = i18n.embed_youtube || 'Embed YouTube Video';
} else if (activeMode === 'url') {
btnText.textContent = i18n.upload_from_url || 'Upload from URL';
@@ -1126,15 +1212,194 @@ window.initUploadForm = (selector) => {
}
};
+ const renderAlbumStaging = () => {
+ if (!filePreview) return;
+ filePreview.style.display = 'block';
+ filePreview.innerHTML = '';
+
+ const stagingCont = document.createElement('div');
+ stagingCont.className = 'album-staging-container';
+
+ const stagingHeader = document.createElement('div');
+ stagingHeader.className = 'album-staging-header';
+ stagingHeader.innerHTML = `
+
+
+ ${(window.f0ckI18n && window.f0ckI18n.album_title) || 'Album'} (${selectedFiles.length} subf0cks)
+
+
+ `;
+ const addBtn = stagingHeader.querySelector('.btn-add-album-pics');
+ if (addBtn) {
+ addBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ if (fileInput) fileInput.click();
+ });
+ }
+ stagingCont.appendChild(stagingHeader);
+
+ const grid = document.createElement('div');
+ grid.className = 'album-staging-grid';
+
+ selectedFiles.forEach((item, index) => {
+ const file = item.file || item;
+ const card = document.createElement('div');
+ card.className = 'album-stage-card' + (index === 0 ? ' is-cover' : '');
+
+ const badge = document.createElement('div');
+ badge.className = 'album-stage-badge';
+ badge.innerHTML = index === 0 ? ' Cover' : `#${index + 1}`;
+ card.appendChild(badge);
+
+ const isVideo = (file.type && file.type.startsWith('video/')) || /\.(mp4|webm|mov|mkv)$/i.test(file.name || '');
+ const isAudio = (file.type && file.type.startsWith('audio/')) || /\.(mp3|ogg|wav|flac|m4a|aac)$/i.test(file.name || '');
+
+ if (isVideo) {
+ const video = document.createElement('video');
+ video.src = URL.createObjectURL(file);
+ video.muted = true;
+ video.playsInline = true;
+ video.autoplay = false;
+ video.preload = 'metadata';
+ card.appendChild(video);
+
+ const mimeBadge = document.createElement('span');
+ mimeBadge.className = 'album-stage-mime-badge';
+ mimeBadge.innerHTML = '';
+ card.appendChild(mimeBadge);
+ } else if (isAudio) {
+ const audioPreview = document.createElement('div');
+ audioPreview.className = 'album-stage-audio-preview';
+ audioPreview.innerHTML = `
+
+ ${file.name || 'Audio'}
+ `;
+ card.appendChild(audioPreview);
+
+ const mimeBadge = document.createElement('span');
+ mimeBadge.className = 'album-stage-mime-badge';
+ mimeBadge.innerHTML = '';
+ card.appendChild(mimeBadge);
+ } else {
+ const img = document.createElement('img');
+ img.src = URL.createObjectURL(file);
+ img.alt = file.name || `Subf0ck ${index + 1}`;
+ card.appendChild(img);
+ }
+
+ const actions = document.createElement('div');
+ actions.className = 'album-stage-actions';
+
+ // Move Left
+ const btnLeft = document.createElement('button');
+ btnLeft.type = 'button';
+ btnLeft.className = 'album-action-btn btn-album-move-left';
+ btnLeft.title = window.f0ckI18n?.album_move_left || 'Move left';
+ btnLeft.innerHTML = '';
+ if (index === 0) {
+ btnLeft.disabled = true;
+ btnLeft.style.opacity = '0.3';
+ btnLeft.style.cursor = 'not-allowed';
+ } else {
+ btnLeft.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const temp = selectedFiles[index];
+ selectedFiles[index] = selectedFiles[index - 1];
+ selectedFiles[index - 1] = temp;
+ renderAlbumStaging();
+ updateSubmitButton();
+ });
+ }
+ actions.appendChild(btnLeft);
+
+ // Move Right
+ const btnRight = document.createElement('button');
+ btnRight.type = 'button';
+ btnRight.className = 'album-action-btn btn-album-move-right';
+ btnRight.title = window.f0ckI18n?.album_move_right || 'Move right';
+ btnRight.innerHTML = '';
+ if (index === selectedFiles.length - 1) {
+ btnRight.disabled = true;
+ btnRight.style.opacity = '0.3';
+ btnRight.style.cursor = 'not-allowed';
+ } else {
+ btnRight.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const temp = selectedFiles[index];
+ selectedFiles[index] = selectedFiles[index + 1];
+ selectedFiles[index + 1] = temp;
+ renderAlbumStaging();
+ updateSubmitButton();
+ });
+ }
+ actions.appendChild(btnRight);
+
+ // Remove
+ const btnRemove = document.createElement('button');
+ btnRemove.type = 'button';
+ btnRemove.className = 'album-action-btn btn-album-remove';
+ btnRemove.title = window.f0ckI18n?.album_remove || 'Remove subf0ck';
+ btnRemove.innerHTML = '';
+ btnRemove.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ selectedFiles.splice(index, 1);
+ if (selectedFiles.length === 0) {
+ if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') {
+ form._f0ckUploader.reset();
+ }
+ } else {
+ handleFile();
+ updateSubmitButton();
+ }
+ });
+ actions.appendChild(btnRemove);
+
+ card.appendChild(actions);
+
+ const caption = document.createElement('div');
+ caption.className = 'album-stage-caption';
+ caption.title = file.name;
+ caption.textContent = `${file.name} (${formatSize(file.size)})`;
+ card.appendChild(caption);
+
+ grid.appendChild(card);
+ });
+
+ // Add More Card in Grid if under 100 subf0cks
+ const maxAlbumItems = 100;
+ if (selectedFiles.length < maxAlbumItems) {
+ const addMoreCard = document.createElement('div');
+ addMoreCard.className = 'album-stage-card album-stage-add-more';
+ addMoreCard.innerHTML = `
+
+ ${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}
+ (${selectedFiles.length}/${maxAlbumItems})
+ `;
+ addMoreCard.addEventListener('click', (e) => {
+ e.preventDefault();
+ if (fileInput) fileInput.click();
+ });
+ grid.appendChild(addMoreCard);
+ }
+
+ stagingCont.appendChild(grid);
+ filePreview.appendChild(stagingCont);
+ };
+
const handleFile = (files) => {
const isShitpost = !!window.f0ckShitpostMode;
// If files were provided, process them (append or replace)
if (files && files.length > 0) {
- const filesToProcess = isShitpost ? Array.from(files) : [files[0]];
- if (!isShitpost) {
- selectedFiles = []; // Reset for normal mode — replace, not append
- // Also wipe the preview DOM so the old card doesn't linger
+ const isMultiAllowed = isShitpost || activeMode === 'album' || files.length > 1 || selectedFiles.length > 0;
+ const filesToProcess = isMultiAllowed ? Array.from(files) : [files[0]];
+ if (!isMultiAllowed && selectedFiles.length === 0) {
+ selectedFiles = []; // Reset for normal mode single non-image file
if (filePreview) filePreview.innerHTML = '';
}
@@ -1219,18 +1484,32 @@ window.initUploadForm = (selector) => {
}
}
+ if (activeMode === 'album' && selectedFiles.length >= 100) {
+ const errorMsg = 'Album limit reached (maximum 100 subf0cks).';
+ if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error');
+ else if (window.showFlash) window.showFlash(errorMsg, 'error');
+ else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; }
+ break;
+ }
+
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: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
- } else {
- selectedFiles.push(file); // Legacy single file mode uses raw File
- }
+ selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
}
}
}
+ // Toggle album choice container
+ if (activeMode === 'album') {
+ albumChoiceMode = 'album';
+ }
+ const isAlbumCandidate = activeMode === 'file' && selectedFiles.length > 1;
+ if (albumChoiceContainer) {
+ albumChoiceContainer.style.display = isAlbumCandidate ? 'flex' : 'none';
+ }
+
// Rebuild UI state
if (selectedFiles.length === 0) {
+ if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
if (filePreview) {
filePreview.style.display = 'none';
filePreview.innerHTML = '';
@@ -1257,14 +1536,23 @@ window.initUploadForm = (selector) => {
statusDiv.className = 'upload-status';
}
- // Force 'file' mode tab UI
- if (activeMode !== 'file' && modeTabs.length > 0) {
+ // Force 'file' or 'album' mode tab UI if coming from URL mode
+ if (activeMode !== 'file' && activeMode !== 'album' && modeTabs.length > 0) {
modeTabs.forEach(t => t.classList.remove('active'));
- const fileTab = form.querySelector('.upload-mode-tab[data-mode="file"]');
- if (fileTab) fileTab.classList.add('active');
+ const targetMode = isAlbumModeActive() ? 'album' : 'file';
+ const targetTab = form.querySelector(`.upload-mode-tab[data-mode="${targetMode}"]`);
+ if (targetTab) targetTab.classList.add('active');
if (modeFile) modeFile.style.display = '';
if (modeUrl) modeUrl.style.display = 'none';
- activeMode = 'file';
+ activeMode = targetMode;
+ }
+
+ // If Album Mode is active, render Album Staging
+ if (isAlbumModeActive()) {
+ renderAlbumStaging();
+ updateSubmitButton();
+ form.dispatchEvent(new CustomEvent('fileReady', { detail: { files: selectedFiles } }));
+ return true;
}
let lastNewPreviewItem = null;
@@ -1962,7 +2250,7 @@ window.initUploadForm = (selector) => {
// Legacy Global Meta Sync (Non-Shitpost Mode)
if (!isShitpost && selectedFiles.length > 0 && files && files.length > 0) {
- const primaryFile = selectedFiles[0];
+ const primaryFile = selectedFiles[0].file || selectedFiles[0];
autoTags = [];
const metaCont = form.querySelector('.meta-suggestions-container');
const metaList = form.querySelector('.meta-suggestions-list');
@@ -2084,6 +2372,7 @@ window.initUploadForm = (selector) => {
if (el._swfObjectUrl) { URL.revokeObjectURL(el._swfObjectUrl); el._swfObjectUrl = null; }
});
selectedFiles = [];
+ if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
form.querySelector('.gps-privacy-warning')?.remove();
if (fileInput) fileInput.value = '';
if (dropZonePrompt) dropZonePrompt.style.display = 'block';
@@ -2475,17 +2764,22 @@ window.initUploadForm = (selector) => {
}
const isFileMode = activeMode === 'file';
+ const isAlbum = isAlbumModeActive() || activeMode === 'album';
const globalRatingEl = form.querySelector('input[name="rating"]:checked');
// Validation
- if (isShitpost && isFileMode) {
+ if (isShitpost && isFileMode && !isAlbum) {
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 (isAlbum && selectedFiles.length < 2) {
+ if (window.showFlash) window.showFlash('Add at least 2 pictures for an album', 'error');
+ return;
+ }
if (!globalRatingEl) {
if (window.showFlash) window.showFlash('Please select a rating', 'error');
return;
@@ -2661,6 +2955,122 @@ window.initUploadForm = (selector) => {
// --- File Upload ---
if (selectedFiles.length === 0) return;
+ const isAlbum = isAlbumModeActive() || activeMode === 'album';
+ if (isAlbum) {
+ const statusMsg = window.f0ckI18n?.uploading_album || `Uploading album (${selectedFiles.length} pictures)...`;
+ setBtnLoading(statusMsg);
+ if (progressContainer) progressContainer.style.display = 'flex';
+ if (statusDiv) {
+ statusDiv.textContent = '';
+ statusDiv.className = 'upload-status';
+ }
+
+ const globalRatingEl = form.querySelector('input[name="rating"]:checked');
+ const globalVisEl = form.querySelector('input[name="visibility"]:checked');
+ const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
+
+ const formData = new FormData();
+ formData.append('is_album', 'true');
+ formData.append('rating', globalRatingEl ? globalRatingEl.value : 'sfw');
+ formData.append('visibility', globalVisEl ? globalVisEl.value : '0');
+ formData.append('expiry', globalExpiryEl ? globalExpiryEl.value : 'permanent');
+ formData.append('tags', tags.join(','));
+ formData.append('is_oc', isOc ? 'true' : 'false');
+ if (titleVal) formData.append('title', titleVal);
+ if (comment) formData.append('comment', comment);
+
+ for (let i = 0; i < selectedFiles.length; i++) {
+ const f = selectedFiles[i].file || selectedFiles[i];
+ formData.append('files', f);
+ }
+
+ 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 = 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'));
+ xhr.open('POST', '/api/v2/upload');
+ const csrf = window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '';
+ xhr.setRequestHeader('X-CSRF-Token', csrf);
+ xhr.send(formData);
+ });
+
+ if (res.success) {
+ if (dragModal) dragModal.classList.remove('show');
+ const dropModal = document.getElementById('upload-drag-modal');
+ if (dropModal) dropModal.classList.remove('show');
+ form._f0ckUploader.reset();
+
+ const successMsg = res.msg || `Album (${res.album_count || selectedFiles.length} subf0cks) uploaded successfully!`;
+ if (typeof window.flashMessage === 'function') {
+ window.flashMessage(successMsg, 3000, 'success');
+ } else if (!dragModal && statusDiv) {
+ statusDiv.innerHTML = '✓ ' + successMsg;
+ statusDiv.className = 'upload-status success';
+ }
+
+ if (res.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
+ window.NotificationSystemInstance.handleNewItem({
+ id: res.itemid,
+ dest: res.dest,
+ mime: res.mime,
+ username: res.username || window.f0ckSession?.user || '',
+ display_name: res.display_name || window.f0ckSession?.display_name || null,
+ tag_id: res.tag_id ?? 0,
+ is_oc: !!res.is_oc,
+ slug: res.slug,
+ visibility: res.visibility || 0
+ });
+ }
+
+ const targetUrl = res.slug ? `/${res.slug}` : (res.itemid ? `/${res.itemid}` : '/');
+ if (typeof window.loadPageAjax === 'function') {
+ window.loadPageAjax(targetUrl, true, { bypassCache: true });
+ } else {
+ window.location.href = targetUrl;
+ }
+ return;
+ } else {
+ const errMsg = res.msg || 'Upload failed';
+ const err = new Error(errMsg);
+ if (res.repost) err.repost = res.repost;
+ throw err;
+ }
+ } catch (err) {
+ console.error('[ALBUM UPLOAD ERROR]', err);
+ if (err.repost) {
+ statusDiv.innerHTML = '✕ ' + window.escapeHtmlUpload(err.message) + ` (view existing)`;
+ } else {
+ statusDiv.textContent = '✕ ' + err.message;
+ }
+ statusDiv.className = 'upload-status error';
+ if (progressContainer) progressContainer.style.display = 'none';
+ restoreBtn();
+ return;
+ }
+ }
+
setBtnLoading(isShitpost ? `Uploading 1/${selectedFiles.length}...` : 'Uploading...');
if (progressContainer) progressContainer.style.display = 'flex';
if (statusDiv) {
@@ -2909,6 +3319,13 @@ window.initUploadForm = (selector) => {
form.reset();
tags = [];
selectedFiles = [];
+ if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
+ albumChoiceMode = 'album';
+ if (albumChoiceContainer) {
+ albumChoiceContainer.querySelectorAll('.album-choice-btn').forEach(b => {
+ b.classList.toggle('active', b.getAttribute('data-choice') === 'album');
+ });
+ }
if (tagsList) tagsList.innerHTML = '';
if (tagsHidden) tagsHidden.value = '';
if (fileInput) fileInput.style.display = 'inline-block';
@@ -2937,6 +3354,7 @@ window.initUploadForm = (selector) => {
// Reset mode to 'file'
activeMode = 'file';
+ form.classList.remove('album-mode-active');
if (modeTabs.length > 0) {
modeTabs.forEach(t => {
if (t.dataset.mode === 'file') t.classList.add('active');
diff --git a/public/s/js/v0ck.js b/public/s/js/v0ck.js
index 1d68d73..671d2bd 100644
--- a/public/s/js/v0ck.js
+++ b/public/s/js/v0ck.js
@@ -134,8 +134,8 @@ class v0ck {
}
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
- const player = document.querySelector('.v0ck');
- player.style.backgroundImage = `url('${elem.getAttribute('poster')}')`;
+ const player = elem.closest('.v0ck') || document.querySelector('.v0ck');
+ if (player) player.style.backgroundImage = `url('${elem.getAttribute('poster')}')`;
}
}
else
@@ -144,7 +144,7 @@ class v0ck {
}
init(elem) {
- const player = document.querySelector('.v0ck');
+ const player = elem.closest('.v0ck') || document.querySelector('.v0ck');
const video = elem;
video.removeAttribute('controls');
video.removeAttribute('autoplay');
@@ -200,10 +200,21 @@ class v0ck {
return video[video.paused ? 'play' : 'pause']();
}
function updatePlayIcon() {
- toggle.classList.toggle('playing');
- player.classList.toggle('paused');
- toggle.setAttribute('title', toggle.classList.contains('playing') ? 'Pause' : 'Play');
- [...toggle.querySelectorAll('use')].forEach(icon => icon.classList.toggle('v0ck_hidden'));
+ const isPlaying = !video.paused;
+ toggle.classList.toggle('playing', isPlaying);
+ player.classList.toggle('paused', !isPlaying);
+ toggle.setAttribute('title', isPlaying ? 'Pause' : 'Play');
+ const playIcon = toggle.querySelector('#v0ck_svg_play');
+ const pauseIcon = toggle.querySelector('#v0ck_svg_pause');
+ if (playIcon && pauseIcon) {
+ playIcon.classList.toggle('v0ck_hidden', isPlaying);
+ pauseIcon.classList.toggle('v0ck_hidden', !isPlaying);
+ } else {
+ [...toggle.querySelectorAll('use')].forEach(icon => {
+ const isPlaySvg = icon.id === 'v0ck_svg_play' || icon.getAttribute('href')?.includes('play');
+ icon.classList.toggle('v0ck_hidden', isPlaySvg ? isPlaying : !isPlaying);
+ });
+ }
}
function toggleMute(e) {
if (video.volume === 0)
diff --git a/src/inc/lib_delete.mjs b/src/inc/lib_delete.mjs
index 181fac8..7e1697d 100644
--- a/src/inc/lib_delete.mjs
+++ b/src/inc/lib_delete.mjs
@@ -231,6 +231,7 @@ export async function purgeExpiredUploads() {
if (item.dest) {
await safeDeleteMediaFile(item.dest, item.id);
}
+ await safeDeleteAlbumFiles(item.id);
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
if (item.mime && item.mime.startsWith('audio')) {
@@ -249,3 +250,23 @@ export async function purgeExpiredUploads() {
}
}
+/**
+ * Safely delete all album image files associated with an item.
+ * @param {number} itemId
+ */
+export async function safeDeleteAlbumFiles(itemId) {
+ try {
+ const albumRows = await db`SELECT dest FROM album_items WHERE item_id = ${itemId}`;
+ for (const row of albumRows) {
+ if (row.dest) {
+ await safeDeleteMediaFile(row.dest, itemId);
+ const thumbName = row.dest.replace(/\.[^.]+$/, '.webp');
+ await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
+ }
+ }
+ await db`DELETE FROM album_items WHERE item_id = ${itemId}`.catch(() => {});
+ } catch (e) {
+ console.error(`[DELETE] Failed to delete album files for item #${itemId}:`, e);
+ }
+}
+
diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json
index 2551e18..6255e39 100644
--- a/src/inc/locales/de.json
+++ b/src/inc/locales/de.json
@@ -831,5 +831,24 @@
"private": "Privat",
"change_visibility": "Sichtbarkeit ändern"
}
+ },
+ "album": {
+ "title": "Album",
+ "multiple_selected": "Mehrere Dateien ausgewählt",
+ "mode_album": "Album (1 Beitrag)",
+ "mode_batch": "Einzelne Beiträge",
+ "cover": "Titelbild",
+ "pictures": "Subf0cks",
+ "counter": "%s von %s",
+ "prev": "Vorheriger Subf0ck",
+ "next": "Nächster Subf0ck",
+ "hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
+ "move_left": "Nach links",
+ "move_right": "Nach rechts",
+ "remove_picture": "Subf0ck entfernen",
+ "add_more": "Weitere Subf0cks hinzufügen",
+ "drop_hint": "Wähle oder ziehe mehrere Dateien (Subf0cks) hierher, um ein Album zu erstellen",
+ "select_pictures": "Dateien für Album auswählen",
+ "min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich"
}
}
\ No newline at end of file
diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json
index 7ef2f4c..abc76f8 100644
--- a/src/inc/locales/en.json
+++ b/src/inc/locales/en.json
@@ -831,5 +831,24 @@
"private": "Private",
"change_visibility": "Change Visibility"
}
+ },
+ "album": {
+ "title": "Album",
+ "multiple_selected": "Multiple files selected",
+ "mode_album": "Album (1 post)",
+ "mode_batch": "Separate posts",
+ "cover": "Cover",
+ "pictures": "subf0cks",
+ "counter": "%s of %s",
+ "prev": "Previous subf0ck",
+ "next": "Next subf0ck",
+ "hotkey_tip": "Use [ and ] or hover to navigate subf0cks",
+ "move_left": "Move left",
+ "move_right": "Move right",
+ "remove_picture": "Remove subf0ck",
+ "add_more": "Add more subf0cks",
+ "drop_hint": "Select or drop multiple files (subf0cks) to create an Album",
+ "select_pictures": "Select files for album",
+ "min_pictures": "Add at least 2 subf0cks for an album"
}
}
\ No newline at end of file
diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json
index 9cc1418..e3225ed 100644
--- a/src/inc/locales/nl.json
+++ b/src/inc/locales/nl.json
@@ -821,5 +821,24 @@
"slot_refreshes_on": "slot vernieuwd op {date}",
"slot_refreshed": "slot vernieuwd",
"admin_desc": "Je bent admin, ga je gang."
+ },
+ "album": {
+ "title": "Album",
+ "multiple_selected": "Meerdere afbeeldingen geselecteerd",
+ "mode_album": "Album (1 bericht)",
+ "mode_batch": "Aparte berichten",
+ "cover": "Omslag",
+ "pictures": "afbeeldingen",
+ "counter": "%s van %s",
+ "prev": "Vorige afbeelding",
+ "next": "Volgende afbeelding",
+ "hotkey_tip": "Gebruik [ en ] of zweef over album om te navigeren",
+ "move_left": "Naar links",
+ "move_right": "Naar rechts",
+ "remove_picture": "Afbeelding verwijderen",
+ "add_more": "Meer afbeeldingen toevoegen",
+ "drop_hint": "Selecteer of sleep meerdere afbeeldingen om een album te maken",
+ "select_pictures": "Selecteer afbeeldingen voor album",
+ "min_pictures": "Voeg minimaal 2 afbeeldingen toe voor een album"
}
}
\ No newline at end of file
diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json
index 171e8a7..981be4f 100644
--- a/src/inc/locales/zange.json
+++ b/src/inc/locales/zange.json
@@ -821,5 +821,24 @@
"slot_refreshes_on": "Platz erneuert sich am {date}",
"slot_refreshed": "Platz erneuert",
"admin_desc": "Du bist Admin, mach weiter."
+ },
+ "album": {
+ "title": "Album",
+ "multiple_selected": "Mehrere Bildnisse ausgewählt",
+ "mode_album": "Album (1 Einpfostung)",
+ "mode_batch": "Vereinzelte Einpfostungen",
+ "cover": "Deckblatt",
+ "pictures": "Bildnisse",
+ "counter": "%s von %s",
+ "prev": "Vorheriges Bildnis",
+ "next": "Nächstes Bildnis",
+ "hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
+ "move_left": "Nach links",
+ "move_right": "Nach rechts",
+ "remove_picture": "Bildnis entfernen",
+ "add_more": "Weitere Bildnisse hinzufügen",
+ "drop_hint": "Wähle oder droppe mehrere Bilder für 1 Album",
+ "select_pictures": "Bildnisse fürs Album auswählen",
+ "min_pictures": "Mindestens 2 Bildnisse fürs Album nötig"
}
}
\ No newline at end of file
diff --git a/src/inc/multipart.mjs b/src/inc/multipart.mjs
index fa9ddec..d716ca1 100644
--- a/src/inc/multipart.mjs
+++ b/src/inc/multipart.mjs
@@ -53,15 +53,32 @@ export const parseMultipart = (buffer, boundary) => {
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
- const name = nameMatch[1];
+ let name = nameMatch[1];
+ if (name.endsWith('[]')) {
+ name = name.slice(0, -2);
+ }
if (extractedFilename !== null) {
- parts[name] = {
+ const fileObj = {
filename: extractedFilename,
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
+ if (!parts[name]) {
+ parts[name] = fileObj;
+ } else if (Array.isArray(parts[name])) {
+ parts[name].push(fileObj);
+ } else {
+ parts[name] = [parts[name], fileObj];
+ }
} else {
- parts[name] = body.toString().trim();
+ const textVal = body.toString().trim();
+ if (!parts[name]) {
+ parts[name] = textVal;
+ } else if (Array.isArray(parts[name])) {
+ parts[name].push(textVal);
+ } else {
+ parts[name] = [parts[name], textVal];
+ }
}
}
}
diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs
index 1d95a03..76fcfc4 100644
--- a/src/inc/queue.mjs
+++ b/src/inc/queue.mjs
@@ -503,14 +503,25 @@ export default new class queue {
} else {
// Try extracting embedded cover art (video stream in audio file)
try {
- 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]);
- await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
+ const caWebp = path.join(cDir, itemid + '.webp');
+ await this.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'webp', '-frames:v', '1', caWebp]);
+ const stat = await fs.promises.stat(caWebp).catch(() => null);
+ if (stat && stat.size > 0) {
+ await this.spawn('magick', [caWebp + '[0]', tmpFile]);
coverExtracted = true;
}
} catch (err) { }
+ if (!coverExtracted) {
+ try {
+ await this.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
+ const size = (await fs.promises.stat(tmpJpg).catch(() => ({ size: 0 }))).size;
+ if (size > 0) {
+ await this.spawn('magick', [tmpJpg, tmpFile]);
+ await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
+ coverExtracted = true;
+ }
+ } catch (err) { }
+ }
}
// If no new cover art extracted, check if cover art was already saved previously in cDir
if (!coverExtracted) {
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index 05d87f8..119856c 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -119,7 +119,9 @@ const resolveNumericItemId = async (itemIdOrSlug) => {
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
try {
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
- return rows[0]?.id || null;
+ if (rows[0]?.id) return rows[0].id;
+ const subRows = await db`SELECT item_id FROM album_items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
+ return subRows[0]?.item_id || null;
} catch (e) {
return null;
}
@@ -770,6 +772,8 @@ const f0cklib = {
items.is_oc,
items.xd_score,
items.has_coverart,
+ items.is_album,
+ items.album_count,
${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``}
${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`}
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag,
@@ -915,7 +919,19 @@ const f0cklib = {
}
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
- const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
+ let itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
+ let requestedSubf0ckSlug = null;
+
+ if (!isNumeric) {
+ const itemRow = await db`SELECT id FROM items WHERE slug = ${String(rawIdOrSlug)} LIMIT 1`;
+ if (!itemRow.length) {
+ const subRow = await db`SELECT item_id, slug FROM album_items WHERE slug = ${String(rawIdOrSlug)} LIMIT 1`;
+ if (subRow.length) {
+ requestedSubf0ckSlug = subRow[0].slug;
+ itemLookup = db`items.id = ${subRow[0].item_id}`;
+ }
+ }
+ }
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = exclude || [];
@@ -1254,14 +1270,30 @@ const f0cklib = {
}
- // Efficient coverart fallback
+ // Efficient coverart fallback with on-demand extraction
let hasCoverart = actitem.has_coverart;
if (!hasCoverart && actitem.mime?.startsWith('audio/')) {
const caPath = path.join(cfg.paths.ca, `${actitem.id}.webp`);
try {
- if (fs.existsSync(caPath)) {
+ if (fs.existsSync(caPath) && fs.statSync(caPath).size > 0) {
hasCoverart = true;
db`UPDATE items SET has_coverart = TRUE WHERE id = ${actitem.id}`.catch(() => {});
+ } else {
+ // Attempt extraction directly from audio file if embedded
+ const sourcePath = path.join(cfg.paths.b, actitem.dest);
+ if (fs.existsSync(sourcePath)) {
+ await queue.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'webp', '-frames:v', '1', caPath], { quiet: true }).catch(() => {});
+ if (fs.existsSync(caPath) && fs.statSync(caPath).size > 0) {
+ hasCoverart = true;
+ db`UPDATE items SET has_coverart = TRUE WHERE id = ${actitem.id}`.catch(() => {});
+ const tPath = path.join(cfg.paths.t, `${actitem.id}.webp`);
+ if (!fs.existsSync(tPath) || fs.statSync(tPath).size === 0) {
+ await queue.spawn('magick', [caPath + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', tPath], { quiet: true }).catch(() => {});
+ }
+ } else {
+ try { fs.unlinkSync(caPath); } catch (_) {}
+ }
+ }
}
} catch (_) {}
}
@@ -1269,6 +1301,102 @@ const f0cklib = {
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
: `/s/img/music.webp`;
+ let album = [];
+ if (actitem.is_album) {
+ try {
+ const albumRows = await db`
+ SELECT id, dest, mime, size, width, height, order_index, slug, checksum
+ FROM album_items
+ WHERE item_id = ${itemid}
+ ORDER BY order_index ASC
+ `;
+ if (albumRows.length > 0) {
+ album = await Promise.all(albumRows.map(async (r, idx) => {
+ const order = r.order_index !== undefined && r.order_index !== null ? r.order_index : idx;
+ const subSlug = r.slug || r.id;
+ const subBase = r.dest.replace(/\.[^.]+$/, '');
+ const isAudio = (r.mime || '').startsWith('audio/');
+ let subCover = null;
+ let subThumb = `${cfg.websrv.paths.thumbnails}/${subBase}.webp`;
+
+ if (isAudio) {
+ const caFile = path.join(cfg.paths.ca, `${subBase}.webp`);
+ const tFile = path.join(cfg.paths.t, `${subBase}.webp`);
+ let caExists = false;
+ try {
+ caExists = fs.existsSync(caFile) && fs.statSync(caFile).size > 0;
+ } catch (_) {}
+
+ if (!caExists && order === 0 && hasCoverart) {
+ const parentCaFile = path.join(cfg.paths.ca, `${actitem.id}.webp`);
+ try {
+ if (fs.existsSync(parentCaFile) && fs.statSync(parentCaFile).size > 0) {
+ subCover = `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`;
+ subThumb = `${cfg.websrv.paths.thumbnails}/${actitem.id}.webp`;
+ caExists = true;
+ }
+ } catch (_) {}
+ }
+
+ if (!caExists) {
+ const audioSource = path.join(cfg.paths.b, r.dest);
+ if (fs.existsSync(audioSource)) {
+ try {
+ await queue.spawn('ffmpeg', ['-y', '-i', audioSource, '-an', '-vcodec', 'webp', '-frames:v', '1', caFile], { quiet: true });
+ if (fs.existsSync(caFile) && fs.statSync(caFile).size > 0) {
+ caExists = true;
+ await queue.spawn('magick', [caFile + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', tFile], { quiet: true });
+ } else {
+ try { fs.unlinkSync(caFile); } catch (_) {}
+ }
+ } catch (_) {}
+ }
+ }
+
+ if (caExists) {
+ if (!subCover) subCover = `${cfg.websrv.paths.coverarts}/${subBase}.webp`;
+ try {
+ if (!fs.existsSync(tFile) || fs.statSync(tFile).size === 0) {
+ subThumb = subCover;
+ }
+ } catch (_) {
+ subThumb = subCover;
+ }
+ } else {
+ subCover = '/s/img/audio.webp';
+ subThumb = '/s/img/audio.webp';
+ }
+ }
+
+ return {
+ id: r.id,
+ slug: r.slug,
+ subf0ck_id: subSlug,
+ dest: `${cfg.websrv.paths.images}/${r.dest}`,
+ src: `${cfg.websrv.paths.images}/${r.dest}`,
+ filename: r.dest,
+ mime: r.mime,
+ size: lib.formatSize(r.size),
+ width: r.width,
+ height: r.height,
+ checksum: r.checksum,
+ order_index: order,
+ display_index: order + 1,
+ is_first: order === 0,
+ is_video: (r.mime || '').startsWith('video/'),
+ is_audio: isAudio,
+ is_image: (r.mime || '').startsWith('image/'),
+ has_coverart: isAudio ? (subCover && subCover !== '/s/img/audio.webp') : false,
+ coverart: isAudio ? subCover : null,
+ thumb: subThumb
+ };
+ }));
+ }
+ } catch (err) {
+ console.error('[GETF0CK] Failed to fetch album items:', err.message);
+ }
+ }
+
const duration = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Fetch complete in ${duration}ms`);
@@ -1410,8 +1538,12 @@ const f0cklib = {
height: actitem.height || null,
original_filename: actitem.original_filename || null,
expires_at: actitem.expires_at || null,
- expires_in: lib.expiresIn(actitem.expires_at)
-
+ expires_in: lib.expiresIn(actitem.expires_at),
+ is_album: !!(actitem.is_album && album.length > 1),
+ album_count: album.length || actitem.album_count || 0,
+ album: album,
+ album_json: JSON.stringify(album),
+ requested_subf0ck_slug: requestedSubf0ckSlug || null
},
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: {
diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs
index 5f8a6bf..bf86db5 100644
--- a/src/inc/settings.mjs
+++ b/src/inc/settings.mjs
@@ -144,6 +144,21 @@ export const ensureAllItemsHaveSlugs = async () => {
}
};
+export const ensureAllAlbumItemsHaveSlugs = async () => {
+ try {
+ const rows = await db`SELECT id FROM album_items WHERE slug IS NULL OR slug = ''`;
+ if (!rows || rows.length === 0) return;
+ console.log(`[ALBUM_SLUG_BACKFILL] Found ${rows.length} album item(s) missing slugs. Backfilling...`);
+ for (const row of rows) {
+ const newSlug = lib.generateSlug(11);
+ await db`UPDATE album_items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
+ }
+ console.log(`[ALBUM_SLUG_BACKFILL] Successfully backfilled ${rows.length} album item slug(s).`);
+ } catch (err) {
+ console.error('[ALBUM_SLUG_BACKFILL] Error during album item slug backfill:', err.message);
+ }
+};
+
export const getEnableCleanup = () => {
diff --git a/src/index.mjs b/src/index.mjs
index 256ddcd..e0e28be 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -20,7 +20,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
-import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } from "./inc/settings.mjs";
+import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
@@ -524,6 +524,27 @@ process.on('uncaughtException', err => {
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`);
+ await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip character varying(128) DEFAULT NULL`);
+ await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS is_album boolean DEFAULT false`);
+ await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0`);
+ await runMigration(db`
+ CREATE TABLE IF NOT EXISTS album_items (
+ id SERIAL PRIMARY KEY,
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+ dest CHARACTER VARYING(60) NOT NULL,
+ mime CHARACTER VARYING(100) NOT NULL,
+ size INTEGER NOT NULL,
+ checksum CHARACTER VARYING(255) NOT NULL,
+ phash TEXT,
+ width INTEGER,
+ height INTEGER,
+ order_index INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ )
+ `);
+ await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_item_id ON album_items(item_id, order_index ASC)`);
+ await runMigration(db`ALTER TABLE album_items ADD COLUMN IF NOT EXISTS slug character varying(60) DEFAULT NULL`);
+ await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_slug ON album_items(slug)`);
// Initial halls cache (only if halls are enabled)
if (cfg.websrv.halls_enabled !== false) {
@@ -1729,6 +1750,7 @@ process.on('uncaughtException', err => {
// Ensure all items in database have a unique slug backfilled
ensureAllItemsHaveSlugs();
+ ensureAllAlbumItemsHaveSlugs();
const globals = {
lul: cfg.websrv.lul,
diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs
index 4469a96..5527332 100644
--- a/src/upload_handler.mjs
+++ b/src/upload_handler.mjs
@@ -175,7 +175,13 @@ export const handleUpload = async (req, res, self) => {
}
// Validate required fields
- let file = (typeof parts.file === 'object' && parts.file !== null && parts.file.data) ? parts.file : null;
+ let rawFiles = [];
+ if (Array.isArray(parts.files)) rawFiles = parts.files;
+ else if (Array.isArray(parts.file)) rawFiles = parts.file;
+ else if (parts.files && typeof parts.files === 'object' && parts.files.data) rawFiles = [parts.files];
+ else if (parts.file && typeof parts.file === 'object' && parts.file.data) rawFiles = [parts.file];
+
+ let file = rawFiles[0] || null;
let inputUrl = (typeof parts.url === 'string' && parts.url.trim()) ? parts.url.trim() : null;
if (!inputUrl && typeof parts.file === 'string' && /^https?:\/\//i.test(parts.file.trim())) {
@@ -184,6 +190,7 @@ export const handleUpload = async (req, res, self) => {
if (inputUrl) {
file = null;
+ rawFiles = [];
try {
const parsed = new URL(inputUrl);
if (parsed.searchParams.has('igsh')) {
@@ -200,7 +207,14 @@ export const handleUpload = async (req, res, self) => {
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
- const is_shitpost = (parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
+ const isAlbumRequested = (parts.is_album === true || parts.is_album === 'true' || parts.is_album === '1') || (rawFiles.length > 1 && !parts.is_shitpost);
+ const is_album = isAlbumRequested && rawFiles.length > 1;
+ const is_shitpost = !is_album && ((parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true);
+
+ const maxAlbumItems = cfg.websrv?.max_album_items || cfg.websrv?.max_album_images || 100;
+ if (is_album && rawFiles.length > maxAlbumItems) {
+ return sendJson(res, { success: false, msg: `Album exceeds maximum limit of ${maxAlbumItems} items` }, 400);
+ }
if (!file && !inputUrl) {
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
@@ -302,6 +316,15 @@ export const handleUpload = async (req, res, self) => {
}
}
+ if (is_album) {
+ for (let i = 0; i < rawFiles.length; i++) {
+ const f = rawFiles[i];
+ if (!f || !f.data || f.data.length === 0) {
+ return sendJson(res, { success: false, msg: `Album item #${i + 1} is empty` }, 400);
+ }
+ }
+ }
+
let manualApproval = getManualApproval();
const trustedThreshold = getTrustedUploads();
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
@@ -776,12 +799,130 @@ export const handleUpload = async (req, res, self) => {
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt,
- uploader_ip: auditIp
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip')}
+ uploader_ip: auditIp,
+ is_album: !!is_album,
+ album_count: is_album ? rawFiles.length : 0
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip', 'is_album', 'album_count')}
`;
const itemid = await queue.getItemID(filename);
+ if (is_album) {
+ // 1. Insert primary/cover image (file 0) as order_index 0
+ const coverSubSlug = lib.generateSlug(11);
+ await db`
+ INSERT INTO album_items ${db({
+ item_id: itemid,
+ dest: filename,
+ mime: actualMime,
+ size: size,
+ checksum: insertChecksum,
+ phash: phash,
+ width: itemWidth,
+ height: itemHeight,
+ order_index: 0,
+ slug: coverSubSlug
+ }, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
+ `;
+
+ // 2. Process and insert remaining album images (1 .. rawFiles.length - 1)
+ for (let i = 1; i < rawFiles.length; i++) {
+ const subFile = rawFiles[i];
+ if (!subFile || !subFile.data) continue;
+ const subUuid = await queue.genuuid();
+ const subTmpPath = path.join(cfg.paths.tmp, `${subUuid}.tmp`);
+ await fs.writeFile(subTmpPath, subFile.data);
+
+ let subMime = (await queue.spawn('file', ['--mime-type', '-b', subTmpPath])).stdout.trim();
+ const allowedMimesList = Object.keys(cfg.mimes || {});
+ if (!allowedMimesList.includes(subMime) && subMime !== 'application/x-shockwave-flash' && subMime !== 'application/vnd.adobe.flash.movie') {
+ const extFromMime = cfg.mimes?.[subFile.contentType] ? subFile.contentType : null;
+ if (extFromMime) subMime = extFromMime;
+ else {
+ await fs.unlink(subTmpPath).catch(() => {});
+ continue;
+ }
+ }
+
+ const [subChecksum, subPhash, subDims] = await Promise.all([
+ queue.spawn('sha256sum', [subTmpPath]).then(r => r.stdout.trim().split(' ')[0]),
+ queue.generatePHash(subTmpPath).catch(() => null),
+ (async () => {
+ try {
+ if (subMime.startsWith('image/')) {
+ const { stdout: magickOut } = await queue.spawn('magick', [
+ 'identify', '-format', '%wx%h\n', subTmpPath + '[0]'
+ ], { quiet: true, ignoreExitCode: true });
+ const line = magickOut.trim().split('\n')[0];
+ const match = line.match(/^(\d+)x(\d+)$/);
+ if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
+ } else if (subMime.startsWith('video/')) {
+ const { stdout: probeOut } = await queue.spawn('ffprobe', [
+ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', subTmpPath
+ ], { quiet: true, ignoreExitCode: true });
+ const line = probeOut.trim().split('\n')[0];
+ const match = line.match(/^(\d+)x(\d+)$/);
+ if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
+ }
+ } catch (e) {}
+ return null;
+ })()
+ ]);
+
+ const subExt = cfg.mimes[subMime] || 'webp';
+ const subFilename = `${subUuid}.${subExt}`;
+ const subDestPath = manualApproval ? path.join(cfg.paths.pending, 'b', subFilename) : path.join(cfg.paths.b, subFilename);
+
+ await fs.copyFile(subTmpPath, subDestPath);
+ await fs.unlink(subTmpPath).catch(() => {});
+
+ const subWidth = subDims?.width ?? null;
+ const subHeight = subDims?.height ?? null;
+ const subSize = subFile.data.length;
+ const subItemSlug = lib.generateSlug(11);
+
+ await db`
+ INSERT INTO album_items ${db({
+ item_id: itemid,
+ dest: subFilename,
+ mime: subMime,
+ size: subSize,
+ checksum: subChecksum,
+ phash: subPhash,
+ width: subWidth,
+ height: subHeight,
+ order_index: i,
+ slug: subItemSlug
+ }, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
+ `;
+
+ // Generate thumbnail for album item filmstrip
+ try {
+ const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
+ const thumbDest = path.join(tDir, `${subUuid}.webp`);
+ if (subMime.startsWith('image/')) {
+ await queue.spawn('magick', [subDestPath + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
+ } else if (subMime.startsWith('video/')) {
+ await queue.spawn('ffmpegthumbnailer', ['-i', subDestPath, '-s', '256', '-o', thumbDest]).catch(async () => {
+ await queue.spawn('ffmpeg', ['-y', '-ss', '00:00:01', '-i', subDestPath, '-vframes', '1', '-vf', 'scale=256:256:force_original_aspect_ratio=increase,crop=256:256', thumbDest]);
+ });
+ } else if (subMime.startsWith('audio/')) {
+ try {
+ const cDir = manualApproval ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
+ const caDest = path.join(cDir, `${subUuid}.webp`);
+ await queue.spawn('ffmpeg', ['-y', '-i', subDestPath, '-an', '-vcodec', 'webp', '-frames:v', '1', caDest], { quiet: true });
+ const caStat = await fs.stat(caDest).catch(() => null);
+ if (caStat && caStat.size > 0) {
+ await queue.spawn('magick', [caDest + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
+ }
+ } catch (_) {}
+ }
+ } catch (tErr) {
+ console.warn(`[UPLOAD] Failed to generate thumbnail for album item ${subFilename}:`, tErr.message);
+ }
+ }
+ }
+
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'upload',
@@ -1109,6 +1250,8 @@ export const handleUpload = async (req, res, self) => {
mime: actualMime,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
+ is_album: !!is_album,
+ album_count: is_album ? rawFiles.length : 0,
display_name: req.session.display_name || null,
username: req.session.user
});
diff --git a/views/index-partial.html b/views/index-partial.html
index 245da36..a5f3b80 100644
--- a/views/index-partial.html
+++ b/views/index-partial.html
@@ -19,6 +19,9 @@
@if(enable_xd_score && item.xd_tier > 0)
xD
@endif
+ @if(item.is_album && item.album_count > 1)
+ {{ item.album_count }}
+ @endif
diff --git a/views/scroller.html b/views/scroller.html
index e2194d7..2c64589 100644
--- a/views/scroller.html
+++ b/views/scroller.html
@@ -1065,7 +1065,7 @@
- @if(typeof session !== 'undefined' && session && (!session.is_anon || (enable_anonymous_access && anon_permissions.filter)))
+ @if(typeof session !== 'undefined' && session && !session.is_anon || typeof session !== 'undefined' && session && enable_anonymous_access && anon_permissions.filter)
@endif
@if(typeof session !== 'undefined' && session)
diff --git a/views/snippets/footer.html b/views/snippets/footer.html
index b2f2d98..d659cdb 100644
--- a/views/snippets/footer.html
+++ b/views/snippets/footer.html
@@ -675,6 +675,17 @@
url_tracker_complete: "{{ t('upload.url_tracker_complete') || 'Complete!' }}",
url_tracker_failed: "{{ t('upload.url_tracker_failed') || 'Upload failed' }}",
url_tracker_view: "{{ t('upload.url_tracker_view') || 'View →' }}",
+ // albums
+ upload_album: "{{ t('album.mode_album') || 'Upload Album (%s)' }}",
+ uploading_album: "{{ t('album.uploading_album') || 'Uploading album...' }}",
+ album_counter: "{{ t('album.counter') || '%s of %s' }}",
+ album_cover: "{{ t('album.cover') || 'Cover' }}",
+ album_remove: "{{ t('album.remove_picture') || 'Remove picture' }}",
+ album_move_left: "{{ t('album.move_left') || 'Move left' }}",
+ album_move_right: "{{ t('album.move_right') || 'Move right' }}",
+ album_add_more: "{{ t('album.add_more') || 'Add more pictures' }}",
+ album_select_pictures: "{{ t('album.select_pictures') || 'Select pictures for album' }}",
+ album_min_pictures: "{{ t('album.min_pictures') || 'Add at least 2 pictures for an album' }}",
// favorites
no_favs: "{{ t('profile.no_favs') || 'no favorites' }}",
favs_label: "{{ t('profile.favs_label') || 'Favorites' }}",
diff --git a/views/snippets/info-modal.html b/views/snippets/info-modal.html
index 3416287..ac33b34 100644
--- a/views/snippets/info-modal.html
+++ b/views/snippets/info-modal.html
@@ -4,8 +4,10 @@