gfsdgsd
This commit is contained in:
@@ -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);
|
||||
@@ -20718,3 +20718,271 @@ div#flash,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>` : ''} • 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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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' }}",
|
||||
|
||||
@@ -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) • 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user