From 399f2be4564ccba839cef45f8ef933bc7d385e07 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 13 Sep 2026 20:15:48 +0200 Subject: [PATCH] gfsdgsd --- migrations/add_albums.sql | 23 ++ public/s/css/f0ckm.css | 268 ++++++++++++++++ public/s/css/upload.css | 307 +++++++++++++++++++ public/s/js/f0ckm.js | 526 ++++++++++++++++++++++++++++++++ public/s/js/upload.js | 478 +++++++++++++++++++++++++++-- public/s/js/v0ck.js | 25 +- src/inc/lib_delete.mjs | 21 ++ src/inc/locales/de.json | 19 ++ src/inc/locales/en.json | 19 ++ src/inc/locales/nl.json | 19 ++ src/inc/locales/zange.json | 19 ++ src/inc/multipart.mjs | 23 +- src/inc/queue.mjs | 21 +- src/inc/routeinc/f0cklib.mjs | 144 ++++++++- src/inc/settings.mjs | 15 + src/index.mjs | 24 +- src/upload_handler.mjs | 151 ++++++++- views/index-partial.html | 3 + views/scroller.html | 2 +- views/snippets/footer.html | 11 + views/snippets/info-modal.html | 45 ++- views/snippets/item-media.html | 44 ++- views/snippets/items-grid.html | 3 + views/snippets/upload-form.html | 39 ++- 24 files changed, 2159 insertions(+), 90 deletions(-) create mode 100644 migrations/add_albums.sql diff --git a/migrations/add_albums.sql b/migrations/add_albums.sql new file mode 100644 index 0000000..7b2079d --- /dev/null +++ b/migrations/add_albums.sql @@ -0,0 +1,23 @@ +-- Migration: Add Album Support +-- Allows posts to contain multiple pictures displayed as an album gallery + +ALTER TABLE public.items ADD COLUMN IF NOT EXISTS is_album boolean DEFAULT false; +ALTER TABLE public.items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0; + +CREATE TABLE IF NOT EXISTS public.album_items ( + id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES public.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, + slug CHARACTER VARYING(60) DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_album_items_item_id ON public.album_items(item_id, order_index ASC); +CREATE INDEX IF NOT EXISTS idx_album_items_slug ON public.album_items(slug); diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 03ff48a..93a0a64 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -20717,4 +20717,272 @@ div#flash, .bulk-modal-card { padding: 14px; } +} + +/* ========================================================================== + ALBUM GALLERY & INDICATOR STYLES + ========================================================================== */ + +.album-indicator { + padding: 1px 5px; + border-radius: 4px; + font-size: 0.65em; + font-weight: 700; + letter-spacing: 0.03em; + line-height: 1.4; + pointer-events: none; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(6px); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.25); + z-index: 10; + display: inline-flex; + align-items: center; + gap: 3px; +} + +.album-indicator i { + font-size: 0.85em; + color: var(--accent); +} + +.album-gallery-container { + position: relative; + overflow: hidden; +} + +.album-active-image { + transition: opacity 0.18s ease; +} + +.album-img-fade { + animation: albumFadeIn 0.22s ease-out; +} + +@keyframes albumFadeIn { + from { + opacity: 0.4; + transform: scale(0.99); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.album-controls-overlay { + position: absolute; + inset: 0; + pointer-events: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 20; +} + +.album-btn { + background: none; + border: none; + color: var(--accent); + pointer-events: auto; + opacity: 0.5; + cursor: pointer; +} + +.album-btn i { + font-size: 1.1rem; + transition: transform 0.15s ease; +} + +.album-btn:hover { + opacity: 1; + color: var(--accent); +} + +.album-counter-pill { + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + pointer-events: auto; + background: rgba(16, 16, 20, 0.75); + border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: #fff; + padding: 5px 14px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 7px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); + letter-spacing: 0.03em; + user-select: none; +} + +.album-counter-pill i { + color: var(--accent); + font-size: 0.95rem; +} + +.album-thumbnails-strip { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%) translateY(10px); + max-width: calc(100% - 32px); + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(14, 14, 18, 0.85); + border: 1px solid rgba(255, 255, 255, 0.16); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-radius: 12px; + z-index: 22; + overflow-x: auto; + scrollbar-width: none; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.album-gallery-container:hover .album-thumbnails-strip, +.album-gallery-container:focus-within .album-thumbnails-strip, +.album-thumbnails-strip.is-visible { + opacity: 1; + pointer-events: auto; + transform: translateX(-50%) translateY(0); +} + +.album-thumbnails-strip::-webkit-scrollbar { + display: none; +} + +.album-video-wrapper, +.album-audio-wrapper { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: #000; +} + +.album-gallery-container.is-video-active .album-thumbnails-strip, +.album-gallery-container.is-audio-active .album-thumbnails-strip { + bottom: 58px; +} + +.album-gallery-container.is-video-active .image-modal-btn, +.album-gallery-container.is-audio-active .image-modal-btn { + display: none !important; +} + +@media (max-width: 768px) { + .album-gallery-container.is-video-active .album-thumbnails-strip, + .album-gallery-container.is-audio-active .album-thumbnails-strip { + bottom: 48px; + } +} + +.album-thumb-item { + position: relative; + width: 44px; + height: 44px; + border-radius: 6px; + overflow: hidden; + border: 2px solid transparent; + opacity: 0.6; + cursor: pointer; + flex-shrink: 0; + padding: 0; + margin: 0; + background: #000; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.album-thumb-item:hover { + opacity: 0.9; + transform: scale(1.06); +} + +.album-thumb-item.active { + border-color: var(--accent); + opacity: 1; + transform: scale(1.1); + box-shadow: 0 0 12px rgba(var(--accent-rgb, 31, 178, 176), 0.6); +} + +.album-thumb-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.album-thumb-mime-badge { + position: absolute; + bottom: 2px; + right: 2px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 0.62rem; + padding: 1px 3px; + border-radius: 3px; + pointer-events: none; + line-height: 1; +} + +.album-media-stage { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.album-stage-link { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +#f0ck-album-video { + max-width: 100%; + max-height: 100%; + border-radius: 4px; +} + +@media (max-width: 768px) { + .album-btn { + width: 38px; + height: 38px; + } + .album-btn i { + font-size: 0.95rem; + } + .album-controls-overlay { + padding: 0 8px; + } + .album-counter-pill { + top: 10px; + padding: 3px 10px; + font-size: 0.78rem; + } + .album-thumb-item { + width: 36px; + height: 36px; + } + .album-thumbnails-strip { + bottom: 8px; + padding: 4px 8px; + gap: 6px; + } } \ No newline at end of file diff --git a/public/s/css/upload.css b/public/s/css/upload.css index ae70b17..5b47ed0 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -369,6 +369,25 @@ display: none !important; } +.upload-form.shitpost-mode-active.album-mode-active .global-rating-section, +.upload-form.shitpost-mode-active.album-mode-active .global-visibility-section, +.upload-form.shitpost-mode-active.album-mode-active .global-expiry-section, +.upload-form.shitpost-mode-active.album-mode-active .global-comment-section, +.upload-form.shitpost-mode-active.album-mode-active .global-tag-section, +.upload-form.shitpost-mode-active.album-mode-active .global-oc-section, +.upload-form.shitpost-mode-active.album-mode-active .global-title-section { + display: block !important; +} + +.upload-form.album-mode-active .drop-zone { + border-color: rgba(var(--accent-rgb), 0.45); + background: rgba(var(--accent-rgb), 0.03); +} + +.upload-form.album-mode-active .drop-album-hint { + font-weight: 600; +} + /* Per-item Rating Switch */ .item-rating-container { display: flex; @@ -1705,3 +1724,291 @@ } @keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } +/* ========================================================================== + ALBUM UPLOAD STYLES + ========================================================================== */ + +.album-choice-container { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.1)); + border-radius: 8px; + padding: 10px 14px; + margin-top: 12px; + gap: 10px; + flex-wrap: wrap; +} + +.album-choice-title { + font-size: 0.9rem; + font-weight: 600; + color: #eee; + display: flex; + align-items: center; + gap: 7px; +} + +.album-choice-title i { + color: var(--accent); +} + +.album-choice-options { + display: flex; + gap: 8px; +} + +.album-choice-btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 0.85rem; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #ccc; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; +} + +.album-choice-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.3); +} + +.album-choice-btn.active { + background: var(--accent); + color: #111; + font-weight: 700; + border-color: var(--accent); + box-shadow: 0 0 10px rgba(var(--accent-rgb, 31, 178, 176), 0.4); +} + +.album-staging-container { + width: 100%; + margin-top: 14px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 14px; +} + +.album-staging-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.album-staging-title { + font-size: 0.92rem; + font-weight: 600; + color: #eee; + display: flex; + align-items: center; + gap: 7px; +} + +.album-staging-title i { + color: var(--accent); +} + +.btn-add-album-pics { + padding: 5px 12px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + color: #eee; + font-size: 0.8rem; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + transition: all 0.2s ease; +} + +.btn-add-album-pics:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(var(--accent-rgb, 31, 178, 176), 0.1); +} + +.album-staging-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(115px, 1fr)); + gap: 10px; +} + +.album-stage-card { + position: relative; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; + overflow: hidden; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.album-stage-card:hover { + border-color: rgba(255, 255, 255, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.album-stage-card.is-cover { + border-color: var(--accent); + box-shadow: 0 0 10px rgba(var(--accent-rgb, 31, 178, 176), 0.35); +} + +.album-stage-card img, +.album-stage-card video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.album-stage-audio-preview { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + text-align: center; + color: var(--accent); +} + +.album-stage-audio-preview i { + font-size: 1.8rem; +} + +.album-stage-audio-name { + font-size: 0.65rem; + color: #ccc; + max-width: 95px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.album-stage-mime-badge { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 0.65rem; + padding: 2px 5px; + border-radius: 4px; + z-index: 5; + pointer-events: none; +} + +.album-stage-badge { + position: absolute; + top: 4px; + left: 4px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + z-index: 5; + border: 1px solid rgba(255, 255, 255, 0.2); + pointer-events: none; +} + +.album-stage-card.is-cover .album-stage-badge { + background: var(--accent); + color: #111; + border-color: var(--accent); + font-weight: 800; +} + +.album-stage-actions { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85) 60%); + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 10px 4px 4px 4px; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 6; +} + +.album-stage-card:hover .album-stage-actions { + opacity: 1; +} + +.album-action-btn { + width: 25px; + height: 25px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.16); + border: none; + color: #fff; + font-size: 0.72rem; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + padding: 0; +} + +.album-action-btn:hover { + background: var(--accent); + color: #111; + transform: scale(1.1); +} + +.album-action-btn.btn-album-remove:hover { + background: #e74c3c; + color: #fff; +} + +.album-stage-card.album-stage-add-more { + border-style: dashed; + border-color: rgba(255, 255, 255, 0.2); + cursor: pointer; + flex-direction: column; + gap: 4px; + color: rgba(255, 255, 255, 0.6); +} + +.album-stage-card.album-stage-add-more:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(var(--accent-rgb, 31, 178, 176), 0.08); +} + +.album-add-icon { + font-size: 1.4rem; +} + +.album-add-text { + font-size: 0.75rem; + font-weight: 600; +} + +.album-add-sub { + font-size: 0.65rem; + opacity: 0.7; +} + diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 5cc79a5..3fa0075 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -2725,14 +2725,526 @@ window.cancelAnimFrame = (function () { }, delay); }; + const initAlbumGallery = () => { + const container = document.querySelector('.album-gallery-container'); + if (!container) { + window._currentActiveAlbumGallery = null; + return; + } + if (container._f0ckAlbumInit) return; + container._f0ckAlbumInit = true; + + let albumData = null; + const jsonScript = container.querySelector('.album-data-json, #album-data-json'); + if (jsonScript && jsonScript.textContent) { + try { + albumData = JSON.parse(jsonScript.textContent); + } catch (e) { + console.warn('[ALBUM] Could not parse album script tag:', e); + } + } + + if (!albumData) { + try { + let raw = container.getAttribute('data-album'); + if (raw) { + if (raw.includes('"') || raw.includes('{') || raw.includes('&')) { + const txt = document.createElement('textarea'); + txt.innerHTML = raw; + raw = txt.value; + if (raw.includes('"') || raw.includes('{')) { + txt.innerHTML = raw; + raw = txt.value; + } + } + albumData = JSON.parse(raw); + } + } catch (e) { + // Fallback to DOM elements below + } + } + + // Fallback: build albumData directly from rendered thumbnail buttons + if (!Array.isArray(albumData) || albumData.length === 0) { + const thumbs = container.querySelectorAll('.album-thumb-item'); + if (thumbs.length > 1) { + albumData = Array.from(thumbs).map((thumb, i) => { + const idx = parseInt(thumb.getAttribute('data-index'), 10); + const src = thumb.getAttribute('data-src') || thumb.querySelector('img')?.src || ''; + const subSlug = thumb.getAttribute('data-subf0ck-slug') || thumb.getAttribute('data-subf0ck-id') || ''; + return { + order_index: isNaN(idx) ? i : idx, + display_index: (isNaN(idx) ? i : idx) + 1, + src: src, + dest: src, + slug: subSlug, + subf0ck_id: subSlug, + id: subSlug + }; + }); + } + } + + if (!Array.isArray(albumData) || albumData.length <= 1) return; + + let currentIndex = 0; + + const getHashSubf0ckId = () => { + return (window.location.hash || '').replace(/^#/, '').trim(); + }; + + const initialHash = getHashSubf0ckId() || container.getAttribute('data-requested-subf0ck') || ''; + if (initialHash) { + const foundIdx = albumData.findIndex(item => + String(item.slug || '') === initialHash || + String(item.subf0ck_id || '') === initialHash || + String(item.id) === initialHash || + String(item.order_index + 1) === initialHash + ); + if (foundIdx !== -1) { + currentIndex = foundIdx; + } + } + + const imgEl = container.querySelector('#f0ck-image'); + const videoEl = container.querySelector('#f0ck-album-video'); + const videoWrapper = container.querySelector('#f0ck-album-video-wrapper'); + const audioWrapper = container.querySelector('#f0ck-album-audio-wrapper') || container.querySelector('#f0ck-album-audio-container'); + const audioEl = container.querySelector('#f0ck-album-audio'); + const linkEl = container.querySelector('#elfe, .album-stage-link'); + const prevBtn = container.querySelector('.album-btn-prev'); + const nextBtn = container.querySelector('.album-btn-next'); + const currentIdxEl = container.querySelector('.album-current-idx'); + const totalCountEl = container.querySelector('.album-total-count'); + const thumbItems = container.querySelectorAll('.album-thumb-item'); + const stripEl = container.querySelector('.album-thumbnails-strip'); + + if (totalCountEl) totalCountEl.textContent = albumData.length; + + let albumVideoV0ck = null; + const initAlbumVideoV0ck = () => { + if (albumVideoV0ck) return albumVideoV0ck; + if (videoEl && typeof v0ck === 'function') { + try { + albumVideoV0ck = new v0ck(videoEl); + } catch (e) { + console.warn('[ALBUM] Could not init v0ck for album video:', e); + } + } + return albumVideoV0ck; + }; + + let albumAudioV0ck = null; + const initAlbumAudioV0ck = () => { + if (albumAudioV0ck) return albumAudioV0ck; + if (audioEl && typeof v0ck === 'function') { + try { + albumAudioV0ck = new v0ck(audioEl); + } catch (e) { + console.warn('[ALBUM] Could not init v0ck for album audio:', e); + } + } + return albumAudioV0ck; + }; + + const preloadedImages = new Set(); + const preload = (idx) => { + if (idx < 0 || idx >= albumData.length) return; + const sub = albumData[idx]; + if (!sub) return; + const src = sub.src || sub.dest; + const mime = (sub.mime || '').toLowerCase(); + if (src && !mime.startsWith('video/') && !mime.startsWith('audio/') && !preloadedImages.has(src)) { + const i = new Image(); + i.src = src; + preloadedImages.add(src); + } + }; + + preload(1); + if (albumData.length > 2) preload(albumData.length - 1); + + const isBlurred = () => { + const mediaObj = container.closest('.media-object') || document.querySelector('.media-object'); + if (mediaObj && localStorage.getItem('blurDetail') !== 'false') { + const mode = mediaObj.getAttribute('data-mode'); + const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; + const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; + const blurSfw = localStorage.getItem('blurSfw') === 'true'; + const blurUntagged = localStorage.getItem('blurUntagged') === 'true'; + let shouldBlurThis = false; + if (mode === 'nsfw') shouldBlurThis = blurNsfw; + else if (mode === 'nsfl') shouldBlurThis = blurNsfl; + else if (mode === 'sfw') shouldBlurThis = blurSfw; + else if (mode === 'untagged') shouldBlurThis = blurUntagged; + if (shouldBlurThis && !mediaObj.classList.contains('revealed')) { + return true; + } + } + return false; + }; + + const isAutoplayAllowed = () => { + return !isBlurred() && window.f0ckSession?.disable_autoplay !== true; + }; + + const showImage = (index, direction = 'none', updateHash = true) => { + if (index < 0) index = albumData.length - 1; + if (index >= albumData.length) index = 0; + currentIndex = index; + + const item = albumData[currentIndex]; + if (!item) return; + + const newSrc = item.src || item.dest; + const mime = (item.mime || '').toLowerCase(); + + // Update URL hash with subf0ck slug (or id fallback) so links are shareable directly to this subf0ck + const subKey = item.slug || item.subf0ck_id || item.id; + if (updateHash && subKey) { + history.replaceState(null, '', window.location.pathname + window.location.search + '#' + subKey); + } + + // Pause any playing audio or video + if (videoEl && !videoEl.paused) { + try { videoEl.pause(); } catch {} + } + if (audioEl && !audioEl.paused) { + try { audioEl.pause(); } catch {} + } + + if (mime.startsWith('video/')) { + container.classList.add('is-video-active'); + container.classList.remove('is-audio-active'); + if (imgEl) imgEl.style.display = 'none'; + if (linkEl) linkEl.style.display = 'none'; + if (audioWrapper) audioWrapper.style.display = 'none'; + + if (videoWrapper) videoWrapper.style.display = 'block'; + if (videoEl) { + videoEl.style.display = 'block'; + + if (videoEl.src !== newSrc && !videoEl.src.endsWith(newSrc)) { + videoEl.src = newSrc; + videoEl.load(); + } + + initAlbumVideoV0ck(); + video = videoEl; + + if (item.size && videoWrapper) { + const dlBtn = videoWrapper.querySelector('#v0ck_download'); + if (dlBtn) dlBtn.textContent = `Download (${item.size})`; + } + + const playerWrap = videoWrapper.querySelector('.v0ck') || videoWrapper; + if (isAutoplayAllowed()) { + const playPromise = videoEl.play(); + if (playPromise !== undefined) { + playPromise.catch(() => { + playerWrap.classList.add('v0ck_initial'); + }); + } + } else { + try { videoEl.pause(); } catch {} + playerWrap.classList.add('v0ck_initial'); + } + } + } else if (mime.startsWith('audio/')) { + container.classList.remove('is-video-active'); + container.classList.add('is-audio-active'); + if (videoEl) { + try { videoEl.pause(); } catch {} + } + if (videoWrapper) videoWrapper.style.display = 'none'; + if (imgEl) imgEl.style.display = 'none'; + if (linkEl) linkEl.style.display = 'none'; + + if (audioWrapper) { + audioWrapper.style.display = 'block'; + const coverUrl = item.coverart || item.thumb || '/s/img/audio.webp'; + audioWrapper.style.backgroundImage = `url('${coverUrl}')`; + audioWrapper.style.backgroundRepeat = 'no-repeat'; + audioWrapper.style.backgroundPosition = 'center'; + audioWrapper.style.backgroundSize = 'contain'; + audioWrapper.style.backgroundColor = 'black'; + } + + if (audioEl) { + audioEl.style.display = 'block'; + + if (audioEl.src !== newSrc && !audioEl.src.endsWith(newSrc)) { + audioEl.src = newSrc; + audioEl.load(); + } + + initAlbumAudioV0ck(); + video = audioEl; + + if (item.size && audioWrapper) { + const dlBtn = audioWrapper.querySelector('#v0ck_download'); + if (dlBtn) dlBtn.textContent = `Download (${item.size})`; + } + + const playerWrap = audioWrapper.querySelector('.v0ck') || audioWrapper; + if (isAutoplayAllowed()) { + const playPromise = audioEl.play(); + if (playPromise !== undefined) { + playPromise.catch(() => { + playerWrap.classList.add('v0ck_initial'); + }); + } + } else { + try { audioEl.pause(); } catch {} + playerWrap.classList.add('v0ck_initial'); + } + } + } else { + // Image + container.classList.remove('is-video-active', 'is-audio-active'); + if (videoEl) { + try { videoEl.pause(); } catch {} + } + if (audioEl) { + try { audioEl.pause(); } catch {} + } + if (videoWrapper) videoWrapper.style.display = 'none'; + if (audioWrapper) audioWrapper.style.display = 'none'; + if (linkEl) { + linkEl.style.display = ''; + linkEl.href = newSrc; + } + if (imgEl) { + imgEl.style.display = 'block'; + imgEl.classList.remove('album-img-fade'); + void imgEl.offsetWidth; // trigger reflow + imgEl.src = newSrc; + imgEl.classList.add('album-img-fade'); + } + video = null; + } + + if (linkEl && !mime.startsWith('video/')) { + linkEl.href = newSrc; + } + + if (currentIdxEl) { + currentIdxEl.textContent = currentIndex + 1; + } + + thumbItems.forEach((btn, idx) => { + const isActive = idx === currentIndex; + btn.classList.toggle('active', isActive); + if (isActive) { + btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + }); + + updateInfoModal(); + + preload(currentIndex + 1); + preload(currentIndex - 1); + }; + + const updateInfoModal = () => { + const modal = document.getElementById('info-modal'); + if (!modal) return; + const sub = albumData[currentIndex]; + if (!sub) return; + + const subHeading = modal.querySelector('#info-modal-subheading'); + if (subHeading) { + const parentId = subHeading.getAttribute('data-item-id') || container.getAttribute('data-album-id') || ''; + const parentSlug = subHeading.getAttribute('data-item-slug') || ''; + const subSlug = sub.slug || sub.subf0ck_id || sub.id; + const total = albumData.length; + const idx = currentIndex + 1; + subHeading.innerHTML = `Post ID: ${parentId}${parentSlug ? ` (${parentSlug})` : ''} • Subf0ck: ${subSlug} (${idx}/${total})`; + } + + const specsTitle = modal.querySelector('#info-specs-header-title'); + if (specsTitle) { + specsTitle.textContent = `Technical Specifications (Subf0ck ${currentIndex + 1}/${albumData.length})`; + } + + const fileSizeEl = modal.querySelector('#info-file-size'); + if (fileSizeEl) { + fileSizeEl.textContent = sub.size || ''; + } + + const dimsCard = modal.querySelector('#info-card-dimensions'); + const dimsEl = modal.querySelector('#info-file-dimensions'); + if (dimsCard && dimsEl) { + if (sub.width && sub.height) { + dimsEl.textContent = `${sub.width} × ${sub.height} px`; + dimsCard.style.display = ''; + } else { + dimsCard.style.display = 'none'; + } + } + + const mimeEl = modal.querySelector('#info-file-mime'); + if (mimeEl) { + mimeEl.textContent = sub.mime || ''; + } + + const directLink = modal.querySelector('#info-file-direct-link'); + if (directLink) { + directLink.href = sub.dest || sub.src || '#'; + } + + const sourceCard = modal.querySelector('#info-card-source'); + if (sourceCard) { + sourceCard.style.display = 'none'; + } + + const hashCard = modal.querySelector('#info-card-hash'); + const hashEl = modal.querySelector('#info-file-hash'); + const copyHashBtn = modal.querySelector('#info-copy-hash-btn'); + if (hashCard) { + const cleanHash = sub.checksum ? String(sub.checksum).split('_bypass_')[0] : ''; + if (cleanHash) { + if (hashEl) hashEl.textContent = cleanHash; + if (copyHashBtn) copyHashBtn.setAttribute('data-hash', cleanHash); + hashCard.style.display = ''; + } else { + hashCard.style.display = 'none'; + } + } + }; + + if (imgEl) { + imgEl.addEventListener('load', () => { + const sub = albumData[currentIndex]; + if (sub && (!sub.width || !sub.height) && imgEl.naturalWidth) { + sub.width = imgEl.naturalWidth; + sub.height = imgEl.naturalHeight; + updateInfoModal(); + } + }); + } + if (videoEl) { + videoEl.addEventListener('loadedmetadata', () => { + const sub = albumData[currentIndex]; + if (sub && (!sub.width || !sub.height) && videoEl.videoWidth) { + sub.width = videoEl.videoWidth; + sub.height = videoEl.videoHeight; + updateInfoModal(); + } + }); + } + + // Ensure proper initial media display (especially if initial item is video or subf0ck hash was requested) + showImage(currentIndex, 'none', !!initialHash); + + if (prevBtn) { + prevBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + showImage(currentIndex - 1, 'prev'); + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + showImage(currentIndex + 1, 'next'); + }); + } + + thumbItems.forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const targetIdx = parseInt(btn.getAttribute('data-index'), 10); + if (!isNaN(targetIdx)) { + showImage(targetIdx); + } + }); + }); + + // Touch tap on container reveals thumbnail strip briefly on touch devices + let stripTimeout = null; + container.addEventListener('click', (e) => { + if (stripEl && !e.target.closest('.album-thumbnails-strip, .v0ck_player_controls, .v0ck_settings_menu, .v0ck_hud, .album-btn')) { + stripEl.classList.add('is-visible'); + clearTimeout(stripTimeout); + stripTimeout = setTimeout(() => { + stripEl.classList.remove('is-visible'); + }, 3500); + } + }); + + // Hashchange listener for forward/backward browser navigation + const hashChangeHandler = () => { + const newHash = getHashSubf0ckId(); + if (!newHash) return; + const targetIdx = albumData.findIndex(item => + String(item.slug || '') === newHash || + String(item.subf0ck_id || '') === newHash || + String(item.id) === newHash || + String(item.order_index + 1) === newHash + ); + if (targetIdx !== -1 && targetIdx !== currentIndex) { + showImage(targetIdx, 'none', false); + } + }; + window.addEventListener('hashchange', hashChangeHandler); + + // Touch swipe support on album container + let touchStartX = 0; + let touchStartY = 0; + container.addEventListener('touchstart', (e) => { + if (e.target.closest('.v0ck_player_controls, .v0ck_settings_menu, input[type="range"]')) return; + if (e.touches && e.touches.length === 1) { + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + } + }, { passive: true }); + + container.addEventListener('touchend', (e) => { + if (e.target.closest('.v0ck_player_controls, .v0ck_settings_menu, input[type="range"]')) return; + if (e.changedTouches && e.changedTouches.length === 1) { + const diffX = e.changedTouches[0].clientX - touchStartX; + const diffY = e.changedTouches[0].clientY - touchStartY; + if (Math.abs(diffX) > 40 && Math.abs(diffX) > Math.abs(diffY) * 1.5) { + if (diffX > 0) { + showImage(currentIndex - 1, 'prev'); + } else { + showImage(currentIndex + 1, 'next'); + } + } + } + }, { passive: true }); + + window._currentActiveAlbumGallery = { + prev: () => showImage(currentIndex - 1, 'prev'), + next: () => showImage(currentIndex + 1, 'next'), + updateInfoModal: updateInfoModal, + getCurrentSubf0ck: () => albumData[currentIndex], + isHovered: false + }; + + container.addEventListener('mouseenter', () => { + if (window._currentActiveAlbumGallery) window._currentActiveAlbumGallery.isHovered = true; + }); + container.addEventListener('mouseleave', () => { + if (window._currentActiveAlbumGallery) window._currentActiveAlbumGallery.isHovered = false; + }); + }; + const setupMedia = () => { + window._currentActiveAlbumGallery = null; const elem = document.querySelector("#my-video") || document.querySelector("audio#my-video"); if (elem) { video = new v0ck(elem); } else { video = null; } + initAlbumGallery(); }; + document.addEventListener('f0ck:contentLoaded', initAlbumGallery); const initOnaraInitialState = () => { if (!isOnaraActive()) return; @@ -6219,6 +6731,15 @@ window.cancelAnimFrame = (function () { // 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 @@
-

{{ t('info_modal.title') || 'Post & File Details' }}

- ID: {{ item.id }} +

{{ t('info_modal.title') || 'Post & File Details' }}

+ + ID: {{ item.id }}@if(item.slug) • Slug: {{ item.slug }}@endif +
-
{{ item.checksum.split('_bypass_')[0] }}
- +
@if(item.checksum){{ item.checksum.split('_bypass_')[0] }}@endif
- @endif @if(item.show_repost_row) -
+
Similar & Duplicate Uploads diff --git a/views/snippets/item-media.html b/views/snippets/item-media.html index 6d818c1..9a08680 100644 --- a/views/snippets/item-media.html +++ b/views/snippets/item-media.html @@ -1,4 +1,46 @@ -@if(item.mime === 'video/youtube') +@if(item.is_album && item.album && item.album.length > 1) + +@elseif(item.mime === 'video/youtube')
diff --git a/views/snippets/items-grid.html b/views/snippets/items-grid.html index c20711a..900a546 100644 --- a/views/snippets/items-grid.html +++ b/views/snippets/items-grid.html @@ -15,6 +15,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/snippets/upload-form.html b/views/snippets/upload-form.html index f281b2b..39cb605 100644 --- a/views/snippets/upload-form.html +++ b/views/snippets/upload-form.html @@ -1,34 +1,57 @@
- @if(web_url_upload)
+ + @if(web_url_upload) + @endif
- @endif
- +
-

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

+

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

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

+

+ {{ t('album.drop_hint') || 'Select or drop multiple files (subf0cks) to create an Album' }} +

+ + + @if(web_url_upload) - @if(!shitpost_mode && enable_item_title) -
+ @if(enable_item_title) +