This commit is contained in:
2026-09-13 20:15:48 +02:00
parent ffd32a6b63
commit 399f2be456
24 changed files with 2159 additions and 90 deletions
+23
View File
@@ -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);
+268
View File
@@ -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;
}
}
+307
View File
@@ -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;
}
+526
View File
@@ -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: <code>${parentId}</code>${parentSlug ? ` <span class="text-muted">(${parentSlug})</span>` : ''} &bull; Subf0ck: <code>${subSlug}</code> <span class="text-muted">(${idx}/${total})</span>`;
}
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 () {
// <keybindings>
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';
+448 -30
View File
@@ -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 = `
<div class="album-staging-title">
<i class="fa-solid fa-layer-group"></i>
<span>${(window.f0ckI18n && window.f0ckI18n.album_title) || 'Album'} (${selectedFiles.length} subf0cks)</span>
</div>
<button type="button" class="btn-add-album-pics">
<i class="fa-solid fa-plus"></i> ${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}
</button>
`;
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 ? '<i class="fa-solid fa-star"></i> 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 = '<i class="fa-solid fa-play"></i>';
card.appendChild(mimeBadge);
} else if (isAudio) {
const audioPreview = document.createElement('div');
audioPreview.className = 'album-stage-audio-preview';
audioPreview.innerHTML = `
<i class="fa-solid fa-music"></i>
<span class="album-stage-audio-name" title="${file.name || 'Audio'}">${file.name || 'Audio'}</span>
`;
card.appendChild(audioPreview);
const mimeBadge = document.createElement('span');
mimeBadge.className = 'album-stage-mime-badge';
mimeBadge.innerHTML = '<i class="fa-solid fa-music"></i>';
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 = '<i class="fa-solid fa-arrow-left"></i>';
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 = '<i class="fa-solid fa-arrow-right"></i>';
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 = '<i class="fa-solid fa-xmark"></i>';
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 = `
<div class="album-add-icon"><i class="fa-solid fa-plus"></i></div>
<div class="album-add-text">${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}</div>
<div class="album-add-sub">(${selectedFiles.length}/${maxAlbumItems})</div>
`;
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) + ` (<a href="/${err.repost}" class="repost-link">view existing</a>)`;
} 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');
+18 -7
View File
@@ -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)
+21
View File
@@ -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);
}
}
+19
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+20 -3
View File
@@ -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];
}
}
}
}
+16 -5
View File
@@ -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) {
+138 -6
View File
@@ -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: {
+15
View File
@@ -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 = () => {
+23 -1
View File
@@ -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,
+147 -4
View File
@@ -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
});
+3
View File
@@ -19,6 +19,9 @@
@if(enable_xd_score && item.xd_tier > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}" title="xD Score: {{ item.xd_score }}">xD</span>
@endif
@if(item.is_album && item.album_count > 1)
<span class="album-indicator anim" title="Album: {{ item.album_count }} images"><i class="fa-solid fa-images"></i> {{ item.album_count }}</span>
@endif
</div>
<div class="thumb-select-check"><i class="fa-solid fa-check"></i></div>
<p></p>
+1 -1
View File
@@ -1065,7 +1065,7 @@
<button id="chan-open-btn" class="topbar-icon-btn" title="{{ t('scroller.chan_threads') }}" style="display:none"><i class="fa-solid fa-clover"></i></button>
<button id="chan-gallery-btn" class="topbar-icon-btn" title="{{ t('scroller.thread_gallery') }} (G)" style="display:none"><i class="fa-solid fa-grip"></i></button>
<button id="settings-open-btn" class="topbar-icon-btn" title="{{ t('scroller.settings') }}"><i class="fa-solid fa-gear"></i></button>
@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)
<button id="filter-open-btn" class="topbar-icon-btn" title="{{ t('scroller.filters') }} (F)"><i class="fa-solid fa-sliders"></i></button>
@endif
@if(typeof session !== 'undefined' && session)
+11
View File
@@ -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' }}",
+21 -24
View File
@@ -4,8 +4,10 @@
<div class="infobox-modal-header">
<div class="infobox-header-title">
<div class="infobox-header-text">
<h3>{{ t('info_modal.title') || 'Post & File Details' }}</h3>
<span class="infobox-sub-heading">ID: <code>{{ item.id }}</code></span>
<h3 id="info-modal-title">{{ t('info_modal.title') || 'Post & File Details' }}</h3>
<span class="infobox-sub-heading" id="info-modal-subheading" data-item-id="{{ item.id }}" data-item-slug="{{ item.slug || '' }}">
ID: <code>{{ item.id }}</code>@if(item.slug) &bull; Slug: <code>{{ item.slug }}</code>@endif
</span>
</div>
</div>
<button type="button" class="infobox-close-icon-btn" id="info-modal-close-icon" title="{{ t('common.close') || 'Close' }}">
@@ -17,7 +19,7 @@
<div class="infobox-modal-body">
<!-- Section 1: Post Details -->
<div class="infobox-section">
<div class="infobox-section" id="info-section-post">
<div class="infobox-section-header">
<i class="fa-solid fa-sliders"></i>
<span>Post Configuration</span>
@@ -80,68 +82,63 @@
</div>
<!-- Section 2: Technical File Specifications -->
<div class="infobox-section">
<div class="infobox-section" id="info-section-specs">
<div class="infobox-section-header">
<i class="fa-solid fa-file-code"></i>
<span>Technical Specifications</span>
<span id="info-specs-header-title">Technical Specifications</span>
</div>
<div class="infobox-cards-grid">
<div class="infobox-card">
<div class="infobox-card" id="info-card-filesize">
<label class="infobox-card-label"><i class="fa-solid fa-hard-drive"></i> File Size</label>
<div class="infobox-card-value font-mono">{{ item.size }}</div>
<div class="infobox-card-value font-mono" id="info-file-size">{{ item.size }}</div>
</div>
@if(item_has_dimensions)
<div class="infobox-card">
<div class="infobox-card" id="info-card-dimensions" @if(!item_has_dimensions)style="display: none;"@endif>
<label class="infobox-card-label"><i class="fa-solid fa-ruler-combined"></i> Dimensions</label>
<div class="infobox-card-value font-mono">{{ item.width }} × {{ item.height }} px</div>
<div class="infobox-card-value font-mono" id="info-file-dimensions">@if(item_has_dimensions){{ item.width }} × {{ item.height }} px@endif</div>
</div>
@endif
<div class="infobox-card">
<div class="infobox-card" id="info-card-mime">
<label class="infobox-card-label"><i class="fa-solid fa-file-lines"></i> MIME Type</label>
<div class="infobox-card-value"><code class="mime-pill">{{ item.mime }}</code></div>
<div class="infobox-card-value"><code class="mime-pill" id="info-file-mime">{{ item.mime }}</code></div>
</div>
<div class="infobox-card">
<div class="infobox-card" id="info-card-directlink">
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
<div class="infobox-card-value">
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill">
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill" id="info-file-direct-link">
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
</a>
</div>
</div>
@if(item.src && item.src.short)
<div class="infobox-card full-width">
<div class="infobox-card full-width" id="info-card-source">
<label class="infobox-card-label"><i class="fa-solid fa-link"></i> Source URL</label>
<div class="infobox-card-value">
<a href="{{ item.src.long }}" target="_blank" class="infobox-source-link">
<a href="{{ item.src.long }}" target="_blank" class="infobox-source-link" id="info-file-source">
<i class="fa-solid fa-globe"></i> {{ item.src.short }}
</a>
</div>
</div>
@endif
@if(item.checksum)
<div class="infobox-card full-width">
<div class="infobox-card full-width" id="info-card-hash" @if(!item.checksum)style="display: none;"@endif>
<div class="infobox-card-label-row">
<label class="infobox-card-label"><i class="fa-solid fa-fingerprint"></i> SHA-256 Hash</label>
<button type="button" class="copy-hash-btn" id="info-copy-hash-btn" data-hash="{{ item.checksum.split('_bypass_')[0] }}" title="Copy SHA-256 Hash">
<button type="button" class="copy-hash-btn" id="info-copy-hash-btn" data-hash="@if(item.checksum){{ item.checksum.split('_bypass_')[0] }}@endif" title="Copy SHA-256 Hash">
<i class="fa-solid fa-copy"></i> Copy
</button>
</div>
<div class="info-hash-codeblock font-mono"><code>{{ item.checksum.split('_bypass_')[0] }}</code></div>
<div class="info-hash-codeblock font-mono"><code id="info-file-hash">@if(item.checksum){{ item.checksum.split('_bypass_')[0] }}@endif</code></div>
</div>
@endif
</div>
</div>
<!-- Section 3: Reposts & Duplicates (if present) -->
@if(item.show_repost_row)
<div class="infobox-section">
<div class="infobox-section" id="info-section-reposts">
<div class="infobox-section-header">
<i class="fa-solid fa-copy"></i>
<span>Similar & Duplicate Uploads</span>
+43 -1
View File
@@ -1,4 +1,46 @@
@if(item.mime === 'video/youtube')
@if(item.is_album && item.album && item.album.length > 1)
<div class="embed-responsive embed-responsive-16by9 album-gallery-container" data-album-id="{{ item.id }}" data-requested-subf0ck="{{ item.requested_subf0ck_slug || '' }}">
<script type="application/json" class="album-data-json">{{ item.album_json }}</script>
<div class="embed-responsive-image album-media-stage" id="image-scroll">
<a href="{{ item.dest }}" id="elfe" target="_blank" class="album-stage-link">
<img id="f0ck-image" class="img-fluid album-active-image" src="{{ item.dest }}" loading="lazy" decoding="async" />
</a>
<div id="f0ck-album-video-wrapper" class="album-video-wrapper embed-responsive embed-responsive-16by9" style="display: none; position: absolute; inset: 0; width: 100%; height: 100%;">
<video id="f0ck-album-video" class="embed-responsive-item" preload="auto" loop playsinline></video>
</div>
<div id="f0ck-album-audio-wrapper" class="album-audio-wrapper embed-responsive embed-responsive-16by9" style="display: none; position: absolute; inset: 0; width: 100%; height: 100%; background: url('/s/img/audio.webp') no-repeat center / contain black;">
<audio id="f0ck-album-audio" class="embed-responsive-item" preload="auto" loop poster="/s/img/audio.webp"></audio>
</div>
</div>
<!-- Album Gallery Controls Inside View -->
<div class="album-controls-overlay">
<button type="button" class="album-btn album-btn-prev" title="{{ t('album.prev') || 'Previous' }}" aria-label="Previous">
<i class="fa-solid fa-chevron-left"></i>
</button>
<div class="album-counter-pill" title="Subf0cks">
<i class="fa-solid fa-layer-group"></i>
<span class="album-current-idx">1</span> / <span class="album-total-count">{{ item.album.length }}</span>
</div>
<button type="button" class="album-btn album-btn-next" title="{{ t('album.next') || 'Next' }}" aria-label="Next">
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
<!-- Album Thumbnail Strip (reveals on hover) -->
<div class="album-thumbnails-strip">
@each(item.album as img)
<button type="button" class="album-thumb-item {{ img.is_first ? 'active' : '' }}" data-index="{{ img.order_index }}" data-subf0ck-id="{{ img.slug || img.id }}" data-subf0ck-slug="{{ img.slug || img.id }}" data-src="{{ img.src }}" data-mime="{{ img.mime }}" title="Subf0ck {{ img.slug || img.id }} ({{ img.display_index }}/{{ item.album.length }})">
<img src="{{ img.thumb || img.src }}" loading="lazy" alt="Subf0ck {{ img.slug || img.id }}" />
@if(img.is_video)
<span class="album-thumb-mime-badge"><i class="fa-solid fa-play"></i></span>
@elseif(img.is_audio)
<span class="album-thumb-mime-badge"><i class="fa-solid fa-music"></i></span>
@endif
</button>
@endeach
</div>
<button class="image-modal-btn" title="Open in modal"><i class="fa-solid fa-plus"></i></button>
</div>
@elseif(item.mime === 'video/youtube')
<div class="embed-responsive embed-responsive-16by9">
<iframe id="yt-embed" class="embed-responsive-item" width="640" height="360" src="https://www.youtube.com/embed/{!! item.dest.replace('yt:', '') !!}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
+3
View File
@@ -15,6 +15,9 @@
@if(enable_xd_score && item.xd_tier > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}" title="xD Score: {{ item.xd_score }}">xD</span>
@endif
@if(item.is_album && item.album_count > 1)
<span class="album-indicator anim" title="Album: {{ item.album_count }} images"><i class="fa-solid fa-images"></i> {{ item.album_count }}</span>
@endif
</div>
<div class="thumb-select-check"><i class="fa-solid fa-check"></i></div>
<p></p>
+31 -8
View File
@@ -1,34 +1,57 @@
<form id="upload-form" class="upload-form {{ shitpost_mode ? 'shitpost-mode-active' : '' }}" enctype="multipart/form-data" data-mimes='{!! mimes_json !!}' data-max-bytes="{{ max_file_size_bytes }}" data-min-tags="{{ min_tags }}" data-comment-max-length="{{ comment_max_length }}" data-enable-swf="{{ enable_swf ? '1' : '0' }}" data-enable-archive="{{ enable_archive ? '1' : '0' }}">
<div class="form-section">
@if(web_url_upload)
<div class="upload-mode-tabs">
<button type="button" class="upload-mode-tab active" data-mode="file">
<i class="fa-solid fa-upload"></i>
{{ t('upload.file_tab') }}
</button>
<button type="button" class="upload-mode-tab" data-mode="album">
<i class="fa-solid fa-images"></i>
{{ t('album.title') || 'Album' }}
</button>
@if(web_url_upload)
<button type="button" class="upload-mode-tab" data-mode="url">
<i class="fa-solid fa-link"></i>
@if(enable_youtube_upload){{ t('upload.url_tab_yt') }}@else {{ t('upload.url_tab') }} @endif
</button>
@endif
</div>
@endif
<!-- File input area -->
<div class="upload-mode-content" id="mode-file">
<div class="drop-zone" id="upload-form-drop-zone">
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}" {{ shitpost_mode ? 'multiple' : '' }}>
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}" multiple>
<div class="drop-zone-prompt">
<p style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }}</p>
<p class="drop-zone-title" style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }}</p>
<p style="font-size: 0.9rem; opacity: 0.6;">(max {{ max_file_size }})@if(session.admin) <span style="color: var(--accent);">{{ t('upload.admin_boost') }}</span>@endif</p>
<p class="drop-album-hint" style="margin-top: 6px; font-size: 0.85rem; color: var(--accent); opacity: 0.95;">
<i class="fa-solid fa-layer-group"></i> {{ t('album.drop_hint') || 'Select or drop multiple files (subf0cks) to create an Album' }}
</p>
</div>
<!-- Preview Container -->
<div class="file-preview" style="display: none;">
<!-- Video will be injected here via JS -->
<!-- Video/images will be injected here via JS -->
</div>
</div>
</div>
<!-- Album / Batch Mode Switcher (displayed when multiple files are chosen) -->
<div class="album-choice-container" id="album-choice-container" style="display: none;">
<div class="album-choice-title">
<i class="fa-solid fa-layer-group"></i> {{ t('album.multiple_selected') || 'Multiple files selected' }}
</div>
<div class="album-choice-options">
<button type="button" class="album-choice-btn active" data-choice="album">
<i class="fa-solid fa-layer-group"></i> <span>{{ t('album.mode_album') || 'Album (1 post)' }}</span>
</button>
@if(shitpost_mode)
<button type="button" class="album-choice-btn" data-choice="batch">
<i class="fa-solid fa-cubes-stacked"></i> <span>{{ t('album.mode_batch') || 'Separate posts' }}</span>
</button>
@endif
</div>
</div>
@if(web_url_upload)
<!-- URL input area -->
<div class="upload-mode-content" id="mode-url" style="display: none;">
@@ -52,8 +75,8 @@
<input type="file" name="thumbnail" id="upload-thumbnail-input" accept="image/jpeg,image/png,image/webp" style="display:none;">
</div>
@if(!shitpost_mode && enable_item_title)
<div class="form-section global-title-section">
@if(enable_item_title)
<div class="form-section global-title-section" @if(shitpost_mode) style="display:none;" @endif>
<label>Title <span style="opacity: 0.5; font-weight: normal;">{{ t('upload.comment_optional') }}</span></label>
<input type="text" name="title" class="upload-title-input" placeholder="Add a title for this item…" maxlength="500" autocomplete="off">
</div>