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 +
+
+ + +
+ +
+ + + +
+
+ + +
+ `; + 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 = ` +
+
+

Bulk Tagging

+ +
+

Add tags to selected 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 --}}
@@ -40,16 +40,16 @@ @endif
@js - const _isScrollerAnon = !(typeof session !== 'undefined' && session && session.user && !session.is_anon); - const _scrollerAllowedModes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; - const _scrollerShowSfw = !_isScrollerAnon || _scrollerAllowedModes.includes('sfw'); - const _scrollerShowNsfw = (typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('nsfw')); - const _scrollerShowNsfl = enable_nsfl && ((typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('nsfl'))); - const _scrollerShowAll = (typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('all')); - const _scrollerShowUntagged = (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isScrollerAnon && _scrollerAllowedModes.includes('untagged')); - const _scrollerActiveModes = [_scrollerShowSfw ? 'sfw' : null, _scrollerShowNsfw ? 'nsfw' : null, _scrollerShowNsfl ? 'nsfl' : null, _scrollerShowUntagged ? 'untagged' : null].filter(Boolean); - const _scrollerIsSingleMode = _isScrollerAnon && _scrollerActiveModes.length === 1 && !_scrollerShowAll; - const _scrollerSingleMode = _scrollerIsSingleMode ? _scrollerActiveModes[0] : null; + const _isScrollerMember = typeof session !== 'undefined' && session && session.user && !session.is_anon; + const _isScrollerAnon = typeof session !== 'undefined' && session && (session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))); + const _isScrollerGuest = !_isScrollerMember && !_isScrollerAnon; + const _isScrollerAnonUser = !_isScrollerMember; + const _scrollerAllowedModes = _isScrollerGuest ? ['sfw'] : ((typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']); + const _scrollerShowSfw = true; + const _scrollerShowNsfw = _isScrollerGuest || (!_isScrollerAnonUser || _scrollerAllowedModes.includes('nsfw')); + const _scrollerShowNsfl = enable_nsfl && (_isScrollerGuest || (!_isScrollerAnonUser || _scrollerAllowedModes.includes('nsfl'))); + const _scrollerShowAll = _isScrollerGuest || (!_isScrollerAnonUser || _scrollerAllowedModes.includes('all')); + const _scrollerShowUntagged = _isScrollerGuest || (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isScrollerAnonUser && _scrollerAllowedModes.includes('untagged')); + const _scrollerActiveModes = _isScrollerGuest ? ['sfw'] : [_scrollerShowSfw ? 'sfw' : null, _scrollerShowNsfw ? 'nsfw' : null, _scrollerShowNsfl ? 'nsfl' : null, _scrollerShowUntagged ? 'untagged' : null].filter(Boolean); + const _scrollerIsSingleMode = _isScrollerGuest || (_isScrollerAnonUser && _scrollerActiveModes.length === 1 && !_scrollerShowAll); + const _scrollerSingleMode = _isScrollerGuest ? 'sfw' : (_scrollerIsSingleMode ? _scrollerActiveModes[0] : null); const _scrollerAllowedMimes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_mimes) || ['image', 'video', 'audio', 'flash', 'pdf']; - const _scrollerIsSingleMime = _isScrollerAnon && _scrollerAllowedMimes.length === 1; + const _scrollerIsSingleMime = _isScrollerAnonUser && _scrollerAllowedMimes.length === 1; const _scrollerSingleMime = _scrollerIsSingleMime ? _scrollerAllowedMimes[0] : null; @endjs
- @if(_scrollerIsSingleMode) + @if(_isScrollerGuest) + + + @if(enable_nsfl) + + @endif + + + @elseif(_scrollerIsSingleMode) @else @if(_scrollerShowSfw) diff --git a/views/snippets/excluded-tags-modal.html b/views/snippets/excluded-tags-modal.html index 276e220..e585bc5 100644 --- a/views/snippets/excluded-tags-modal.html +++ b/views/snippets/excluded-tags-modal.html @@ -2,18 +2,21 @@
×
@js - const _isAnonUser = !(typeof session !== 'undefined' && session && session.user && !session.is_anon); + const _isMember = typeof session !== 'undefined' && session && session.user && !session.is_anon; + const _isAnon = typeof session !== 'undefined' && session && (session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))); + const _isGuest = !_isMember && !_isAnon; + const _isAnonUser = !_isMember; const _canExcludeTags = !_isAnonUser || (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.exclude_tags !== false && anon_permissions.filter !== false); - const _allowedModes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; - const _showSfw = !_isAnonUser || _allowedModes.includes('sfw'); - const _showNsfw = !_isAnonUser || _allowedModes.includes('nsfw'); - const _showNsfl = enable_nsfl && (!_isAnonUser || _allowedModes.includes('nsfl')); - const _showUntagged = (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isAnonUser && _allowedModes.includes('untagged')); - const _showAll = !_isAnonUser || _allowedModes.includes('all'); + const _allowedModes = _isGuest ? ['sfw'] : ((typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']); + const _showSfw = true; + const _showNsfw = _isGuest || (!_isAnonUser || _allowedModes.includes('nsfw')); + const _showNsfl = enable_nsfl && (_isGuest || (!_isAnonUser || _allowedModes.includes('nsfl'))); + const _showUntagged = _isGuest || (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isAnonUser && _allowedModes.includes('untagged')); + const _showAll = _isGuest || (!_isAnonUser || _allowedModes.includes('all')); - const _activeRatingList = [_showSfw ? 'sfw' : null, _showNsfw ? 'nsfw' : null, _showNsfl ? 'nsfl' : null, _showUntagged ? 'untagged' : null].filter(Boolean); - const _isSingleRating = _isAnonUser && _activeRatingList.length === 1 && !_showAll; - const _singleRating = _isSingleRating ? _activeRatingList[0] : null; + const _activeRatingList = _isGuest ? ['sfw'] : [_showSfw ? 'sfw' : null, _showNsfw ? 'nsfw' : null, _showNsfl ? 'nsfl' : null, _showUntagged ? 'untagged' : null].filter(Boolean); + const _isSingleRating = _isGuest || (_isAnonUser && _activeRatingList.length === 1 && !_showAll); + const _singleRating = _isGuest ? 'sfw' : (_isSingleRating ? _activeRatingList[0] : null); const _allowedMimes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_mimes) || ['image', 'video', 'audio', 'flash', 'pdf']; const _isSingleMime = _isAnonUser && _allowedMimes.length === 1; @@ -37,22 +40,22 @@ @endif
- -
+ +
@if(_showSfw) - + @endif @if(_showNsfw) - + @endif @if(_showNsfl) - + @endif @if(_showUntagged) - + @endif - @if(_showAll && !_isSingleRating) - + @if(_showAll && (!_isSingleRating || _isGuest)) + @endif
diff --git a/views/snippets/items-grid.html b/views/snippets/items-grid.html index 98ee175..c20711a 100644 --- a/views/snippets/items-grid.html +++ b/views/snippets/items-grid.html @@ -1,5 +1,5 @@ @each(items as item) - + @if(item.is_audio && !item.has_coverart) +

@endeach \ No newline at end of file diff --git a/views/user-partial.html b/views/user-partial.html index 33b4a17..11211af 100644 --- a/views/user-partial.html +++ b/views/user-partial.html @@ -123,7 +123,7 @@ @if(count.f0cks)
@each(f0cks.items as item) - + @if(item.is_audio && !item.has_coverart) +

@endeach @@ -161,7 +162,7 @@ @if(count.favs)