fdsfads
This commit is contained in:
+33
-12
@@ -4223,17 +4223,20 @@ class CommentSystem {
|
||||
if (startH === null) startH = el.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
el.style.height = 'auto';
|
||||
const fullH = el.scrollHeight;
|
||||
const endH = hide ? 0 : fullH;
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.height = endH + 'px';
|
||||
// Height + clipping go through a class and a registered custom property (see f0ckm.css): layouts
|
||||
// like the Onara modal pin the container with `height: auto !important; overflow: visible
|
||||
// !important`, which beats both an animated height and an inline overflow. The class rule is
|
||||
// more specific and !important, and its height reads the variable the animation drives.
|
||||
el.style.setProperty('--comments-slide-h', endH + 'px');
|
||||
el.classList.add('is-comments-sliding');
|
||||
|
||||
const frames = (h0, h1) => [{ transform: 'translateY(' + (h0 - fullH) + 'px)' }, { transform: 'translateY(' + (h1 - fullH) + 'px)' }];
|
||||
// A turnaround covers only part of the distance, so it takes proportionally less time (same speed)
|
||||
const share = fullH > 0 ? Math.abs(endH - startH) / fullH : 1;
|
||||
const opts = { duration: Math.round(DURATION * Math.max(0.3, Math.min(1, share))), easing: EASING };
|
||||
const anims = [el.animate([{ height: startH + 'px' }, { height: endH + 'px' }], opts)];
|
||||
const anims = [el.animate([{ '--comments-slide-h': startH + 'px' }, { '--comments-slide-h': endH + 'px' }], opts)];
|
||||
kids.forEach(k => anims.push(k.animate(frames(startH, endH), opts)));
|
||||
|
||||
const slide = { dir: hide ? 'hide' : 'show', anims, el };
|
||||
@@ -4241,8 +4244,8 @@ class CommentSystem {
|
||||
anims[0].onfinish = () => {
|
||||
if (this._legacySlide !== slide) return;
|
||||
this._legacySlide = null;
|
||||
el.style.removeProperty('overflow');
|
||||
el.style.removeProperty('height');
|
||||
el.classList.remove('is-comments-sliding');
|
||||
el.style.removeProperty('--comments-slide-h');
|
||||
if (!hide) {
|
||||
document.body.classList.remove('comments-sliding');
|
||||
return;
|
||||
@@ -5005,6 +5008,28 @@ class CommentSystem {
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
// Bring the whole picker into view with some room below it. Measured once the enter animation has
|
||||
// finished (mid-animation it is shifted up and slightly squashed, so it would look smaller than it
|
||||
// is) and scrolled on whatever actually scrolls here (Onara modal, index container or the window).
|
||||
const revealPicker = () => {
|
||||
if (!picker || picker.style.display === 'none') return;
|
||||
let done = false;
|
||||
const run = () => {
|
||||
if (done || !picker || picker.style.display === 'none') return;
|
||||
done = true;
|
||||
const MARGIN = 24;
|
||||
const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(picker) : null;
|
||||
const view = sc ? sc.getBoundingClientRect() : { top: 0, bottom: window.innerHeight };
|
||||
const r = picker.getBoundingClientRect();
|
||||
let delta = r.bottom + MARGIN - view.bottom;
|
||||
// Never push the picker's own top out of view (very short viewports)
|
||||
delta = Math.min(delta, r.top - view.top - 8);
|
||||
if (delta > 0) (sc || window).scrollBy({ top: delta, behavior: 'smooth' });
|
||||
};
|
||||
picker.addEventListener('animationend', run, { once: true });
|
||||
setTimeout(run, 300);
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -5028,9 +5053,7 @@ class CommentSystem {
|
||||
picker.offsetHeight; // reflow
|
||||
picker.style.animation = '';
|
||||
trigger.classList.add('is-active');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||||
picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
));
|
||||
revealPicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5093,9 +5116,7 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
container.appendChild(picker);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||||
picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
));
|
||||
revealPicker();
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
+202
-53
@@ -809,6 +809,36 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
let video;
|
||||
let isNavigating = false;
|
||||
// Next/prev presses that arrive while an item is still loading: +1 per next, -1 per prev. Each
|
||||
// finished load immediately takes the next step (runNavQueue), so mashing next flies through items
|
||||
// instead of dropping every press after the first. Capped so holding the key can't run forever.
|
||||
let navQueue = 0;
|
||||
const NAV_QUEUE_MAX = 25;
|
||||
// Step inside the current item's album if there is a slide left in that direction. Returns true when
|
||||
// it stepped (the post navigation must then not happen).
|
||||
const albumNavStep = (direction) => {
|
||||
const gal = window._currentActiveAlbumGallery;
|
||||
if (!gal || typeof gal.getIndex !== 'function' || (gal.isAlive && !gal.isAlive())) return false;
|
||||
const idx = gal.getIndex();
|
||||
const count = gal.getCount();
|
||||
if (!(count > 1)) return false;
|
||||
if (direction === 'next' && idx < count - 1) { gal.next(); return true; }
|
||||
if (direction === 'prev' && idx > 0) { gal.prev(); return true; }
|
||||
return false;
|
||||
};
|
||||
const runNavQueue = () => {
|
||||
if (!navQueue || isNavigating) return;
|
||||
const dir = navQueue > 0 ? 'next' : 'prev';
|
||||
const el = dir === 'next'
|
||||
? document.querySelector(".steuerung .nav-next:not([href='#']), .nav-next:not([href='#']), #next:not([href='#'])")
|
||||
: document.querySelector(".steuerung .nav-prev:not([href='#']), .nav-prev:not([href='#']), #prev:not([href='#'])");
|
||||
if (!el || !el.href || el.href.endsWith('#')) {
|
||||
navQueue = 0; // reached the end of the list
|
||||
return;
|
||||
}
|
||||
navQueue += dir === 'next' ? -1 : 1;
|
||||
el.click();
|
||||
};
|
||||
const main = document.getElementById('main');
|
||||
let posts = document.querySelector('.posts');
|
||||
const navbar = document.querySelector("nav.navbar");
|
||||
@@ -3149,6 +3179,10 @@ window.cancelAnimFrame = (function () {
|
||||
background = !background;
|
||||
window.background = background;
|
||||
localStorage.setItem('background', background ? 'true' : 'false');
|
||||
// Toggling (l key / player button): fade out exactly as slowly as it fades in. Plain fader-out is
|
||||
// the quick navigation fade, so the toggle marks the canvas while the background is off.
|
||||
const bgEl = document.getElementById('bg');
|
||||
if (bgEl) bgEl.classList.toggle('bg-toggle-fade', !background);
|
||||
window.initBackground();
|
||||
|
||||
// Update videoplayer toggle buttons if they exist
|
||||
@@ -3597,8 +3631,6 @@ window.cancelAnimFrame = (function () {
|
||||
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');
|
||||
|
||||
@@ -3613,7 +3645,6 @@ window.cancelAnimFrame = (function () {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
if (totalCountEl) totalCountEl.textContent = albumData.length;
|
||||
|
||||
let albumVideoV0ck = null;
|
||||
const initAlbumVideoV0ck = () => {
|
||||
@@ -3953,10 +3984,6 @@ window.cancelAnimFrame = (function () {
|
||||
linkEl.href = newSrc;
|
||||
}
|
||||
|
||||
if (currentIdxEl) {
|
||||
currentIdxEl.textContent = currentIndex + 1;
|
||||
}
|
||||
|
||||
thumbItems.forEach((btn, idx) => {
|
||||
const isActive = idx === currentIndex;
|
||||
btn.classList.toggle('active', isActive);
|
||||
@@ -4152,8 +4179,19 @@ window.cancelAnimFrame = (function () {
|
||||
});
|
||||
}
|
||||
|
||||
// Arrived here by stepping backwards (prev from the following post): start on the LAST slide, so
|
||||
// going back through albums mirrors going forward. Only when no specific slide was requested.
|
||||
let enterFromEnd = false;
|
||||
if (window._albumEnterFromEnd) {
|
||||
window._albumEnterFromEnd = false;
|
||||
if (!initialHash) {
|
||||
currentIndex = albumData.length - 1;
|
||||
enterFromEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper initial media display (especially if initial item is video or subf0ck hash was requested)
|
||||
showImage(currentIndex, 'none', !!initialHash);
|
||||
showImage(currentIndex, 'none', !!initialHash || enterFromEnd);
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', (e) => {
|
||||
@@ -4182,6 +4220,10 @@ window.cancelAnimFrame = (function () {
|
||||
let lastX = 0;
|
||||
let lastTime = 0;
|
||||
|
||||
// Thumbnails are images inside buttons: without this the browser starts a native image drag on
|
||||
// them (Firefox ignores -webkit-user-drag), which swallows the mousemoves the strip scrolls with
|
||||
stripEl.addEventListener('dragstart', (e) => e.preventDefault());
|
||||
|
||||
stripEl.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
isDown = true;
|
||||
@@ -4201,6 +4243,8 @@ window.cancelAnimFrame = (function () {
|
||||
if (Math.abs(walk) > 6) {
|
||||
hasDraggedStrip = true;
|
||||
stripEl.classList.add('is-dragging');
|
||||
// Grabbing cursor for the whole drag, also over thumbnails and outside the strip
|
||||
document.documentElement.classList.add('album-strip-dragging');
|
||||
e.preventDefault();
|
||||
stripEl.scrollLeft = startScrollLeft - walk;
|
||||
|
||||
@@ -4215,6 +4259,7 @@ window.cancelAnimFrame = (function () {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
stripEl.classList.remove('is-dragging');
|
||||
document.documentElement.classList.remove('album-strip-dragging');
|
||||
showStripPeek(6000);
|
||||
|
||||
if (hasDraggedStrip && Math.abs(velX) > 0.15) {
|
||||
@@ -4393,6 +4438,10 @@ window.cancelAnimFrame = (function () {
|
||||
window._currentActiveAlbumGallery = {
|
||||
prev: () => showImage(currentIndex - 1, 'prev'),
|
||||
next: () => showImage(currentIndex + 1, 'next'),
|
||||
// Used by next/prev post navigation to step through the slides first (no wrap-around there)
|
||||
getIndex: () => currentIndex,
|
||||
getCount: () => albumData.length,
|
||||
isAlive: () => container.isConnected,
|
||||
showBySlug: (subKey) => {
|
||||
if (!subKey || !Array.isArray(albumData)) return false;
|
||||
const foundIdx = albumData.findIndex((item) =>
|
||||
@@ -4437,7 +4486,6 @@ window.cancelAnimFrame = (function () {
|
||||
th.setAttribute('data-index', newIdx);
|
||||
});
|
||||
|
||||
if (totalCountEl) totalCountEl.textContent = albumData.length;
|
||||
|
||||
if (currentIndex >= albumData.length) currentIndex = albumData.length - 1;
|
||||
showImage(currentIndex, 'none', true);
|
||||
@@ -5502,6 +5550,21 @@ window.cancelAnimFrame = (function () {
|
||||
const startTime = performance.now();
|
||||
const duration = 350; // ms
|
||||
|
||||
// Render the new background (scaled + blurred) ONCE into an offscreen canvas at canvas size.
|
||||
// Each crossfade frame then only blends two small bitmaps instead of re-scaling and re-blurring
|
||||
// the source (which may be a full-resolution album image) ~20 times.
|
||||
let prerendered = null;
|
||||
try {
|
||||
prerendered = document.createElement('canvas');
|
||||
prerendered.width = targetW;
|
||||
prerendered.height = targetH;
|
||||
const pctx = prerendered.getContext('2d');
|
||||
pctx.filter = getCanvasFilter();
|
||||
drawCoverImage(pctx, sourceImg);
|
||||
} catch (e) {
|
||||
prerendered = null;
|
||||
}
|
||||
|
||||
const step = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(1.0, elapsed / duration);
|
||||
@@ -5520,9 +5583,13 @@ window.cancelAnimFrame = (function () {
|
||||
// 2. Fade in new background smoothly over previous
|
||||
context.save();
|
||||
context.globalAlpha = ease;
|
||||
context.filter = getCanvasFilter();
|
||||
try {
|
||||
drawCoverImage(context, sourceImg);
|
||||
if (prerendered) {
|
||||
context.drawImage(prerendered, 0, 0, w, h);
|
||||
} else {
|
||||
context.filter = getCanvasFilter();
|
||||
drawCoverImage(context, sourceImg);
|
||||
}
|
||||
} catch (e) {}
|
||||
context.restore();
|
||||
|
||||
@@ -5607,6 +5674,9 @@ window.cancelAnimFrame = (function () {
|
||||
let videoFadeStartTime = prevSnapshot ? performance.now() : 0;
|
||||
let _bgLastTs = 0;
|
||||
const animationLoop = (ts) => {
|
||||
// Started directly (not from rAF) there is no timestamp: without one the fps throttle below compares
|
||||
// against NaN and never throttles again for the rest of the loop
|
||||
if (typeof ts !== 'number') ts = performance.now();
|
||||
// Increment the per-frame ID so getEffectiveBackgroundTuning() cache invalidates correctly
|
||||
window._rafFrameId = ((window._rafFrameId || 0) + 1) & 0xFFFFFF;
|
||||
|
||||
@@ -6169,21 +6239,25 @@ window.cancelAnimFrame = (function () {
|
||||
window.isCurrentlyInOnara = isCurrentlyInOnara;
|
||||
|
||||
let _isAudioItemCache = { result: false, ts: 0 };
|
||||
// The album template always contains a hidden <audio>; it only counts while an audio slide is shown
|
||||
const isInactiveAlbumAudio = (el) => {
|
||||
if (!el || el.id !== 'f0ck-album-audio') return false;
|
||||
const albumContainer = el.closest('.album-gallery-container');
|
||||
return !albumContainer || !albumContainer.classList.contains('is-audio-active');
|
||||
};
|
||||
|
||||
const isCurrentItemAudio = () => {
|
||||
const now = Date.now();
|
||||
if (now - _isAudioItemCache.ts < 250) return _isAudioItemCache.result;
|
||||
// Albums always contain a hidden <audio>: it only counts while an audio slide is shown, otherwise
|
||||
// video/image albums would get the audio background profile (different fps cap and tuning)
|
||||
const isActiveAudio = (el) => !!el && el.isConnected && !isInactiveAlbumAudio(el);
|
||||
const mount = document.getElementById('onara-item-mount');
|
||||
if (mount) {
|
||||
const a = mount.querySelector('#f0ck-album-audio, audio#my-video, audio');
|
||||
if (a && a.isConnected) { _isAudioItemCache = { result: true, ts: now }; return true; }
|
||||
}
|
||||
const aud = document.querySelector('#f0ck-album-audio, audio#my-video, audio');
|
||||
let result = false;
|
||||
if (aud && aud.isConnected) {
|
||||
const albumAudioWrap = document.getElementById('f0ck-album-audio-wrapper');
|
||||
if (albumAudioWrap && albumAudioWrap.style.display !== 'none') result = true;
|
||||
if (aud.tagName === 'AUDIO') result = true;
|
||||
if (mount && Array.from(mount.querySelectorAll('audio')).some(isActiveAudio)) {
|
||||
_isAudioItemCache = { result: true, ts: now };
|
||||
return true;
|
||||
}
|
||||
const result = Array.from(document.querySelectorAll('#f0ck-album-audio, audio#my-video, audio')).some(isActiveAudio);
|
||||
_isAudioItemCache = { result, ts: now };
|
||||
return result;
|
||||
};
|
||||
@@ -8860,13 +8934,6 @@ window.cancelAnimFrame = (function () {
|
||||
if (bgCanvas) bgCanvas.style.filter = '';
|
||||
};
|
||||
|
||||
// The album template always contains a hidden <audio>; it only counts while an audio slide is shown
|
||||
const isInactiveAlbumAudio = (el) => {
|
||||
if (!el || el.id !== 'f0ck-album-audio') return false;
|
||||
const albumContainer = el.closest('.album-gallery-container');
|
||||
return !albumContainer || !albumContainer.classList.contains('is-audio-active');
|
||||
};
|
||||
|
||||
window.initVisualizer = (targetAudio) => {
|
||||
let audioElement = targetAudio;
|
||||
if (!audioElement || !audioElement.isConnected) {
|
||||
@@ -12553,7 +12620,11 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
const loadItemAjax = async (url, inheritContext = true, options = {}) => {
|
||||
|
||||
// Anything other than a next/prev step (random, thumbnail, back...) ends a queued burst
|
||||
if (!options.direction) navQueue = 0;
|
||||
if (isNavigating) return;
|
||||
// Stepping back (prev) into an album opens it on its last slide (read by initAlbumGallery)
|
||||
window._albumEnterFromEnd = options.direction === 'prev';
|
||||
cancelPrefetch();
|
||||
|
||||
// Immediately restore scrollability and hide modals
|
||||
@@ -13384,9 +13455,12 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
} catch (err) {
|
||||
console.error("AJAX load failed:", err);
|
||||
navQueue = 0;
|
||||
} finally {
|
||||
isNavigating = false;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
// Queued next/prev presses: take the next step right away, from the freshly loaded item's links
|
||||
if (navQueue) setTimeout(runNavQueue, 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13735,6 +13809,20 @@ window.cancelAnimFrame = (function () {
|
||||
const isGridPage = !!document.querySelector('.index-layout-wrapper, .index-container, .posts');
|
||||
const useOnara = isOnaraActive();
|
||||
|
||||
// Still loading the previous step: remember the press, it runs as soon as that item is in
|
||||
if (direction && isNavigating) {
|
||||
navQueue = Math.max(-NAV_QUEUE_MAX, Math.min(NAV_QUEUE_MAX, navQueue + (direction === 'next' ? 1 : -1)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Album on this item: next/prev go through its slides one by one; only past the last (or before
|
||||
// the first) slide do they move on to the next (previous) post
|
||||
if (direction && albumNavStep(direction)) {
|
||||
// A queued burst keeps going through the remaining slides / posts
|
||||
if (navQueue) setTimeout(runNavQueue, 120);
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction) {
|
||||
const outEls = document.querySelectorAll('.content, ._204863, .item_title');
|
||||
outEls.forEach(el => {
|
||||
@@ -14849,15 +14937,7 @@ 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;
|
||||
}
|
||||
}
|
||||
// Albums: the next/prev link click handler steps through the slides first (see albumNavStep)
|
||||
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');
|
||||
@@ -24078,6 +24158,9 @@ document.addEventListener('keydown', (e) => {
|
||||
percent: Math.round(percent),
|
||||
type
|
||||
};
|
||||
// sendBeacon can't set headers: logged-in users need the CSRF token in the body or get a 403
|
||||
const csrf = window.f0ckSession?.csrf_token;
|
||||
if (csrf) payload.csrf_token = csrf;
|
||||
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
@@ -24086,7 +24169,7 @@ document.addEventListener('keydown', (e) => {
|
||||
} else {
|
||||
fetch('/api/v2/track/interaction', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-CSRF-Token': csrf } : {}) },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
@@ -24112,6 +24195,9 @@ document.addEventListener('keydown', (e) => {
|
||||
|
||||
// Dispatch tracking to backend
|
||||
const payload = { tag: tags.join(',') };
|
||||
// sendBeacon can't set headers: logged-in users need the CSRF token in the body or get a 403
|
||||
const csrf = window.f0ckSession?.csrf_token;
|
||||
if (csrf) payload.csrf_token = csrf;
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
||||
@@ -24119,7 +24205,7 @@ document.addEventListener('keydown', (e) => {
|
||||
} else {
|
||||
fetch('/api/v2/track/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-CSRF-Token': csrf } : {}) },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
@@ -24265,24 +24351,18 @@ window.BulkSelection = (() => {
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<div class="bulk-count-box">
|
||||
<i class="fa-solid fa-check-double" style="color: var(--accent);"></i>
|
||||
<span class="bulk-count-num">0</span>
|
||||
<span class="bulk-count-label">selected</span>
|
||||
</div>
|
||||
<div class="bulk-actions-group">
|
||||
<button type="button" class="bulk-btn bulk-btn-primary" id="bulk-btn-add-tags" title="Add tags in bulk">
|
||||
<i class="fa-solid fa-tag"></i>
|
||||
<span>Add Tag<span class="btn-text-full">s</span></span>
|
||||
</button>
|
||||
<button type="button" class="bulk-btn" id="bulk-btn-remove-tags" title="Remove tags in bulk">
|
||||
<i class="fa-solid fa-tags"></i>
|
||||
<span>Remove Tag<span class="btn-text-full">s</span></span>
|
||||
</button>
|
||||
<div class="bulk-dropdown-wrap">
|
||||
<button type="button" class="bulk-btn" id="bulk-btn-rate" title="Change rating in bulk">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
<span>Rating</span>
|
||||
<i class="fa-solid fa-chevron-up" style="font-size: 10px; margin-left: 2px;"></i>
|
||||
<i class="fa-solid fa-chevron-up bulk-chevron"></i>
|
||||
</button>
|
||||
<div class="bulk-dropdown-menu" id="bulk-rating-menu">
|
||||
<button type="button" class="bulk-menu-item rating-sfw" data-rating="sfw">
|
||||
@@ -24296,6 +24376,27 @@ window.BulkSelection = (() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bulk-dropdown-wrap">
|
||||
<button type="button" class="bulk-btn" id="bulk-btn-visibility" title="Change visibility in bulk">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
<span>Visibility</span>
|
||||
<i class="fa-solid fa-chevron-up bulk-chevron"></i>
|
||||
</button>
|
||||
<div class="bulk-dropdown-menu" id="bulk-visibility-menu">
|
||||
<button type="button" class="bulk-menu-item" data-visibility="0">
|
||||
<i class="fa-solid fa-globe"></i> Public
|
||||
</button>
|
||||
<button type="button" class="bulk-menu-item" data-visibility="1">
|
||||
<i class="fa-solid fa-link"></i> Unlisted
|
||||
</button>
|
||||
<button type="button" class="bulk-menu-item" data-visibility="2">
|
||||
<i class="fa-solid fa-lock"></i> Private
|
||||
</button>
|
||||
<button type="button" class="bulk-menu-item" data-visibility="3">
|
||||
<i class="fa-solid fa-ban"></i> Unavailable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="bulk-btn bulk-btn-danger" id="bulk-btn-delete" title="Delete selected items">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
<span class="btn-text-full">Delete</span>
|
||||
@@ -24337,14 +24438,15 @@ window.BulkSelection = (() => {
|
||||
|
||||
// Wire up Action Bar events
|
||||
document.getElementById('bulk-btn-add-tags')?.addEventListener('click', () => openTagModal('add'));
|
||||
document.getElementById('bulk-btn-remove-tags')?.addEventListener('click', () => openTagModal('remove'));
|
||||
|
||||
// Rating menu toggle
|
||||
const rateBtn = document.getElementById('bulk-btn-rate');
|
||||
const rateMenu = document.getElementById('bulk-rating-menu');
|
||||
rateBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
rateMenu?.classList.toggle('show');
|
||||
const open = !rateMenu?.classList.contains('show');
|
||||
closeBulkMenus();
|
||||
if (open) rateMenu?.classList.add('show');
|
||||
});
|
||||
|
||||
// Rating items click
|
||||
@@ -24357,6 +24459,24 @@ window.BulkSelection = (() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Visibility menu toggle + items
|
||||
const visBtn = document.getElementById('bulk-btn-visibility');
|
||||
const visMenu = document.getElementById('bulk-visibility-menu');
|
||||
visBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const open = !visMenu?.classList.contains('show');
|
||||
closeBulkMenus();
|
||||
if (open) visMenu?.classList.add('show');
|
||||
});
|
||||
visMenu?.querySelectorAll('.bulk-menu-item').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
visMenu.classList.remove('show');
|
||||
const vis = parseInt(btn.dataset.visibility, 10);
|
||||
if (!isNaN(vis)) await executeBulkVisibility(vis, btn.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('bulk-btn-select-all')?.addEventListener('click', () => selectAllLoaded());
|
||||
document.getElementById('bulk-btn-close')?.addEventListener('click', () => exitSelectionMode());
|
||||
document.getElementById('bulk-btn-delete')?.addEventListener('click', () => executeBulkDelete());
|
||||
@@ -24394,16 +24514,14 @@ window.BulkSelection = (() => {
|
||||
if (bar) {
|
||||
bar.classList.remove('visible');
|
||||
}
|
||||
document.getElementById('bulk-rating-menu')?.classList.remove('show');
|
||||
closeBulkMenus();
|
||||
closeTagModal();
|
||||
}
|
||||
|
||||
function updateToolbar() {
|
||||
const count = selectedIds.size;
|
||||
const countNum = document.querySelector('#bulk-action-bar .bulk-count-num');
|
||||
const countLabel = document.querySelector('#bulk-action-bar .bulk-count-label');
|
||||
if (countNum) countNum.textContent = count;
|
||||
if (countLabel) countLabel.textContent = count === 1 ? 'item' : 'items';
|
||||
|
||||
if (count > 0 && !isSelectionMode) {
|
||||
enterSelectionMode();
|
||||
@@ -24493,7 +24611,7 @@ window.BulkSelection = (() => {
|
||||
|
||||
renderTagChips();
|
||||
if (input) input.value = '';
|
||||
document.getElementById('bulk-rating-menu')?.classList.remove('show');
|
||||
closeBulkMenus();
|
||||
|
||||
modal?.classList.add('visible');
|
||||
setTimeout(() => input?.focus(), 80);
|
||||
@@ -24696,6 +24814,37 @@ window.BulkSelection = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
function closeBulkMenus() {
|
||||
document.querySelectorAll('#bulk-action-bar .bulk-dropdown-menu.show').forEach(m => m.classList.remove('show'));
|
||||
}
|
||||
|
||||
async function executeBulkVisibility(visibility, label) {
|
||||
const itemIds = Array.from(selectedIds);
|
||||
if (itemIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v2/items/bulk-visibility', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ item_ids: itemIds, visibility })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const n = data.updated || 0;
|
||||
flash(n ? `Visibility set to ${label} for ${n} item${n === 1 ? '' : 's'}` : (data.msg || 'Nothing to change'));
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
flash(data.msg || 'Failed to update visibility', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BULK_VISIBILITY_ERROR]', err);
|
||||
flash('Failed to communicate with server', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBulkRating(rating) {
|
||||
const itemIds = Array.from(selectedIds);
|
||||
if (itemIds.length === 0) return;
|
||||
@@ -24918,7 +25067,7 @@ window.BulkSelection = (() => {
|
||||
|
||||
// Close dropdown if clicking outside
|
||||
if (!e.target.closest('.bulk-dropdown-wrap')) {
|
||||
document.getElementById('bulk-rating-menu')?.classList.remove('show');
|
||||
closeBulkMenus();
|
||||
}
|
||||
|
||||
const thumb = e.target.closest('.posts > a.thumb, .posts > a.lazy-thumb');
|
||||
|
||||
Reference in New Issue
Block a user