diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index 4303c4d..03ff48a 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -12521,10 +12521,10 @@ body > nav.navbar {
@media (max-width: 467px) {
- .nav-avatar-name,
+ /* .nav-avatar-name,
.nav-avatar-caret {
display: none;
- }
+ } */
}
@@ -12667,7 +12667,14 @@ input:checked+.slider:before {
.rating-toggle-btn.locked {
cursor: not-allowed !important;
pointer-events: none !important;
- opacity: 0.85 !important;
+}
+
+.rating-toggle-btn.locked:not(.active) {
+ opacity: 0.45 !important;
+}
+
+.rating-toggle-btn.locked.active {
+ opacity: 1 !important;
}
/* Per-rating active colours */
@@ -20221,4 +20228,493 @@ div#flash,
#onara-modal:fullscreen .onara-modal-content {
overflow: hidden !important;
padding: 0 !important;
+}
+
+/* ==========================================================================
+ Bulk Item Selection & Operations (Themed: No rounded corners, no box shadows)
+ ========================================================================== */
+
+/* Square checkbox indicator on thumbnails */
+.thumb-select-check {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 18px;
+ height: 18px;
+ border-radius: 0 !important;
+ background: var(--bg, #111);
+ border: 1px solid var(--accent, #9f0);
+ color: var(--accent, #9f0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-family: var(--font, monospace);
+ z-index: 25;
+ pointer-events: none;
+ opacity: 0;
+ box-shadow: none !important;
+ transition: opacity 0.15s ease;
+}
+
+.bulk-selection-active .thumb .thumb-select-check {
+ opacity: 0.85;
+}
+
+.thumb.bulk-selected .thumb-select-check {
+ opacity: 1 !important;
+ background: var(--accent, #9f0) !important;
+ color: var(--black, #000) !important;
+ font-weight: bold !important;
+}
+
+/* Selected thumbnail outline: theme-colored border, no shadows, no transform */
+.thumb.bulk-selected {
+ outline: 2px solid var(--accent, #9f0) !important;
+ outline-offset: -2px !important;
+ box-shadow: none !important;
+ transform: none !important;
+ filter: none !important;
+}
+
+/* Long-press holding indicator: dashed border */
+.thumb.thumb-longpressing {
+ outline: 1px dashed var(--accent, #9f0) !important;
+ outline-offset: -1px !important;
+ box-shadow: none !important;
+ transform: none !important;
+}
+
+/* Floating Bulk Action Bar (fixed to bottom, themed border and background) */
+#bulk-action-bar {
+ position: fixed;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(100%);
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--dropdown-bg, #232323) !important;
+ border-top: 2px solid var(--accent, #9f0) !important;
+ border-left: 1px solid var(--accent, #9f0) !important;
+ border-right: 1px solid var(--accent, #9f0) !important;
+ border-bottom: none !important;
+ border-radius: 0 !important;
+ padding: 6px 12px;
+ box-shadow: none !important;
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 0.2s ease, opacity 0.2s ease;
+ user-select: none;
+ font-family: var(--font, monospace) !important;
+ max-width: 100vw;
+}
+
+#bulk-action-bar.visible {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.bulk-count-box {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: bold;
+ font-size: 12px;
+ color: var(--white, #fff);
+ padding-right: 10px;
+ border-right: 1px solid var(--nav-border-color, #444);
+ white-space: nowrap;
+ font-family: var(--font, monospace);
+}
+
+.bulk-count-num {
+ background: var(--bg, #111);
+ color: var(--accent, #9f0);
+ border: 1px solid var(--accent, #9f0);
+ border-radius: 0 !important;
+ padding: 1px 6px;
+ font-size: 11px;
+ font-weight: bold;
+ box-shadow: none !important;
+}
+
+.bulk-actions-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: nowrap;
+}
+
+.bulk-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 10px;
+ border-radius: 0 !important;
+ font-size: 12px;
+ font-family: var(--font, monospace) !important;
+ border: 1px solid var(--nav-border-color, #444);
+ background: var(--nav-bg, #2b2b2b);
+ color: var(--white, #fff);
+ cursor: pointer;
+ box-shadow: none !important;
+ transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
+ white-space: nowrap;
+}
+
+.bulk-btn:hover {
+ background: var(--nav-link-hover-bg, #333);
+ color: var(--accent, #9f0);
+ border-color: var(--accent, #9f0);
+ transform: none !important;
+}
+
+.bulk-btn:active {
+ transform: none !important;
+}
+
+.bulk-btn-primary {
+ background: var(--accent, #9f0) !important;
+ color: var(--black, #000) !important;
+ border: 1px solid var(--accent, #9f0) !important;
+ font-weight: bold !important;
+ box-shadow: none !important;
+}
+
+.bulk-btn-primary:hover {
+ filter: brightness(1.15);
+}
+
+.bulk-btn-close {
+ padding: 5px 8px;
+ background: var(--nav-bg, #2b2b2b);
+ color: var(--white, #fff);
+ border: 1px solid var(--nav-border-color, #444);
+}
+
+.bulk-btn-close:hover {
+ background: #ff4444 !important;
+ color: #fff !important;
+ border-color: #ff4444 !important;
+}
+
+/* Bulk rating dropdown wrapper */
+.bulk-dropdown-wrap {
+ position: relative;
+}
+
+.bulk-dropdown-menu {
+ position: absolute;
+ bottom: calc(100% + 4px);
+ left: 0;
+ background: var(--dropdown-bg, #232323);
+ border: 1px solid var(--accent, #9f0);
+ border-radius: 0 !important;
+ padding: 4px;
+ display: none;
+ flex-direction: column;
+ gap: 2px;
+ box-shadow: none !important;
+ min-width: 120px;
+ z-index: 100000;
+ font-family: var(--font, monospace);
+}
+
+.bulk-dropdown-menu.show {
+ display: flex;
+}
+
+.bulk-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 8px;
+ border-radius: 0 !important;
+ font-size: 11.5px;
+ font-family: var(--font, monospace);
+ background: transparent;
+ border: none;
+ color: var(--white, #fff);
+ cursor: pointer;
+ text-align: left;
+ box-shadow: none !important;
+ transition: background 0.1s ease, color 0.1s ease;
+ white-space: nowrap;
+}
+
+.bulk-menu-item:hover {
+ background: var(--nav-link-hover-bg, #333);
+ color: var(--accent, #9f0);
+}
+
+.bulk-menu-item.rating-sfw {
+ border-left: 3px solid var(--badge-sfw, #02500b);
+}
+.bulk-menu-item.rating-sfw:hover {
+ background: var(--badge-sfw, #02500b);
+ color: #fff;
+}
+
+.bulk-menu-item.rating-nsfw {
+ border-left: 3px solid var(--badge-nsfw, #E10DC3);
+}
+.bulk-menu-item.rating-nsfw:hover {
+ background: var(--badge-nsfw, #E10DC3);
+ color: #fff;
+}
+
+.bulk-menu-item.rating-nsfl {
+ border-left: 3px solid var(--badge-nsfl, #660000);
+}
+.bulk-menu-item.rating-nsfl:hover {
+ background: var(--badge-nsfl, #660000);
+ color: #fff;
+}
+
+/* Bulk Modals (Add Tags / Remove Tags) */
+.bulk-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 100300;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.bulk-modal-overlay.visible {
+ display: flex;
+}
+
+.bulk-modal-card {
+ background: var(--bg, #111);
+ border: 1px solid var(--accent, #9f0);
+ border-radius: 0 !important;
+ padding: 18px;
+ width: 100%;
+ max-width: 440px;
+ box-shadow: none !important;
+ color: var(--white, #fff);
+ font-family: var(--font, monospace);
+ position: relative;
+}
+
+.bulk-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--nav-border-color, #444);
+ padding-bottom: 8px;
+}
+
+.bulk-modal-header h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: bold;
+ font-family: var(--font, monospace);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--accent, #9f0);
+}
+
+.bulk-modal-close {
+ background: none;
+ border: none;
+ color: var(--white, #fff);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 0 !important;
+ font-family: var(--font, monospace);
+}
+
+.bulk-modal-close:hover {
+ color: #ff4444;
+}
+
+.bulk-modal-desc {
+ font-size: 12px;
+ color: #aaa;
+ margin-bottom: 12px;
+ line-height: 1.4;
+ font-family: var(--font, monospace);
+}
+
+.bulk-tag-chips-wrap {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 10px;
+ min-height: 24px;
+}
+
+.bulk-tag-chip {
+ background: var(--badge-bg, #171717);
+ border: 1px solid var(--accent, #9f0);
+ color: var(--white, #fff);
+ border-radius: 0 !important;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-family: var(--font, monospace);
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ box-shadow: none !important;
+}
+
+.bulk-tag-chip .remove-chip-btn {
+ cursor: pointer;
+ opacity: 0.7;
+ font-size: 10px;
+}
+
+.bulk-tag-chip .remove-chip-btn:hover {
+ opacity: 1;
+ color: #ff4444;
+}
+
+.bulk-input-container {
+ position: relative;
+ margin-bottom: 14px;
+}
+
+.bulk-text-input {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--nav-border-color, #444);
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ padding: 8px 10px;
+ color: var(--white, #fff);
+ font-size: 13px;
+ font-family: var(--font, monospace);
+ outline: none;
+ box-sizing: border-box;
+}
+
+.bulk-text-input:focus {
+ border-color: var(--accent, #9f0);
+ outline: none;
+ box-shadow: none !important;
+}
+
+.bulk-tag-suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--dropdown-bg, #232323);
+ border: 1px solid var(--accent, #9f0);
+ border-radius: 0 !important;
+ max-height: 160px;
+ overflow-y: auto;
+ z-index: 100310;
+ box-shadow: none !important;
+ display: none;
+ font-family: var(--font, monospace);
+}
+
+.bulk-suggestion-item {
+ padding: 6px 10px;
+ font-size: 12px;
+ font-family: var(--font, monospace);
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ color: var(--white, #fff);
+ border-radius: 0 !important;
+}
+
+.bulk-suggestion-item:hover,
+.bulk-suggestion-item.highlighted {
+ background: var(--accent, #9f0);
+ color: var(--black, #000);
+ font-weight: bold;
+}
+
+.bulk-suggestion-count {
+ font-size: 10px;
+ opacity: 0.7;
+}
+
+.bulk-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.bulk-modal-btn {
+ padding: 6px 14px;
+ border-radius: 0 !important;
+ font-size: 12px;
+ font-family: var(--font, monospace) !important;
+ cursor: pointer;
+ border: 1px solid var(--nav-border-color, #444);
+ background: var(--nav-bg, #2b2b2b);
+ color: var(--white, #fff);
+ box-shadow: none !important;
+ transition: all 0.12s ease;
+}
+
+.bulk-modal-btn:hover {
+ background: var(--nav-link-hover-bg, #333);
+ color: var(--accent, #9f0);
+ border-color: var(--accent, #9f0);
+}
+
+.bulk-modal-btn-submit {
+ background: var(--accent, #9f0) !important;
+ color: var(--black, #000) !important;
+ font-weight: bold !important;
+ border: 1px solid var(--accent, #9f0) !important;
+ box-shadow: none !important;
+}
+
+.bulk-modal-btn-submit:hover {
+ filter: brightness(1.15);
+}
+
+.bulk-modal-btn-submit:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ filter: none;
+}
+
+/* Responsive tweaks for mobile devices */
+@media (max-width: 768px) {
+ #bulk-action-bar {
+ width: 100vw;
+ padding: 6px 8px;
+ justify-content: space-between;
+ gap: 4px;
+ }
+
+ .bulk-count-box {
+ padding-right: 6px;
+ font-size: 11.5px;
+ gap: 4px;
+ }
+
+ .bulk-btn {
+ padding: 5px 7px;
+ font-size: 11.5px;
+ gap: 3px;
+ }
+
+ .bulk-btn .btn-text-full {
+ display: none;
+ }
+
+ .bulk-modal-card {
+ padding: 14px;
+ }
}
\ No newline at end of file
diff --git a/public/s/js/admin.js b/public/s/js/admin.js
index 4458f44..ee8743f 100644
--- a/public/s/js/admin.js
+++ b/public/s/js/admin.js
@@ -50,6 +50,7 @@
const tagsContainer = document.querySelector("#tags");
if (!tagsContainer) return;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
+ const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
const canManage = !!(
document.querySelector('#tags[data-can-manage="true"]') ||
@@ -96,6 +97,7 @@
if (isRating) {
span.classList.add('rating-tag', `is-${tag.normalized}`);
span.dataset.rating = tag.normalized;
+ if (activePostId) span.dataset.itemId = activePostId;
if (canManage) {
span.classList.add('can-cycle');
}
@@ -124,6 +126,7 @@
const untaggedSpan = document.createElement("span");
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`;
untaggedSpan.dataset.rating = 'untagged';
+ if (activePostId) untaggedSpan.dataset.itemId = activePostId;
const lbl = document.createElement("span");
lbl.className = "rating-label";
lbl.textContent = "untagged";
@@ -139,6 +142,18 @@
}
}
+ // Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
+ if (activePostId) {
+ const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
+ document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
+ th.setAttribute('data-mode', modeAttr);
+ });
+ if (document.body.classList.contains('onara-modal-open')) {
+ const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
+ if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
+ }
+ }
+
// Handle show more/less toggle visibility and count
const allBadges = [...inner.querySelectorAll(".badge")];
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js
index e26202b..6d6549c 100644
--- a/public/s/js/anon_ssh.js
+++ b/public/s/js/anon_ssh.js
@@ -645,11 +645,16 @@
if (data.success) {
this.isSessionReady = true;
- if (data.csrf_token) {
- if (window.f0ckSession) window.f0ckSession.csrf_token = data.csrf_token;
- const metaCsrf = document.querySelector('meta[name="csrf-token"]');
- if (metaCsrf) metaCsrf.content = data.csrf_token;
+ if (window.f0ckSession) {
+ window.f0ckSession.user = 'anonymous';
+ window.f0ckSession.is_anon = true;
+ window.f0ckSession.logged_in = true;
+ window.f0ckSession.id = data.user_id;
+ window.f0ckSession.user_id = data.user_id;
+ if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token;
}
+ const metaCsrf = document.querySelector('meta[name="csrf-token"]');
+ if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token;
window.f0ckAnonIdentity = {
userId: data.user_id,
fingerprint: data.fingerprint,
@@ -657,6 +662,9 @@
hwFingerprint: data.hw_fingerprint || hwFingerprint
};
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
+ if (typeof window.syncRatingButtonUI === 'function') {
+ window.syncRatingButtonUI();
+ }
}
} catch (err) {
console.warn('[ANON_SSH] Session handshake failed:', err);
@@ -870,6 +878,15 @@
const guestFavsLink = document.getElementById('nav-guest-favs-link');
if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none';
if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none';
+
+ if (isAnon && window.f0ckSession) {
+ window.f0ckSession.user = window.f0ckSession.user || 'anonymous';
+ window.f0ckSession.is_anon = true;
+ window.f0ckSession.logged_in = true;
+ }
+ if (typeof window.syncRatingButtonUI === 'function') {
+ window.syncRatingButtonUI();
+ }
}
/**
diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js
index b21edaf..779d2d7 100644
--- a/public/s/js/f0ckm.js
+++ b/public/s/js/f0ckm.js
@@ -51,22 +51,56 @@ window.cancelAnimFrame = (function () {
};
window.getCurrentItemId = () => {
- const idEl = document.querySelector('a.id-link[data-item-id], .rating-tag[data-item-id], #a_toggle[data-item-id], #a_favo[data-item-id], #comments-container[data-item-id], [data-item-id]');
- if (idEl && idEl.dataset && idEl.dataset.itemId) {
- const parsed = parseInt(idEl.dataset.itemId, 10);
- if (!isNaN(parsed)) return String(parsed);
+ // 1. If Onara modal is open or mounted, strictly search within #onara-item-mount
+ const isOnaraOpen = document.body.classList.contains('onara-modal-open');
+ if (isOnaraOpen) {
+ const mount = document.getElementById('onara-item-mount');
+ if (mount) {
+ const idEl = mount.querySelector('.item-layout-container[data-item-id], a.id-link[data-item-id], #comments-container[data-item-id], .rating-tag[data-item-id], #tags[data-item-id]');
+ if (idEl?.dataset?.itemId) {
+ const parsed = parseInt(idEl.dataset.itemId, 10);
+ if (!isNaN(parsed) && parsed > 0) return String(parsed);
+ }
+ const idLink = mount.querySelector('a.id-link');
+ if (idLink && idLink.innerText) {
+ const parsed = parseInt(idLink.innerText.trim(), 10);
+ if (!isNaN(parsed) && parsed > 0) return String(parsed);
+ }
+ }
+ const activeThumb = document.querySelector('.posts > a.thumb.onara-active[data-item-id]');
+ if (activeThumb?.dataset?.itemId) {
+ const parsed = parseInt(activeThumb.dataset.itemId, 10);
+ if (!isNaN(parsed) && parsed > 0) return String(parsed);
+ }
}
+
+ // 2. If viewing an item in main content (regular full-page view or PJAX)
+ const itemContainer = document.querySelector('.item-layout-container, #main.item-view, #main > .container.item-view');
+ if (itemContainer) {
+ const idEl = itemContainer.querySelector('.item-layout-container[data-item-id], a.id-link[data-item-id], #comments-container[data-item-id], .rating-tag[data-item-id], #tags[data-item-id]');
+ if (idEl?.dataset?.itemId) {
+ const parsed = parseInt(idEl.dataset.itemId, 10);
+ if (!isNaN(parsed) && parsed > 0) return String(parsed);
+ }
+ if (itemContainer.dataset?.itemId) {
+ const parsed = parseInt(itemContainer.dataset.itemId, 10);
+ if (!isNaN(parsed) && parsed > 0) return String(parsed);
+ }
+ const idLink = itemContainer.querySelector('a.id-link');
+ if (idLink && idLink.innerText) {
+ const parsed = parseInt(idLink.innerText.trim(), 10);
+ if (!isNaN(parsed) && parsed > 0) return String(parsed);
+ }
+ }
+
+ // 3. Fallback to URL pathname if on an item page
const path = window.location.pathname;
- // Explicitly ignore admin/mod/settings paths to avoid false positives from user IDs, etc.
if (path.includes('/admin/') || path.includes('/mod/') || path.includes('/settings') || path.includes('/user/')) return null;
const match = path.match(/\/(\d+)\/?$/);
if (match) return match[1];
- const idLink = document.querySelector('a.id-link');
- if (idLink && idLink.innerText) {
- const parsed = parseInt(idLink.innerText.trim(), 10);
- if (!isNaN(parsed)) return String(parsed);
- }
+ // NOTE: NEVER use document.querySelector('[data-item-id]') globally here,
+ // as it erroneously matches feed/grid thumbnails!
return null;
};
@@ -600,8 +634,157 @@ window.cancelAnimFrame = (function () {
let visualizerRafId = null;
let audioSource = null;
+ const isAnonUser = () => !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon);
+ window.isAnonUser = isAnonUser;
+
+ const isGuestUser = () => {
+ if (!window.f0ckSession) return true;
+ if (window.f0ckSession.user && !window.f0ckSession.is_anon) return false;
+ if (window.f0ckSession.is_anon && (window.f0ckSession.logged_in || window.f0ckSession.user)) return false;
+ if (window.f0ckAnonSSH && (window.f0ckAnonSSH.isSessionReady || window.f0ckAnonSSH.pubkey)) return false;
+ if (typeof localStorage !== 'undefined' && localStorage.getItem('f0ck_anon_ssh_pub')) return false;
+ return !window.f0ckSession.logged_in;
+ };
+ window.isGuestUser = isGuestUser;
+
+ const getRatingsCookie = () => {
+ const raw = document.cookie.split('; ').find(r => r.startsWith('ratings='));
+ if (!raw) return [];
+ const val = raw.split('=').slice(1).join('=');
+ const decoded = decodeURIComponent(val);
+ const parts = decoded.includes('|') ? decoded.split('|') : decoded.split(',');
+ return parts.filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r));
+ };
+ window.getRatingsCookie = getRatingsCookie;
+
+ const setRatingsCookie = (ratings) => {
+ const val = ratings.join('|');
+ document.cookie = `ratings=${val}; Path=/; Max-Age=31536000; SameSite=Lax`;
+ };
+ window.setRatingsCookie = setRatingsCookie;
+
+ const clearRatingsCookie = () => {
+ document.cookie = 'ratings=; Path=/; Max-Age=0; SameSite=Lax';
+ };
+ window.clearRatingsCookie = clearRatingsCookie;
+
+ const reconcileAnonFilterPermissions = () => {
+ if (isGuestUser()) {
+ let changed = false;
+ if (window.activeMode !== 0) {
+ window.activeMode = 0;
+ document.cookie = 'mode=0; Path=/; Max-Age=31536000; SameSite=Lax';
+ changed = true;
+ }
+ const curRatings = getRatingsCookie();
+ if (curRatings.length !== 1 || curRatings[0] !== 'sfw') {
+ setRatingsCookie(['sfw']);
+ changed = true;
+ }
+ return changed;
+ }
+
+ if (!isAnonUser()) return false;
+
+ const perms = window.f0ckSession?.anon_permissions;
+ if (!perms) return false;
+
+ let changed = false;
+ const canFilter = perms.filter !== false;
+ const allowedModes = Array.isArray(perms.allowed_modes) ? perms.allowed_modes.map(m => String(m).toLowerCase()) : ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
+ const allowedMimes = Array.isArray(perms.allowed_mimes) ? perms.allowed_mimes.map(m => String(m).toLowerCase()) : ['image', 'video', 'audio', 'flash', 'pdf'];
+ const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
+
+ // 1. Check locked rating from permissions (single allowed mode only)
+ const isSingleAllowedMode = allowedModes.length === 1 && !allowedModes.includes('all');
+ const lockedRating = isSingleAllowedMode ? allowedModes[0] : null;
+
+ if (lockedRating) {
+ const targetMode = modeNames.indexOf(lockedRating);
+ if (targetMode >= 0 && window.activeMode !== targetMode) {
+ window.activeMode = targetMode;
+ document.cookie = `mode=${targetMode}; Path=/; Max-Age=31536000; SameSite=Lax`;
+ changed = true;
+ }
+ const raw = getRatingsCookie();
+ if (raw.length !== 1 || raw[0] !== lockedRating) {
+ setRatingsCookie([lockedRating]);
+ changed = true;
+ }
+ } else {
+ // Validate activeMode
+ const curModeName = modeNames[window.activeMode] || 'sfw';
+ if (!canFilter || !allowedModes.includes(curModeName)) {
+ const fallbackName = allowedModes.find(m => modeNames.includes(m)) || 'sfw';
+ const fallbackIdx = modeNames.indexOf(fallbackName);
+ window.activeMode = fallbackIdx >= 0 ? fallbackIdx : 0;
+ document.cookie = `mode=${window.activeMode}; Path=/; Max-Age=31536000; SameSite=Lax`;
+ changed = true;
+ }
+
+ // Validate ratings cookie
+ const curRatings = getRatingsCookie();
+ if (curRatings.length > 0) {
+ if (!canFilter) {
+ clearRatingsCookie();
+ changed = true;
+ } else {
+ const filtered = curRatings.filter(r => allowedModes.includes(r));
+ if (filtered.length !== curRatings.length) {
+ changed = true;
+ if (filtered.length > 0) {
+ setRatingsCookie(filtered);
+ } else {
+ if (allowedModes.length === 1 && !allowedModes.includes('all')) {
+ setRatingsCookie([allowedModes[0]]);
+ } else {
+ clearRatingsCookie();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 2. Validate MIME cookie
+ const cookieMimeRaw = document.cookie.split('; ').find(row => row.startsWith('mime='));
+ const isSingleMime = Array.isArray(allowedMimes) && allowedMimes.length === 1;
+ const singleMime = isSingleMime ? allowedMimes[0] : null;
+
+ if (isSingleMime) {
+ const curVal = cookieMimeRaw ? cookieMimeRaw.split('=')[1] : null;
+ if (curVal !== singleMime) {
+ document.cookie = `mime=${singleMime}; path=/; max-age=31536000; SameSite=Lax`;
+ changed = true;
+ }
+ } else if (cookieMimeRaw) {
+ const curVal = cookieMimeRaw.split('=')[1] || '';
+ const curParts = curVal ? curVal.split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)) : [];
+ if (!canFilter) {
+ if (curVal !== '') {
+ document.cookie = 'mime=; path=/; max-age=0; SameSite=Lax';
+ changed = true;
+ }
+ } else {
+ const filteredParts = curParts.filter(m => allowedMimes.includes(m));
+ if (filteredParts.length !== curParts.length) {
+ changed = true;
+ if (filteredParts.length > 0) {
+ document.cookie = `mime=${filteredParts.join(',')}; path=/; max-age=31536000; SameSite=Lax`;
+ } else {
+ document.cookie = 'mime=; path=/; max-age=0; SameSite=Lax';
+ }
+ }
+ }
+ }
+
+ return changed;
+ };
+ window.reconcileAnonFilterPermissions = reconcileAnonFilterPermissions;
+
const updateMimeLabel = () => {
- const isAnon = !!(window.f0ckSession?.is_anon);
+ reconcileAnonFilterPermissions();
+ const isAnon = isAnonUser();
const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1;
const singleMime = isSingleMime ? allowedMimes[0] : null;
@@ -622,7 +805,15 @@ window.cancelAnimFrame = (function () {
}
selected = mimeStr ? mimeStr.split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m)) : [];
if (isAnon && Array.isArray(allowedMimes) && allowedMimes.length > 0) {
- selected = selected.filter(m => allowedMimes.includes(m));
+ const filtered = selected.filter(m => allowedMimes.includes(m));
+ if (filtered.length !== selected.length) {
+ selected = filtered;
+ if (selected.length > 0) {
+ document.cookie = `mime=${selected.join(',')}; path=/; max-age=31536000; SameSite=Lax`;
+ } else {
+ document.cookie = `mime=; path=/; max-age=0; SameSite=Lax`;
+ }
+ }
}
}
@@ -659,6 +850,7 @@ window.cancelAnimFrame = (function () {
window.updateMimeLabel = updateMimeLabel;
const updateFilterBadge = () => {
+ reconcileAnonFilterPermissions();
const badge = document.getElementById('nav-filter-badge');
if (!badge) return;
@@ -809,7 +1001,7 @@ window.cancelAnimFrame = (function () {
// this device could disagree (e.g. PC had ALL, mobile set SFW — PC cookie still says 3).
// Fix: prefer f0ckSession.mode when logged in and reconcile the local cookie to match.
// For guests: fall back to cookie (no session = cookie is the only persistence).
- if (window.f0ckSession && window.f0ckSession.logged_in && window.f0ckSession.mode !== undefined) {
+ if (window.f0ckSession && window.f0ckSession.logged_in && !window.f0ckSession.is_anon && window.f0ckSession.mode !== undefined) {
window.activeMode = +window.f0ckSession.mode;
// Reconcile the local mode cookie so future reads are consistent on this device
document.cookie = `mode=${window.activeMode}; Path=/; Max-Age=31536000`;
@@ -834,6 +1026,13 @@ window.cancelAnimFrame = (function () {
if (modeMatch) window.activeMode = +modeMatch[1];
}
}
+ const filterReconciled = reconcileAnonFilterPermissions();
+ if (filterReconciled) {
+ const postsEl = document.querySelector('.posts, .tags-grid');
+ if (postsEl && postsEl.children.length === 0 && window.loadPageAjax) {
+ window.loadPageAjax(window.location.href, { skipPush: true, skipCache: true });
+ }
+ }
}
// Track active mode on for notification thumbnail blur.
@@ -904,28 +1103,8 @@ window.cancelAnimFrame = (function () {
document.addEventListener('f0ck:contentLoaded', _applyNotifBlurOnNav);
// ---- Multi-select Rating Toggles ----
- // Reads/writes a `ratings` cookie (e.g. "sfw|untagged") and syncs with server via /mode/3 (ALL).
- const getRatingsCookie = () => {
- const raw = document.cookie.split('; ').find(r => r.startsWith('ratings='));
- if (!raw) return [];
- const val = raw.split('=').slice(1).join('='); // handle any = in value
- // Support both | separator (new) and , (legacy/encoded)
- const decoded = decodeURIComponent(val);
- const parts = decoded.includes('|') ? decoded.split('|') : decoded.split(',');
- return parts.filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r));
- };
- window.getRatingsCookie = getRatingsCookie;
-
- const setRatingsCookie = (ratings) => {
- const val = ratings.join('|'); // Use | separator — no encoding needed, no ambiguity
- document.cookie = `ratings=${val}; Path=/; Max-Age=31536000; SameSite=Lax`;
- };
-
- const clearRatingsCookie = () => {
- document.cookie = 'ratings=; Path=/; Max-Age=0';
- };
-
const syncRatingButtonUI = () => {
+ reconcileAnonFilterPermissions();
let activeRatings = getRatingsCookie();
if (activeRatings.length === 0) {
if (window.activeMode === 0) activeRatings = ['sfw'];
@@ -935,10 +1114,64 @@ window.cancelAnimFrame = (function () {
}
const selector = document.getElementById('rating-selector');
if (!selector) return;
- const lockedRating = selector.dataset.lockedRating;
- if (lockedRating) {
+ const isGuest = isGuestUser();
+ if (isGuest) {
+ selector.classList.add('locked');
+ selector.dataset.guestLocked = 'true';
+ selector.dataset.lockedRating = 'sfw';
selector.querySelectorAll('.rating-toggle-btn').forEach(btn => {
- if (btn.dataset.rating === lockedRating) {
+ if (btn.dataset.rating === 'sfw') {
+ btn.classList.add('active', 'locked');
+ btn.disabled = true;
+ btn.style.cursor = 'default';
+ btn.style.opacity = '1';
+ btn.style.display = '';
+ if (!btn.querySelector('.fa-lock')) {
+ const lockIcon = document.createElement('i');
+ lockIcon.className = 'fa-solid fa-lock';
+ lockIcon.style.marginLeft = '5px';
+ lockIcon.style.fontSize = '0.75em';
+ lockIcon.style.opacity = '0.7';
+ btn.appendChild(lockIcon);
+ }
+ } else {
+ btn.classList.remove('active');
+ btn.classList.add('locked');
+ btn.disabled = true;
+ btn.style.cursor = 'not-allowed';
+ btn.style.opacity = '0.45';
+ btn.style.display = '';
+ btn.title = (window.f0ckI18n && window.f0ckI18n.members_only) || 'Only available for members';
+ if (!btn.querySelector('.fa-lock')) {
+ const lockIcon = document.createElement('i');
+ lockIcon.className = 'fa-solid fa-lock';
+ lockIcon.style.marginLeft = '5px';
+ lockIcon.style.fontSize = '0.75em';
+ lockIcon.style.opacity = '0.7';
+ btn.appendChild(lockIcon);
+ }
+ }
+ });
+ return;
+ }
+
+ // User is NOT a guest (anonymous user or registered member)
+ delete selector.dataset.guestLocked;
+ selector.removeAttribute('data-guest-locked');
+
+ // Check single allowed mode restrictions
+ const anonPerms = window.f0ckSession?.anon_permissions;
+ const allowedModes = Array.isArray(anonPerms?.allowed_modes)
+ ? anonPerms.allowed_modes.map(m => String(m).toLowerCase())
+ : ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
+ const isSingleAllowedMode = isAnonUser() && allowedModes.length === 1 && !allowedModes.includes('all');
+ const singleLockedMode = isSingleAllowedMode ? allowedModes[0] : null;
+
+ if (singleLockedMode) {
+ selector.classList.add('locked');
+ selector.dataset.lockedRating = singleLockedMode;
+ selector.querySelectorAll('.rating-toggle-btn').forEach(btn => {
+ if (btn.dataset.rating === singleLockedMode) {
btn.classList.add('active', 'locked');
btn.disabled = true;
btn.style.cursor = 'not-allowed';
@@ -959,15 +1192,40 @@ window.cancelAnimFrame = (function () {
});
return;
}
+
+ // User is NOT locked into a single mode: unlock container and buttons
+ selector.classList.remove('locked');
+ delete selector.dataset.lockedRating;
+ selector.removeAttribute('data-locked-rating');
+
selector.querySelectorAll('.rating-toggle-btn').forEach(btn => {
const r = btn.dataset.rating;
- if (!r) return; // ALL button handled separately
- btn.classList.toggle('active', activeRatings.includes(r));
+ btn.classList.remove('locked');
+ btn.disabled = false;
+ btn.style.cursor = '';
+ btn.style.opacity = '';
+ btn.style.display = '';
+ btn.querySelectorAll('.fa-lock').forEach(icon => icon.remove());
+ if (r) {
+ btn.classList.toggle('active', activeRatings.includes(r));
+ if (btn.title === 'Only available for members' || (window.f0ckI18n && btn.title === window.f0ckI18n.members_only)) {
+ btn.title = r.toUpperCase();
+ }
+ }
});
+
// ALL button: active when ratings cookie is empty/absent (server mode is the authority)
const allBtn = document.getElementById('rating-btn-all');
if (allBtn) {
+ allBtn.classList.remove('locked');
+ allBtn.disabled = false;
+ allBtn.style.cursor = '';
+ allBtn.style.opacity = '';
+ allBtn.querySelectorAll('.fa-lock').forEach(icon => icon.remove());
allBtn.classList.toggle('active', (getRatingsCookie().length === 0 && window.activeMode === 3) || activeRatings.length === 0);
+ if (allBtn.title === 'Only available for members' || (window.f0ckI18n && allBtn.title === window.f0ckI18n.members_only)) {
+ allBtn.title = 'Show All Ratings';
+ }
}
};
@@ -975,7 +1233,7 @@ window.cancelAnimFrame = (function () {
document.addEventListener('click', (e) => {
const btn = e.target.closest('.rating-toggle-btn');
if (!btn) return;
- if (btn.disabled || btn.classList.contains('locked') || btn.closest('#rating-selector[data-locked-rating]') || btn.closest('#rating-selector.locked')) {
+ if (isGuestUser() || btn.disabled || btn.classList.contains('locked') || btn.closest('#rating-selector[data-locked-rating]') || btn.closest('#rating-selector[data-guest-locked]') || btn.closest('#rating-selector.locked')) {
e.preventDefault();
e.stopPropagation();
return;
@@ -1109,6 +1367,19 @@ window.cancelAnimFrame = (function () {
syncRatingButtonUI();
+ window.addEventListener('f0ck:anon_session_ready', (e) => {
+ if (window.f0ckSession) {
+ window.f0ckSession.user = 'anonymous';
+ window.f0ckSession.is_anon = true;
+ window.f0ckSession.logged_in = true;
+ if (e.detail?.userId) {
+ window.f0ckSession.id = e.detail.userId;
+ window.f0ckSession.user_id = e.detail.userId;
+ }
+ }
+ syncRatingButtonUI();
+ });
+
// Cleanup strict param from URL bar on initial load if present (legacy or external link)
if (window.location.search.includes('strict=1')) {
const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]strict=1/, '').replace(/[?&]$/, '') + window.location.hash;
@@ -5681,7 +5952,10 @@ window.cancelAnimFrame = (function () {
const ratingEl = targetEl ? (targetEl.closest('.rating-tag') || targetEl.closest('button#a_toggle') || targetEl) : document.querySelector('.rating-tag.can-cycle, button#a_toggle');
if (!ratingEl) return;
- const itemIdStr = explicitPostId || ratingEl.dataset.itemId || window.getCurrentItemId();
+ const containerEl = ratingEl.closest('.item-layout-container, #tags, #onara-item-mount, .item-view');
+ const containerId = containerEl?.dataset?.itemId
+ || containerEl?.querySelector('a.id-link[data-item-id], #comments-container[data-item-id]')?.dataset?.itemId;
+ const itemIdStr = explicitPostId || ratingEl.dataset.itemId || containerId || window.getCurrentItemId();
if (!itemIdStr) return;
const postid = parseInt(itemIdStr, 10);
if (isNaN(postid)) return;
@@ -5737,6 +6011,7 @@ window.cancelAnimFrame = (function () {
// Optimistically apply new state to rating tag in-place (no full-DOM recreation)
ratingEl.className = `badge ${info.badgeCls} rating-tag ${info.isCls} can-cycle`;
ratingEl.dataset.rating = nextRating;
+ ratingEl.dataset.itemId = String(postid);
ratingEl.removeAttribute('title');
ratingEl.removeAttribute('tooltip');
ratingEl.removeAttribute('flow');
@@ -5766,6 +6041,21 @@ window.cancelAnimFrame = (function () {
mediaObj.dataset.mode = nextRating;
}
+ // Optimistically update grid thumbnail(s) data-mode so div.posts > a > p::before updates immediately
+ const modeAttr = (nextRating === 'untagged' || !nextRating) ? 'null' : nextRating;
+ const findMatchingThumbs = () => {
+ const thumbs = new Set();
+ document.querySelectorAll(`.posts > a.thumb[data-item-id="${postid}"], .posts a[data-item-id="${postid}"], .posts a[href$="/${postid}"]`).forEach(t => thumbs.add(t));
+ if (document.body.classList.contains('onara-modal-open')) {
+ const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
+ if (onaraThumb) thumbs.add(onaraThumb);
+ }
+ return Array.from(thumbs);
+ };
+ const targetThumbs = findMatchingThumbs();
+ const oldThumbModes = targetThumbs.map(t => ({ el: t, mode: t.getAttribute('data-mode') }));
+ targetThumbs.forEach(t => t.setAttribute('data-mode', modeAttr));
+
const flashMsg = (window.f0ckI18n && window.f0ckI18n.mode_activated && window.f0ckI18n.mode_activated.replace('{mode}', nextLabel)) || ('RATING UPDATED: ' + nextLabel);
window.flashMessage(flashMsg);
@@ -5817,6 +6107,8 @@ window.cancelAnimFrame = (function () {
if (curMedia && res.rating) {
curMedia.dataset.mode = res.rating;
}
+ const confirmedMode = (res.rating === 'untagged' || !res.rating) ? 'null' : res.rating;
+ findMatchingThumbs().forEach(t => t.setAttribute('data-mode', confirmedMode));
} else {
revert();
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
@@ -5842,6 +6134,10 @@ window.cancelAnimFrame = (function () {
oldClasses.forEach(cls => ratingEl.classList.add(cls));
ratingEl.innerHTML = oldInnerHTML;
if (oldDatasetRating !== undefined) ratingEl.dataset.rating = oldDatasetRating;
+ oldThumbModes.forEach(({ el, mode }) => {
+ if (mode !== null) el.setAttribute('data-mode', mode);
+ else el.removeAttribute('data-mode');
+ });
}
};
@@ -9269,7 +9565,7 @@ class NotificationSystem {
}
// If currently viewing this item, navigate to next item using soft AJAX nav
- const currentItemId = window.currentItemId || document.querySelector('[data-item-id]')?.dataset?.itemId;
+ const currentItemId = (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null) || window.currentItemId;
if (currentItemId && +currentItemId === delId) {
// Skip navigation if the current user is the admin/mod who deleted this item
// (admin.js sets this flag immediately on delete, and shows a "deleted" placeholder)
@@ -10616,6 +10912,19 @@ class NotificationSystem {
allRatingBadges[i].remove();
}
}
+
+ // Also update data-mode on grid thumbnail(s) so div.posts > a > p::before updates live
+ const rTag = cleanTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
+ const sseMode = rTag ? rTag.normalized : 'null';
+ document.querySelectorAll(`.posts > a.thumb[data-item-id="${data.item_id}"], .posts a[data-item-id="${data.item_id}"], .posts a[href$="/${data.item_id}"]`).forEach(th => {
+ th.setAttribute('data-mode', sseMode);
+ });
+ if (document.body.classList.contains('onara-modal-open')) {
+ const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
+ if (onaraThumb && (onaraThumb.dataset.itemId === String(data.item_id) || onaraThumb.getAttribute('href')?.endsWith(`/${data.item_id}`))) {
+ onaraThumb.setAttribute('data-mode', sseMode);
+ }
+ }
}
}
@@ -11117,7 +11426,7 @@ document.addEventListener("DOMContentLoaded", function () {
// Multi-Select MIME Logic
const handleMimeChange = (menu) => {
- const isAnon = !!(window.f0ckSession?.is_anon);
+ const isAnon = isAnonUser();
const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1;
if (isSingleMime) {
@@ -11190,7 +11499,7 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('.nav-mime-menu').forEach(menu => {
menu.addEventListener('change', (e) => {
- const isAnon = !!(window.f0ckSession?.is_anon);
+ const isAnon = isAnonUser();
const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1;
if (isSingleMime) {
@@ -11206,7 +11515,7 @@ document.addEventListener("DOMContentLoaded", function () {
// Prevent menu closure on click inside
menu.addEventListener('click', (e) => {
- const isAnon = !!(window.f0ckSession?.is_anon);
+ const isAnon = isAnonUser();
const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1;
if (isSingleMime) {
@@ -11223,7 +11532,7 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('.nav-mime-clear').forEach(btn => {
btn.addEventListener('click', (e) => {
- const isAnon = !!(window.f0ckSession?.is_anon);
+ const isAnon = isAnonUser();
const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1;
if (isSingleMime) {
@@ -14490,8 +14799,8 @@ document.addEventListener('keydown', (e) => {
f0ckInterestEngine.flushCurrentTracking();
// Only track if on an item page
- const itemIdEl = document.querySelector('[data-item-id]');
- const itemId = itemIdEl ? parseInt(itemIdEl.dataset.itemId, 10) : null;
+ const currentIdStr = typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null;
+ const itemId = currentIdStr ? parseInt(currentIdStr, 10) : null;
if (!itemId) return;
const tags = Array.from(document.querySelectorAll('#tags .tag-text, #tags .tag-badge, .tag-link'))
@@ -14542,3 +14851,730 @@ document.addEventListener('keydown', (e) => {
document.addEventListener('DOMContentLoaded', () => setTimeout(f0ckInterestEngine.startTrackingItem, 200));
}
})();
+
+// ==========================================================================
+// BulkSelection: Multi-item selection via Mouse Long-Press, Touch Long-Press,
+// and Shift+Click with Bulk Actions Toolbar & Tagging Modal
+// ==========================================================================
+window.BulkSelection = (() => {
+ const selectedIds = new Set();
+ let isSelectionMode = false;
+ let lastSelectedThumb = null;
+ let suppressNextClick = false;
+ let currentModalAction = 'add'; // 'add' or 'remove'
+ let activeTagChips = [];
+
+ function getCsrfToken() {
+ return window.f0ckSession?.csrf_token ||
+ document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
+ document.querySelector('meta[name="csrf"]')?.getAttribute('content') || '';
+ }
+
+ function getItemId(thumb) {
+ if (!thumb) return null;
+ const idStr = thumb.dataset.itemId || thumb.getAttribute('data-item-id') ||
+ (thumb.dataset.bg && thumb.dataset.bg.match(/\/t\/(\d+)/)?.[1]) ||
+ (thumb.href && thumb.href.match(/\/(\d+)(?:[/?#]|$)/)?.[1]);
+ const id = idStr ? parseInt(idStr, 10) : null;
+ return (id && !isNaN(id)) ? id : null;
+ }
+
+ function flash(msg, type = 'info') {
+ if (typeof window.flashMessage === 'function') {
+ window.flashMessage(msg);
+ } else if (typeof window.showFlash === 'function') {
+ window.showFlash(msg, type);
+ } else {
+ console.log(`[BULK] ${msg}`);
+ }
+ }
+
+ function isModOrAdmin() {
+ return !!(
+ window.f0ckSession?.is_admin ||
+ window.f0ckSession?.is_moderator ||
+ window.f0ckSession?.admin
+ );
+ }
+
+ // --- UI Injections ---
+ function ensureDOMElements() {
+ if (!isModOrAdmin()) return;
+ if (document.getElementById('bulk-action-bar')) return;
+
+ // 1. Floating Action Bar
+ const bar = document.createElement('div');
+ bar.id = 'bulk-action-bar';
+ bar.innerHTML = `
+
+
+ 0
+ selected
+
+
+
+
+ Add Tags
+
+
+
+ Remove Tags
+
+
+
+
+ Rating
+
+
+
+
+
+
+ Select All
+
+
+
+
+
+ `;
+ document.body.appendChild(bar);
+
+ // 2. Bulk Tags Modal
+ const modal = document.createElement('div');
+ modal.id = 'bulk-tag-modal';
+ modal.className = 'bulk-modal-overlay';
+ modal.innerHTML = `
+
+
+
Add tags to selected items
+
+
+
+
+
+
+ Cancel
+ Apply to Items
+
+
+ `;
+ document.body.appendChild(modal);
+
+ // 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');
+ });
+
+ // Rating items click
+ rateMenu?.querySelectorAll('.bulk-menu-item').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const rating = btn.dataset.rating;
+ rateMenu.classList.remove('show');
+ if (rating) await executeBulkRating(rating);
+ });
+ });
+
+ document.getElementById('bulk-btn-select-all')?.addEventListener('click', () => selectAllLoaded());
+ document.getElementById('bulk-btn-close')?.addEventListener('click', () => exitSelectionMode());
+
+ // Wire up Modal events
+ document.getElementById('bulk-modal-close-btn')?.addEventListener('click', () => closeTagModal());
+ document.getElementById('bulk-modal-cancel-btn')?.addEventListener('click', () => closeTagModal());
+ document.getElementById('bulk-modal-submit-btn')?.addEventListener('click', () => submitBulkTags());
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeTagModal();
+ });
+
+ setupTagInputAutocomplete();
+ }
+
+ // --- Selection Mode Lifecycle ---
+ function enterSelectionMode() {
+ if (!isModOrAdmin()) return;
+ isSelectionMode = true;
+ document.documentElement.classList.add('bulk-selection-active');
+ ensureDOMElements();
+ const bar = document.getElementById('bulk-action-bar');
+ if (bar) {
+ bar.classList.add('visible');
+ }
+ }
+
+ function exitSelectionMode() {
+ isSelectionMode = false;
+ document.documentElement.classList.remove('bulk-selection-active');
+ document.querySelectorAll('.posts > a.thumb.bulk-selected, .posts a.bulk-selected').forEach(th => th.classList.remove('bulk-selected'));
+ selectedIds.clear();
+ lastSelectedThumb = null;
+ const bar = document.getElementById('bulk-action-bar');
+ if (bar) {
+ bar.classList.remove('visible');
+ }
+ document.getElementById('bulk-rating-menu')?.classList.remove('show');
+ 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();
+ } else if (count === 0 && isSelectionMode) {
+ exitSelectionMode();
+ }
+ }
+
+ function toggleThumb(thumb, forceState = null) {
+ if (!thumb) return;
+ const itemId = getItemId(thumb);
+ if (!itemId) return;
+
+ const currentlySelected = selectedIds.has(itemId);
+ const shouldSelect = forceState !== null ? forceState : !currentlySelected;
+
+ if (shouldSelect) {
+ selectedIds.add(itemId);
+ thumb.classList.add('bulk-selected');
+ } else {
+ selectedIds.delete(itemId);
+ thumb.classList.remove('bulk-selected');
+ }
+
+ lastSelectedThumb = thumb;
+ updateToolbar();
+ }
+
+ function rangeSelect(fromThumb, toThumb) {
+ const allThumbs = Array.from(document.querySelectorAll('.posts > a.thumb, .posts > a.lazy-thumb'));
+ const idx1 = allThumbs.indexOf(fromThumb);
+ const idx2 = allThumbs.indexOf(toThumb);
+ if (idx1 === -1 || idx2 === -1) {
+ toggleThumb(toThumb);
+ return;
+ }
+ const start = Math.min(idx1, idx2);
+ const end = Math.max(idx1, idx2);
+ for (let i = start; i <= end; i++) {
+ const th = allThumbs[i];
+ const id = getItemId(th);
+ if (id) {
+ selectedIds.add(id);
+ th.classList.add('bulk-selected');
+ }
+ }
+ lastSelectedThumb = toThumb;
+ updateToolbar();
+ }
+
+ function selectAllLoaded() {
+ const allThumbs = Array.from(document.querySelectorAll('.posts > a.thumb, .posts > a.lazy-thumb'));
+ allThumbs.forEach(th => {
+ const id = getItemId(th);
+ if (id) {
+ selectedIds.add(id);
+ th.classList.add('bulk-selected');
+ }
+ });
+ if (!isSelectionMode && selectedIds.size > 0) enterSelectionMode();
+ updateToolbar();
+ }
+
+ // --- Tag Modal & Autocomplete ---
+ function openTagModal(action = 'add') {
+ if (selectedIds.size === 0) return;
+ currentModalAction = action;
+ activeTagChips = [];
+ ensureDOMElements();
+
+ const modal = document.getElementById('bulk-tag-modal');
+ const title = document.getElementById('bulk-modal-title');
+ const desc = document.getElementById('bulk-modal-desc');
+ const submitBtn = document.getElementById('bulk-modal-submit-btn');
+ const input = document.getElementById('bulk-tag-input');
+ const count = selectedIds.size;
+
+ if (action === 'add') {
+ title.innerHTML = ' Add Tags in Bulk';
+ desc.textContent = `Add tags to all ${count} selected item${count === 1 ? '' : 's'}. Type a tag name and hit Enter.`;
+ submitBtn.textContent = `Add Tags to ${count} Item${count === 1 ? '' : 's'}`;
+ } else {
+ title.innerHTML = ' Remove Tags in Bulk';
+ desc.textContent = `Remove tags from all ${count} selected item${count === 1 ? '' : 's'}.`;
+ submitBtn.textContent = `Remove Tags from ${count} Item${count === 1 ? '' : 's'}`;
+ }
+
+ renderTagChips();
+ if (input) input.value = '';
+ document.getElementById('bulk-rating-menu')?.classList.remove('show');
+
+ modal?.classList.add('visible');
+ setTimeout(() => input?.focus(), 80);
+ }
+
+ function closeTagModal() {
+ const modal = document.getElementById('bulk-tag-modal');
+ modal?.classList.remove('visible');
+ const suggestions = document.getElementById('bulk-tag-suggestions');
+ if (suggestions) suggestions.style.display = 'none';
+ }
+
+ function renderTagChips() {
+ const wrap = document.getElementById('bulk-tag-chips');
+ if (!wrap) return;
+ wrap.innerHTML = '';
+ activeTagChips.forEach((tag, idx) => {
+ const chip = document.createElement('span');
+ chip.className = 'bulk-tag-chip';
+ chip.innerHTML = `${escapeHtml(tag)} `;
+ wrap.appendChild(chip);
+ });
+
+ wrap.querySelectorAll('.remove-chip-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const idx = +btn.dataset.idx;
+ activeTagChips.splice(idx, 1);
+ renderTagChips();
+ });
+ });
+ }
+
+ function addChip(raw) {
+ const tag = (raw || '').trim().replace(/^#/, '');
+ if (!tag) return;
+ const protectedTags = ['sfw', 'nsfw', 'nsfl'];
+ if (protectedTags.includes(tag.toLowerCase())) {
+ flash(`"${tag}" is reserved for rating modes`, 'error');
+ return;
+ }
+ if (!activeTagChips.map(t => t.toLowerCase()).includes(tag.toLowerCase())) {
+ activeTagChips.push(tag);
+ renderTagChips();
+ }
+ const input = document.getElementById('bulk-tag-input');
+ if (input) input.value = '';
+ const suggestions = document.getElementById('bulk-tag-suggestions');
+ if (suggestions) suggestions.style.display = 'none';
+ }
+
+ function escapeHtml(str) {
+ return (str || '').replace(/[&<>"']/g, m => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ })[m]);
+ }
+
+ function setupTagInputAutocomplete() {
+ const input = document.getElementById('bulk-tag-input');
+ const suggestions = document.getElementById('bulk-tag-suggestions');
+ if (!input || !suggestions) return;
+
+ let debounceTimer = null;
+ let highlightedIndex = -1;
+ let currentSuggestions = [];
+
+ const fetchSuggestions = (query) => {
+ if (!query || query.length < 1) {
+ suggestions.style.display = 'none';
+ return;
+ }
+ fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`)
+ .then(r => r.json())
+ .then(items => {
+ if (!Array.isArray(items) || items.length === 0) {
+ suggestions.style.display = 'none';
+ currentSuggestions = [];
+ return;
+ }
+ currentSuggestions = items;
+ highlightedIndex = -1;
+ suggestions.innerHTML = '';
+ items.slice(0, 10).forEach((item, idx) => {
+ const div = document.createElement('div');
+ div.className = 'bulk-suggestion-item';
+ div.innerHTML = `#${escapeHtml(item.tag)} ${item.count || ''} `;
+ div.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ addChip(item.tag);
+ input.focus();
+ });
+ suggestions.appendChild(div);
+ });
+ suggestions.style.display = 'block';
+ })
+ .catch(() => { suggestions.style.display = 'none'; });
+ };
+
+ input.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ const val = input.value;
+ if (val.includes(',') || val.includes('\n')) {
+ const parts = val.split(/[,\n]+/);
+ parts.forEach(p => addChip(p));
+ input.value = '';
+ suggestions.style.display = 'none';
+ return;
+ }
+ debounceTimer = setTimeout(() => fetchSuggestions(val.trim()), 200);
+ });
+
+ input.addEventListener('keydown', (e) => {
+ const items = suggestions.querySelectorAll('.bulk-suggestion-item');
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (items.length > 0) {
+ highlightedIndex = (highlightedIndex + 1) % items.length;
+ items.forEach((it, i) => it.classList.toggle('highlighted', i === highlightedIndex));
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (items.length > 0) {
+ highlightedIndex = (highlightedIndex - 1 + items.length) % items.length;
+ items.forEach((it, i) => it.classList.toggle('highlighted', i === highlightedIndex));
+ }
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (highlightedIndex >= 0 && items[highlightedIndex] && currentSuggestions[highlightedIndex]) {
+ addChip(currentSuggestions[highlightedIndex].tag);
+ } else if (input.value.trim().length > 0) {
+ addChip(input.value.trim());
+ } else if (activeTagChips.length > 0) {
+ submitBulkTags();
+ }
+ } else if (e.key === 'Backspace' && input.value === '' && activeTagChips.length > 0) {
+ activeTagChips.pop();
+ renderTagChips();
+ } else if (e.key === 'Escape') {
+ closeTagModal();
+ }
+ });
+
+ input.addEventListener('blur', () => {
+ setTimeout(() => { suggestions.style.display = 'none'; }, 200);
+ });
+ }
+
+ // --- API Execution ---
+ async function submitBulkTags() {
+ const input = document.getElementById('bulk-tag-input');
+ if (input && input.value.trim()) {
+ addChip(input.value.trim());
+ }
+
+ if (activeTagChips.length === 0) {
+ flash('Please enter at least one tag', 'error');
+ return;
+ }
+
+ const itemIds = Array.from(selectedIds);
+ if (itemIds.length === 0) {
+ flash('No items selected', 'error');
+ return;
+ }
+
+ const submitBtn = document.getElementById('bulk-modal-submit-btn');
+ if (submitBtn) {
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Applying...';
+ }
+
+ try {
+ const res = await fetch('/api/v2/tags/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrfToken()
+ },
+ body: JSON.stringify({
+ action: currentModalAction,
+ item_ids: itemIds,
+ tags: activeTagChips
+ })
+ });
+ const data = await res.json();
+ if (data.success) {
+ flash(data.msg || `Applied tags to ${itemIds.length} items!`);
+ closeTagModal();
+ exitSelectionMode();
+ } else {
+ flash(data.msg || 'Failed to apply tags', 'error');
+ }
+ } catch (err) {
+ console.error('[BULK_TAG_SUBMIT_ERROR]', err);
+ flash('Failed to communicate with server', 'error');
+ } finally {
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.innerHTML = 'Apply to Items';
+ }
+ }
+ }
+
+ async function executeBulkRating(rating) {
+ const itemIds = Array.from(selectedIds);
+ if (itemIds.length === 0) return;
+
+ try {
+ const res = await fetch('/api/v2/tags/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrfToken()
+ },
+ body: JSON.stringify({
+ action: 'set_rating',
+ item_ids: itemIds,
+ rating: rating
+ })
+ });
+ const data = await res.json();
+ if (data.success) {
+ // Update DOM data-mode attribute on selected thumbs immediately
+ itemIds.forEach(id => {
+ const thumb = document.querySelector(`.posts > a.thumb[data-item-id="${id}"], .posts a[href$="/${id}"]`);
+ if (thumb) {
+ thumb.setAttribute('data-mode', rating);
+ }
+ });
+ flash(data.msg || `Updated rating to ${rating.toUpperCase()}!`);
+ exitSelectionMode();
+ } else {
+ flash(data.msg || 'Failed to update rating', 'error');
+ }
+ } catch (err) {
+ console.error('[BULK_RATING_ERROR]', err);
+ flash('Failed to communicate with server', 'error');
+ }
+ }
+
+ // --- Event Listeners Initialization ---
+ function init() {
+ ensureDOMElements();
+
+ // 1. Mouse Long-Press (Left Button Hold for ~600ms)
+ let mouseHoldTimer = null;
+ let holdThumb = null;
+ let startX = 0, startY = 0;
+
+ document.addEventListener('mousedown', (e) => {
+ if (!isModOrAdmin()) return;
+ if (e.button !== 0) return;
+ const thumb = e.target.closest('.posts > a.thumb, .posts > a.lazy-thumb');
+ if (!thumb) return;
+ if (e.target.closest('.hide-thumb-btn, .thumb-select-check, button, input')) return;
+
+ holdThumb = thumb;
+ startX = e.clientX;
+ startY = e.clientY;
+ thumb.classList.add('thumb-longpressing');
+
+ if (mouseHoldTimer) clearTimeout(mouseHoldTimer);
+ mouseHoldTimer = setTimeout(() => {
+ mouseHoldTimer = null;
+ if (holdThumb) {
+ holdThumb.classList.remove('thumb-longpressing');
+ suppressNextClick = true;
+ if (typeof window.clearHoverPreview === 'function') {
+ window.clearHoverPreview();
+ }
+ if (navigator.vibrate) try { navigator.vibrate(45); } catch {}
+ if (!isSelectionMode) enterSelectionMode();
+ toggleThumb(holdThumb);
+ holdThumb = null;
+ }
+ }, 600);
+ });
+
+ const cancelMouseHold = (e) => {
+ if (!mouseHoldTimer && !holdThumb) return;
+ if (e.type === 'mousemove' && mouseHoldTimer) {
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) <= 8) return;
+ }
+ if (mouseHoldTimer) {
+ clearTimeout(mouseHoldTimer);
+ mouseHoldTimer = null;
+ }
+ if (holdThumb) {
+ holdThumb.classList.remove('thumb-longpressing');
+ holdThumb = null;
+ }
+ };
+ document.addEventListener('mousemove', cancelMouseHold);
+ document.addEventListener('mouseup', cancelMouseHold);
+
+ // 2. Touch Long-Press (Phone / Mobile Hold for ~600ms)
+ let touchHoldTimer = null;
+ let touchHoldThumb = null;
+ let touchStartX = 0, touchStartY = 0;
+
+ document.addEventListener('touchstart', (e) => {
+ if (!isModOrAdmin()) return;
+ if (e.touches.length !== 1) return;
+ const thumb = e.target.closest('.posts > a.thumb, .posts > a.lazy-thumb');
+ if (!thumb) return;
+ if (e.target.closest('.hide-thumb-btn, .thumb-select-check, button, input')) return;
+
+ touchHoldThumb = thumb;
+ touchStartX = e.touches[0].clientX;
+ touchStartY = e.touches[0].clientY;
+ thumb.classList.add('thumb-longpressing');
+
+ if (touchHoldTimer) clearTimeout(touchHoldTimer);
+ touchHoldTimer = setTimeout(() => {
+ touchHoldTimer = null;
+ if (touchHoldThumb) {
+ touchHoldThumb.classList.remove('thumb-longpressing');
+ suppressNextClick = true;
+ if (typeof window.clearHoverPreview === 'function') {
+ window.clearHoverPreview();
+ }
+ if (navigator.vibrate) try { navigator.vibrate(50); } catch {}
+ if (!isSelectionMode) enterSelectionMode();
+ toggleThumb(touchHoldThumb);
+ touchHoldThumb = null;
+ }
+ }, 600);
+ }, { passive: true });
+
+ const cancelTouchHold = (e) => {
+ if (!touchHoldTimer && !touchHoldThumb) return;
+ if (e.type === 'touchmove' && touchHoldTimer && e.touches.length === 1) {
+ if (Math.hypot(e.touches[0].clientX - touchStartX, e.touches[0].clientY - touchStartY) <= 10) return;
+ }
+ if (touchHoldTimer) {
+ clearTimeout(touchHoldTimer);
+ touchHoldTimer = null;
+ }
+ if (touchHoldThumb) {
+ touchHoldThumb.classList.remove('thumb-longpressing');
+ touchHoldThumb = null;
+ }
+ };
+ document.addEventListener('touchmove', cancelTouchHold, { passive: true });
+ document.addEventListener('touchend', cancelTouchHold, { passive: true });
+ document.addEventListener('touchcancel', cancelTouchHold, { passive: true });
+
+ // Prevent context menu from interrupting selection or after long press
+ document.addEventListener('contextmenu', (e) => {
+ if (!isModOrAdmin()) return;
+ if (e.target.closest('.posts > a.thumb, .posts > a.lazy-thumb')) {
+ if (suppressNextClick || isSelectionMode) {
+ e.preventDefault();
+ }
+ }
+ });
+
+ // 3. Click Interception (Shift+Click, Long-press trailing click, Selection Mode tap)
+ document.addEventListener('click', (e) => {
+ if (!isModOrAdmin()) return;
+
+ // Long-press trailing click prevention
+ if (suppressNextClick) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ suppressNextClick = false;
+ return;
+ }
+
+ // Close dropdown if clicking outside
+ if (!e.target.closest('.bulk-dropdown-wrap')) {
+ document.getElementById('bulk-rating-menu')?.classList.remove('show');
+ }
+
+ const thumb = e.target.closest('.posts > a.thumb, .posts > a.lazy-thumb');
+ if (!thumb) return;
+ if (e.target.closest('.hide-thumb-btn')) return;
+
+ // SHIFT + LEFT CLICK -> Quick Marking & Range Selection
+ if (e.shiftKey && e.button === 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+
+ if (!isSelectionMode) enterSelectionMode();
+ if (lastSelectedThumb && document.body.contains(lastSelectedThumb) && lastSelectedThumb !== thumb) {
+ rangeSelect(lastSelectedThumb, thumb);
+ } else {
+ toggleThumb(thumb);
+ }
+ return;
+ }
+
+ // SELECTION MODE ACTIVE -> Tap / Click toggles selection instead of navigating
+ if (isSelectionMode && e.button === 0 && !e.ctrlKey && !e.altKey && !e.metaKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ toggleThumb(thumb);
+ return;
+ }
+ }, true); // Capture phase ensures it runs before any item navigation handlers
+
+ // 4. Keyboard Shortcuts
+ document.addEventListener('keydown', (e) => {
+ if (!isModOrAdmin()) return;
+
+ if (e.key === 'Escape') {
+ const modal = document.getElementById('bulk-tag-modal');
+ if (modal && modal.classList.contains('visible')) {
+ closeTagModal();
+ } else if (isSelectionMode) {
+ exitSelectionMode();
+ }
+ }
+ // Ctrl+A / Cmd+A when in selection mode selects all visible thumbnails
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && isSelectionMode) {
+ if (!['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
+ e.preventDefault();
+ selectAllLoaded();
+ }
+ }
+ });
+ }
+
+ // Initialize once DOM is ready
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {
+ init();
+ } else {
+ document.addEventListener('DOMContentLoaded', init);
+ }
+
+ return {
+ enterSelectionMode,
+ exitSelectionMode,
+ toggleThumb,
+ openTagModal,
+ closeTagModal,
+ executeBulkRating,
+ selectAllLoaded,
+ getSelectedIds: () => Array.from(selectedIds)
+ };
+})();
+
diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js
index 6f58f04..2212c85 100644
--- a/public/s/js/scroller.js
+++ b/public/s/js/scroller.js
@@ -97,11 +97,34 @@
let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10));
let leftHandEnabled = prefs.leftHand === true;
+ const getIsScrollerGuest = () => {
+ if (!window.f0ckSession) return true;
+ if (window.f0ckSession.user && !window.f0ckSession.is_anon) return false;
+ if (window.f0ckSession.is_anon && (window.f0ckSession.logged_in || window.f0ckSession.user)) return false;
+ if (window.f0ckAnonSSH && (window.f0ckAnonSSH.isSessionReady || window.f0ckAnonSSH.pubkey)) return false;
+ if (typeof localStorage !== 'undefined' && localStorage.getItem('f0ck_anon_ssh_pub')) return false;
+ return !window.f0ckSession.logged_in;
+ };
+ const isScrollerGuest = getIsScrollerGuest();
const isScrollerAnon = !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon);
const scrollerAllowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const scrollerSingleMime = (isScrollerAnon && Array.isArray(scrollerAllowedMimes) && scrollerAllowedMimes.length === 1) ? scrollerAllowedMimes[0] : null;
- let applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
+ const scrollerAllowedModes = isScrollerGuest ? ['sfw'] : window.f0ckSession?.anon_permissions?.allowed_modes;
+ const scrollerModeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
+ let effectiveScrollerMode = defaultMode;
+ if (isScrollerGuest) {
+ effectiveScrollerMode = 0;
+ } else if (isScrollerAnon && Array.isArray(scrollerAllowedModes)) {
+ const curName = scrollerModeNames[effectiveScrollerMode] || 'sfw';
+ if (!scrollerAllowedModes.includes(curName)) {
+ const fallbackName = scrollerAllowedModes[0] || 'sfw';
+ const fallbackIdx = scrollerModeNames.indexOf(fallbackName);
+ effectiveScrollerMode = fallbackIdx >= 0 ? fallbackIdx : 0;
+ }
+ }
+
+ let applied = { mode: effectiveScrollerMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
let pending = { ...applied, tags: [] };
// Volume / mute
@@ -3039,6 +3062,19 @@
});
function syncPanelUI() {
+ const isGuest = getIsScrollerGuest();
+ if (isGuest) {
+ pending.mode = 0;
+ applied.mode = 0;
+ } else {
+ document.querySelectorAll('#mode-pills .filter-pill').forEach(pill => {
+ pill.classList.remove('locked');
+ pill.disabled = false;
+ pill.style.cursor = '';
+ pill.style.opacity = '';
+ pill.querySelectorAll('.fa-lock').forEach(i => i.remove());
+ });
+ }
if (scrollerSingleMime) {
pending.mime = scrollerSingleMime;
applied.mime = scrollerSingleMime;
@@ -3055,6 +3091,10 @@
if (!el) return;
el.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', (e) => {
+ if (groupId === 'mode-pills' && (getIsScrollerGuest() || pill.classList.contains('locked') || pill.disabled)) {
+ e.preventDefault();
+ return;
+ }
if (groupId === 'mime-pills' && scrollerSingleMime) {
e.preventDefault();
return;
@@ -3064,6 +3104,15 @@
});
});
}
+
+ window.addEventListener('f0ck:anon_session_ready', () => {
+ if (window.f0ckSession) {
+ window.f0ckSession.user = 'anonymous';
+ window.f0ckSession.is_anon = true;
+ window.f0ckSession.logged_in = true;
+ }
+ syncPanelUI();
+ });
makePillListener('mode-pills', 'mode', v => +v);
makePillListener('mime-pills', 'mime', v => v);
makePillListener('order-pills', 'order', v => v);
diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js
index 6c765cb..9eec414 100644
--- a/public/s/js/sidebar-activity.js
+++ b/public/s/js/sidebar-activity.js
@@ -1515,12 +1515,17 @@
const getCurrentItemIdentifiers = () => {
const ids = new Set();
- // 1. From DOM elements on the page (primary/direct)
- const idElem = document.querySelector('[data-item-id]');
- if (idElem && idElem.dataset.itemId) {
- ids.add(String(idElem.dataset.itemId).trim());
+ // 1. From active item on the page (never match background grid thumbnails)
+ if (typeof window.getCurrentItemId === 'function') {
+ const currentId = window.getCurrentItemId();
+ if (currentId) ids.add(String(currentId).trim());
+ } else {
+ const idElem = document.querySelector('#onara-item-mount [data-item-id], .item-layout-container[data-item-id], #comments-container[data-item-id]');
+ if (idElem?.dataset?.itemId) {
+ ids.add(String(idElem.dataset.itemId).trim());
+ }
}
- const idLink = document.querySelector('.id-link');
+ const idLink = document.querySelector('#onara-item-mount .id-link, .item-layout-container .id-link, #main.item-view .id-link');
if (idLink) {
if (idLink.dataset.itemId) ids.add(String(idLink.dataset.itemId).trim());
const txt = idLink.textContent.trim();
diff --git a/public/s/js/user.js b/public/s/js/user.js
index 7551093..9bbd36c 100644
--- a/public/s/js/user.js
+++ b/public/s/js/user.js
@@ -46,6 +46,7 @@
const tagsContainer = document.querySelector("#tags");
if (!tagsContainer) return;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
+ const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
const canManage = !!(
document.querySelector('#tags[data-can-manage="true"]') ||
@@ -76,7 +77,7 @@
contentEl.textContent = tag.tag;
} else {
contentEl = document.createElement("a");
- contentEl.href = `/tag/${tag.normalized}`;
+ contentEl.href = "/tag/" + tag.normalized;
contentEl.style = "color: inherit !important";
contentEl.textContent = tag.tag;
}
@@ -92,6 +93,7 @@
if (isRating) {
span.classList.add('rating-tag', `is-${tag.normalized}`);
span.dataset.rating = tag.normalized;
+ if (activePostId) span.dataset.itemId = activePostId;
if (canManage) {
span.classList.add('can-cycle');
}
@@ -120,6 +122,7 @@
const untaggedSpan = document.createElement("span");
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`;
untaggedSpan.dataset.rating = 'untagged';
+ if (activePostId) untaggedSpan.dataset.itemId = activePostId;
const lbl = document.createElement("span");
lbl.className = "rating-label";
lbl.textContent = "untagged";
@@ -135,6 +138,18 @@
}
}
+ // Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
+ if (activePostId) {
+ const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
+ document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
+ th.setAttribute('data-mode', modeAttr);
+ });
+ if (document.body.classList.contains('onara-modal-open')) {
+ const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
+ if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
+ }
+ }
+
// Handle show more/less toggle visibility and count
const allBadges = [...inner.querySelectorAll(".badge")];
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
diff --git a/src/inc/anon_auth.mjs b/src/inc/anon_auth.mjs
index 0530555..0d2f1e6 100644
--- a/src/inc/anon_auth.mjs
+++ b/src/inc/anon_auth.mjs
@@ -174,8 +174,8 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin
const userId = userRows[0].id;
await db`
- INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name)
- VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous')
+ INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name, use_alternative_infobox)
+ VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous', ${cfg.websrv.user_alternative_infobox !== false})
ON CONFLICT (user_id) DO NOTHING
`;
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index 7970fd0..05d87f8 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -18,16 +18,12 @@ const computeBaseMode = (mode, ratings, session) => {
let effMode = Number(mode ?? 0);
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
- // For guest sessions, sanitize ratingsArr to only allow permitted ratings
+ // For guest sessions, strictly force SFW mode and SFW rating
let safeRatingsArr = ratingsArr;
- if (!session && ratingsArr) {
- const allowedRatings = ['sfw'];
- if (cfg.websrv.public_nsfw) allowedRatings.push('nsfw');
- if (cfg.websrv.public_untagged) allowedRatings.push('untagged');
- safeRatingsArr = ratingsArr.filter(r => allowedRatings.includes(r));
- if (safeRatingsArr.length === 0) {
- return "1 = 0";
- }
+ const isGuest = !session || !session.user;
+ if (isGuest) {
+ effMode = 0;
+ safeRatingsArr = ['sfw'];
} else if (isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
const canFilter = canAnonDo('filter');
@@ -35,10 +31,14 @@ const computeBaseMode = (mode, ratings, session) => {
if (!canFilter) {
safeRatingsArr = null;
effMode = 0;
- } else if (safeRatingsArr) {
- safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r));
- if (safeRatingsArr.length === 0) {
- return "1 = 0";
+ } else {
+ if (safeRatingsArr) {
+ safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r));
+ if (safeRatingsArr.length === 0) {
+ safeRatingsArr = (allowedModes.length === 1 && !allowedModes.includes('all'))
+ ? [allowedModes[0]]
+ : (allowedModes.length < 5 ? [...allowedModes] : null);
+ }
}
}
@@ -84,11 +84,11 @@ const computeBaseMode = (mode, ratings, session) => {
}
} else if (effMode === 2) {
if (!cfg.websrv.public_untagged) {
- baseMode = "1 = 0";
+ baseMode = "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (effMode === 1) {
if (!cfg.websrv.public_nsfw) {
- baseMode = "1 = 0";
+ baseMode = "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (effMode === 4) {
baseMode = "1 = 0";
diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs
index b6ec0f8..c4f9b49 100644
--- a/src/inc/routes/ajax.mjs
+++ b/src/inc/routes/ajax.mjs
@@ -34,10 +34,11 @@ export default (router, tpl) => {
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
+ const isGuest = !req.session || !req.session.user;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
- const reqMode = query.mode !== undefined ? +query.mode : req.mode;
+ const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
const ratingsRaw = req.cookies.ratings;
- const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
+ const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
const data = await f0cklib.getf0ck({
@@ -256,10 +257,11 @@ export default (router, tpl) => {
query = req.url.qs || {};
}
+ const isGuest = !req.session || !req.session.user;
const page = parseInt(query.page) || 1;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
- const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
+ const ratingsArr = isGuest ? ['sfw'] : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
const data = await f0cklib.getf0cks({
page: page,
@@ -269,7 +271,7 @@ export default (router, tpl) => {
userHall: query.userHall || null,
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
- mode: query.mode !== undefined ? +query.mode : req.mode,
+ mode: isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode),
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs
index 762ee5d..b72562d 100644
--- a/src/inc/routes/apiv2/tags.mjs
+++ b/src/inc/routes/apiv2/tags.mjs
@@ -9,6 +9,259 @@ import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession } from "../../settings.mjs";
export default router => {
+ router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
+ const isModOrAdmin = !!(req.session?.admin || req.session?.is_moderator);
+ if (!isModOrAdmin) {
+ return res.json({ success: false, msg: 'Bulk selection is only available to administrators and moderators' }, 403);
+ }
+
+ const action = req.body?.action || req.post?.action || 'add';
+ const rawIds = req.body?.item_ids || req.post?.item_ids;
+ if (!rawIds || !Array.isArray(rawIds) || rawIds.length === 0) {
+ return res.json({ success: false, msg: 'No items selected' }, 400);
+ }
+
+ // Sanitize item IDs
+ const itemIds = [...new Set(rawIds.map(id => +id).filter(id => Number.isInteger(id) && id > 0))].slice(0, 500);
+ if (itemIds.length === 0) {
+ return res.json({ success: false, msg: 'Invalid item IDs' }, 400);
+ }
+
+ // 1. ADD TAGS
+ if (action === 'add') {
+ if (isAnonSession(req.session) && !canAnonDo('tag')) {
+ return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
+ }
+
+ let rawTags = req.body?.tags || req.post?.tags;
+ if (typeof rawTags === 'string') {
+ rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
+ }
+ if (!Array.isArray(rawTags) || rawTags.length === 0) {
+ return res.json({ success: false, msg: 'No tags provided' }, 400);
+ }
+
+ const protectedTags = ['sfw', 'nsfw', 'nsfl'];
+ const validTags = [...new Set(
+ rawTags
+ .map(t => (typeof t === 'string' ? t.trim() : ''))
+ .filter(t => t.length > 0 && t.length <= 85 && !protectedTags.includes(t.toLowerCase()))
+ )];
+
+ if (validTags.length === 0) {
+ return res.json({ success: false, msg: 'Tags invalid or contain only reserved names' }, 400);
+ }
+
+ try {
+ const tagIds = [];
+ for (const tagname of validTags) {
+ let tagRow = await db`
+ SELECT id FROM "tags"
+ WHERE normalized = slugify(${tagname})
+ LIMIT 1
+ `;
+ let tagid = tagRow?.[0]?.id;
+ if (!tagid) {
+ const created = await db`
+ INSERT INTO "tags" ${db({ tag: tagname })}
+ RETURNING id
+ `;
+ tagid = created?.[0]?.id;
+ }
+ if (tagid) tagIds.push({ id: +tagid, name: tagname });
+ }
+
+ // Insert assignments
+ for (const postid of itemIds) {
+ for (const { id: tagid } of tagIds) {
+ try {
+ await db`
+ INSERT INTO "tags_assign" (tag_id, item_id, user_id)
+ VALUES (${tagid}, ${postid}, ${+req.session.id})
+ ON CONFLICT DO NOTHING
+ `;
+ } catch (assignErr) {
+ // Ignore duplicate errors if table has constraint
+ if (assignErr.code !== '23505') {
+ console.warn(`[BULK_TAG_ASSIGN_WARN] Item ${postid}, tag ${tagid}:`, assignErr.message);
+ }
+ }
+ }
+ }
+
+ await audit.log(req.session.id, 'bulk_add_tags', 'items', null, { item_ids: itemIds, tags: validTags }).catch(() => {});
+ if (req.session?.is_anon) {
+ await logAnonActivity(req, {
+ action: 'bulk_tag',
+ targetId: itemIds[0],
+ details: { item_ids: itemIds, tags: validTags }
+ }).catch(() => {});
+ }
+
+ // Notify affected items asynchronously
+ (async () => {
+ for (const postid of itemIds) {
+ try {
+ const freshTags = await lib.getTags(postid);
+ await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
+ } catch {}
+ }
+ })().catch(() => {});
+
+ return res.json({
+ success: true,
+ count: itemIds.length,
+ tags: validTags,
+ msg: `Successfully added ${validTags.length} tag(s) to ${itemIds.length} item(s)`
+ });
+ } catch (err) {
+ console.error('[BULK_ADD_TAGS_ERROR]', err);
+ return res.json({ success: false, msg: 'Failed to add tags' }, 500);
+ }
+ }
+
+ // 2. REMOVE TAGS
+ if (action === 'remove') {
+ const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
+
+ let rawTags = req.body?.tags || req.post?.tags;
+ if (typeof rawTags === 'string') {
+ rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
+ }
+ if (!Array.isArray(rawTags) || rawTags.length === 0) {
+ return res.json({ success: false, msg: 'No tags provided to remove' }, 400);
+ }
+
+ try {
+ // Resolve tags to remove
+ const tagRows = await db`
+ SELECT id, tag, normalized FROM "tags"
+ WHERE normalized IN ${db(rawTags.map(t => t.trim().toLowerCase()))}
+ `;
+ if (tagRows.length === 0) {
+ return res.json({ success: false, msg: 'Tags not found' }, 404);
+ }
+ const tagIds = tagRows.map(r => r.id);
+
+ if (isModOrAdmin) {
+ await db`
+ DELETE FROM "tags_assign"
+ WHERE item_id IN ${db(itemIds)}
+ AND tag_id IN ${db(tagIds)}
+ `;
+ } else {
+ // Normal user can only remove tags from items they own
+ await db`
+ DELETE FROM "tags_assign"
+ WHERE item_id IN ${db(itemIds)}
+ AND tag_id IN ${db(tagIds)}
+ AND item_id IN (
+ SELECT id FROM items WHERE username = ${req.session.user}
+ )
+ `;
+ }
+
+ await audit.log(req.session.id, 'bulk_remove_tags', 'items', null, { item_ids: itemIds, tags: tagRows.map(r => r.tag) }).catch(() => {});
+
+ // Realtime notifications
+ (async () => {
+ for (const postid of itemIds) {
+ try {
+ const freshTags = await lib.getTags(postid);
+ await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
+ } catch {}
+ }
+ })().catch(() => {});
+
+ return res.json({
+ success: true,
+ count: itemIds.length,
+ msg: `Successfully removed tag(s) from ${itemIds.length} item(s)`
+ });
+ } catch (err) {
+ console.error('[BULK_REMOVE_TAGS_ERROR]', err);
+ return res.json({ success: false, msg: 'Failed to remove tags' }, 500);
+ }
+ }
+
+ // 3. SET RATING (sfw / nsfw / nsfl)
+ if (action === 'set_rating') {
+ if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
+ return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
+ }
+
+ const rating = (req.body?.rating || req.post?.rating || '').toLowerCase();
+ if (!['sfw', 'nsfw', 'nsfl'].includes(rating)) {
+ return res.json({ success: false, msg: 'Invalid rating. Choose sfw, nsfw, or nsfl' }, 400);
+ }
+
+ const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
+ const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
+ const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
+ const targetTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : nsflId);
+
+ try {
+ let eligibleIds = itemIds;
+ if (!isModOrAdmin) {
+ const ownedItems = await db`
+ SELECT id FROM items
+ WHERE id IN ${db(itemIds)}
+ AND username = ${req.session.user}
+ AND active = true AND is_deleted = false
+ `;
+ eligibleIds = ownedItems.map(r => r.id);
+ }
+
+ if (eligibleIds.length === 0) {
+ return res.json({ success: false, msg: 'You do not have permission to rate the selected items' }, 403);
+ }
+
+ await db.begin(async sql => {
+ // Delete existing rating tags
+ await sql`
+ DELETE FROM tags_assign
+ WHERE item_id IN ${sql(eligibleIds)}
+ AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
+ `;
+
+ // Insert new rating tags
+ for (const id of eligibleIds) {
+ await sql`
+ INSERT INTO tags_assign (item_id, tag_id, user_id)
+ VALUES (${id}, ${targetTagId}, ${+req.session.id})
+ `;
+ }
+ });
+
+ await audit.log(req.session.id, 'bulk_set_rating', 'items', null, { item_ids: eligibleIds, rating }).catch(() => {});
+
+ // Queue blur thumbnail verification where needed
+ (async () => {
+ for (const id of eligibleIds) {
+ try {
+ const blurPath = path.join(cfg.paths.t, `${id}_blur.webp`);
+ await fs.promises.access(blurPath).catch(() => queue.genBlurredThumbnail(id, false));
+ const freshTags = await lib.getTags(id);
+ await db.notify('tags', JSON.stringify({ item_id: id, fresh: true, tags: freshTags }));
+ } catch {}
+ }
+ })().catch(() => {});
+
+ return res.json({
+ success: true,
+ count: eligibleIds.length,
+ rating,
+ msg: `Successfully set rating to ${rating.toUpperCase()} for ${eligibleIds.length} item(s)`
+ });
+ } catch (err) {
+ console.error('[BULK_SET_RATING_ERROR]', err);
+ return res.json({ success: false, msg: 'Failed to update rating' }, 500);
+ }
+ }
+
+ return res.json({ success: false, msg: 'Unknown bulk action' }, 400);
+ });
+
router.group(/^\/api\/v2\/tags\/(?\d+)/, group => {
group.get(/$/, lib.loggedin, async (req, res) => {
// get tags
diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs
index 0009b55..e76df37 100644
--- a/src/inc/routes/index.mjs
+++ b/src/inc/routes/index.mjs
@@ -645,7 +645,19 @@ export default (router, tpl) => {
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
const mode = modeMatch ? +modeMatch[1] : 0;
- if (isAnonSession(req.session)) {
+ const isGuest = !req.session || !req.session.user;
+ if (isGuest) {
+ if (mode !== 0) {
+ if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
+ return res.reply({
+ code: 403,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ success: false, msg: 'Only SFW mode is allowed for guests' })
+ });
+ }
+ return res.redirect('/');
+ }
+ } else if (isAnonSession(req.session)) {
if (!canAnonDo('filter') || !canAnonMode(mode)) {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
diff --git a/src/index.mjs b/src/index.mjs
index 1a739af..256ddcd 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -20,7 +20,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
-import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs } 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, 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";
@@ -32,6 +32,18 @@ import { verifySignature } from "./inc/anon_auth.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
+const addCookieHeader = (res, cookieStr) => {
+ if (!res || !res.setHeader) return;
+ const existing = res.getHeader('Set-Cookie');
+ if (!existing) {
+ res.setHeader('Set-Cookie', [cookieStr]);
+ } else if (Array.isArray(existing)) {
+ res.setHeader('Set-Cookie', [...existing, cookieStr]);
+ } else {
+ res.setHeader('Set-Cookie', [existing, cookieStr]);
+ }
+};
+
// ─── Private Society Gate ────────────────────────────────────────────────────
// Powered by the cloudflare-error-page package.
// Each request gets a fresh page with a unique Ray ID + current UTC timestamp.
@@ -1112,13 +1124,105 @@ process.on('uncaughtException', err => {
const queryMode = req.url.qs?.mode !== undefined ? +req.url.qs.mode : undefined;
req.mode = queryMode !== undefined ? queryMode : (req.session ? +(req.session.mode ?? 0) : +(req.cookies?.mode ?? 0));
- // public_nsfw: when true, the *default* for guests is mode 3 (all content).
- // Only applies when the guest has no explicit mode preference (no ?mode= param, no cookie).
- // If the guest has chosen a filter (e.g. SFW), that choice is always respected.
- if (!req.session) {
- const hasExplicitMode = queryMode !== undefined || req.cookies?.mode !== undefined;
- if (!hasExplicitMode) {
- req.mode = cfg.websrv.public_nsfw ? 3 : 0;
+ const isGuest = !req.session || !req.session.user;
+
+ if (isGuest) {
+ req.mode = 0;
+
+ // Ensure guests have mode=0 cookie
+ if (req.cookies?.mode !== '0') {
+ req.cookies = req.cookies || {};
+ req.cookies.mode = '0';
+ addCookieHeader(res, `mode=0; ${lib.getCookieOptions(31536000, false)}`);
+ }
+
+ // Ensure guests have ratings=sfw cookie
+ if (req.cookies?.ratings !== 'sfw') {
+ req.cookies = req.cookies || {};
+ req.cookies.ratings = 'sfw';
+ addCookieHeader(res, `ratings=sfw; ${lib.getCookieOptions(31536000, false)}`);
+ }
+
+ // Reconcile MIME cookie for guests
+ const allowedMimes = cfg.anonymous_permissions?.allowed_mimes || ['image', 'video', 'audio'];
+ if (req.cookies?.mime) {
+ const rawMimes = decodeURIComponent(req.cookies.mime).split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
+ const validMimes = rawMimes.filter(m => allowedMimes.includes(m));
+ if (validMimes.length !== rawMimes.length) {
+ if (validMimes.length > 0) {
+ req.cookies.mime = validMimes.join(',');
+ addCookieHeader(res, `mime=${req.cookies.mime}; ${lib.getCookieOptions(31536000, false)}`);
+ } else {
+ delete req.cookies.mime;
+ addCookieHeader(res, `mime=; ${lib.getCookieOptions(0, false)}`);
+ }
+ }
+ }
+ } else if (isAnonSession(req.session)) {
+ const canFilter = canAnonDo('filter');
+ const allowedModes = getAnonAllowedModes();
+ const allowedMimes = getAnonAllowedMimes();
+ const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
+
+ // Validate & reconcile req.mode
+ const curModeName = modeNames[req.mode] || 'sfw';
+ if (!canFilter || !allowedModes.includes(curModeName)) {
+ const fallbackName = allowedModes.find(m => modeNames.includes(m)) || 'sfw';
+ const fallbackIdx = modeNames.indexOf(fallbackName);
+ req.mode = fallbackIdx >= 0 ? fallbackIdx : 0;
+ }
+
+ // If client sent a mode cookie that doesn't match the validated req.mode, auto-update it
+ if (req.cookies?.mode !== undefined && +req.cookies.mode !== req.mode) {
+ req.cookies.mode = String(req.mode);
+ addCookieHeader(res, `mode=${req.mode}; ${lib.getCookieOptions(31536000, false)}`);
+ }
+
+ // Validate & reconcile ratings cookie
+ if (req.cookies?.ratings) {
+ const rawRatings = decodeURIComponent(req.cookies.ratings).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r));
+ let validRatings = canFilter ? rawRatings.filter(r => allowedModes.includes(r)) : [];
+ if (validRatings.length !== rawRatings.length) {
+ if (validRatings.length === 0) {
+ if (allowedModes.length === 1 && !allowedModes.includes('all')) {
+ validRatings = [allowedModes[0]];
+ }
+ }
+ if (validRatings.length > 0) {
+ req.cookies.ratings = validRatings.join('|');
+ addCookieHeader(res, `ratings=${req.cookies.ratings}; ${lib.getCookieOptions(31536000, false)}`);
+ } else {
+ delete req.cookies.ratings;
+ addCookieHeader(res, `ratings=; ${lib.getCookieOptions(0, false)}`);
+ }
+ }
+ }
+
+ // Validate & reconcile mime cookie
+ if (req.cookies?.mime) {
+ const rawMimes = decodeURIComponent(req.cookies.mime).split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
+ const isSingleMime = allowedMimes.length === 1;
+ if (isSingleMime) {
+ const singleMime = allowedMimes[0];
+ if (req.cookies.mime !== singleMime) {
+ req.cookies.mime = singleMime;
+ addCookieHeader(res, `mime=${singleMime}; ${lib.getCookieOptions(31536000, false)}`);
+ }
+ } else if (!canFilter) {
+ delete req.cookies.mime;
+ addCookieHeader(res, `mime=; ${lib.getCookieOptions(0, false)}`);
+ } else {
+ const validMimes = rawMimes.filter(m => allowedMimes.includes(m));
+ if (validMimes.length !== rawMimes.length) {
+ if (validMimes.length > 0) {
+ req.cookies.mime = validMimes.join(',');
+ addCookieHeader(res, `mime=${req.cookies.mime}; ${lib.getCookieOptions(31536000, false)}`);
+ } else {
+ delete req.cookies.mime;
+ addCookieHeader(res, `mime=; ${lib.getCookieOptions(0, false)}`);
+ }
+ }
+ }
}
}
diff --git a/views/index-partial.html b/views/index-partial.html
index 85a30f6..245da36 100644
--- a/views/index-partial.html
+++ b/views/index-partial.html
@@ -3,7 +3,7 @@
@include(snippets/page-title)
@each(items as item)
-
+
@if(item.is_audio && !item.has_coverart)
+
@endeach
diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html
index 2810a0b..c732418 100644
--- a/views/item-partial-legacy.html
+++ b/views/item-partial-legacy.html
@@ -1,6 +1,6 @@
{{-- LEGACY LAYOUT — 2-column (main + fixed right sidebar) --}}
{{-- Used by: members with use_new_layout = false --}}
-
+
{{-- MAIN CONTENT: media + navigation + metadata + comments --}}
@@ -191,17 +191,17 @@
@endif
-
+
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
-
+
?
@endif
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
-
+
{!! tag.tag !!}
@else
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html
index 8f8a118..716fe24 100644
--- a/views/item-partial-modern.html
+++ b/views/item-partial-modern.html
@@ -1,6 +1,6 @@
{{-- MODERN LAYOUT — 3-column grid --}}
{{-- Used by: guests (no session) + members with use_new_layout = true --}}
-
+
{{-- LEFT SIDEBAR: comments + tags --}}