diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 428b9ca..223f633 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -6871,8 +6871,6 @@ span#tags:not(.tags-expanded) .tags-inner>span:nth-child(n+11) { background-color: var(--badge-sfw); padding-right: 5px; padding-left: 5px; - padding-top: 1.5px; - padding-bottom: 1.5px; border-radius: 3px; text-shadow: 1px 1px var(--black); text-transform: uppercase; @@ -6883,8 +6881,6 @@ span#tags:not(.tags-expanded) .tags-inner>span:nth-child(n+11) { background-color: var(--badge-nsfw); padding-right: 5px; padding-left: 5px; - padding-top: 1.5px; - padding-bottom: 1.5px; border-radius: 3px; text-shadow: 1px 1px var(--black); text-transform: uppercase; @@ -13024,7 +13020,7 @@ body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item- color: #dc3545 !important; } -/* Rating Button — icon-only, badge-proportioned */ +/* Rating Button — icon-only, badge-proportioned (backward compatibility) */ #a_toggle.rating-btn { display: inline-flex; align-items: center; @@ -13062,6 +13058,51 @@ body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item- background-color: #444; } +/* Rating Tag — interactive rating badge (SFW / NSFW / NSFL / UNTAGGED) */ +.rating-tag { + user-select: none; + cursor: default; +} + +.rating-tag.can-cycle { + cursor: pointer; +} + +.rating-tag .rating-label { + pointer-events: none; + color: inherit !important; + text-shadow: 1px 1px black; +} + +.rating-tag.is-untagged, +.badge.badge-untagged { + background: transparent !important; + color: rgba(255, 255, 255, 0.55) !important; + border: 1px dashed rgba(255, 255, 255, 0.35); + padding: 0.5px 4px; + border-radius: 3px; + text-transform: uppercase; + box-sizing: border-box; +} + +html[theme='light'] .rating-tag.is-untagged, +html[theme='light'] .badge.badge-untagged, +html[theme='paper'] .rating-tag.is-untagged, +html[theme='paper'] .badge.badge-untagged { + color: rgba(0, 0, 0, 0.55) !important; + border-color: rgba(0, 0, 0, 0.35); +} + +.rating-tag a { + color: inherit !important; + text-decoration: none !important; + pointer-events: none; +} + +.rating-tag.can-cycle a { + cursor: pointer; +} + .rules { diff --git a/public/s/css/upload.css b/public/s/css/upload.css index 57ad862..c18f564 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -621,7 +621,6 @@ .rating-label { display: block; - padding: 0.1rem 2rem; border-radius: 0; border: 2px solid transparent; transition: all 0.2s; diff --git a/public/s/js/admin.js b/public/s/js/admin.js index d037113..8ecac35 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -51,6 +51,12 @@ if (!tagsContainer) return; const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer; + const canManage = !!( + document.querySelector('#tags[data-can-manage="true"]') || + (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) || + (window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) + ); + // Only remove existing dynamically generated tags [...inner.querySelectorAll(".badge")].forEach(tag => { // Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself @@ -59,34 +65,80 @@ } }); - _tags.reverse().forEach(tag => { - const a = document.createElement("a"); - a.href = `/tag/${tag.normalized}`; - a.style = "color: inherit !important"; - a.textContent = tag.tag; + // Deduplicate: ensure only at most ONE rating tag exists in the tags list + const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized)); + const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null; + const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag); + + const tagsCopy = [...cleanTags]; + tagsCopy.reverse().forEach(tag => { + const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized); + let contentEl; + if (isRating) { + contentEl = document.createElement("span"); + contentEl.className = "rating-label"; + contentEl.textContent = tag.tag; + } else { + contentEl = document.createElement("a"); + contentEl.href = `/tag/${tag.normalized}`; + contentEl.style = "color: inherit !important"; + contentEl.textContent = tag.tag; + } const span = document.createElement("span"); span.classList.add("badge"); if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) { span.classList.add('new-tag-glow'); } - span.setAttribute('tooltip', tag.display_name || tag.user); tag.badge.split(" ").forEach(b => span.classList.add(b)); - const delbutton = document.createElement("a"); - delbutton.innerHTML = ''; - delbutton.href = "javascript:void(0)"; - // Class for delegation - delbutton.classList.add("admin-deltag", "removetag"); + if (isRating) { + span.classList.add('rating-tag', `is-${tag.normalized}`); + span.dataset.rating = tag.normalized; + if (canManage) { + span.classList.add('can-cycle'); + } + } else if (tag.display_name || tag.user) { + span.setAttribute('tooltip', tag.display_name || tag.user); + } - span.appendChild(a); - span.appendChild(document.createTextNode('\u00A0')); - span.appendChild(delbutton); + span.appendChild(contentEl); + + if (!isRating) { + const delbutton = document.createElement("a"); + delbutton.innerHTML = ''; + delbutton.href = "javascript:void(0)"; + // Class for delegation + delbutton.classList.add("admin-deltag", "removetag"); + + span.appendChild(document.createTextNode('\u00A0')); + span.appendChild(delbutton); + } inner.insertAdjacentElement("afterbegin", span); }); + const hasRating = !!lastRatingTag; + if (!hasRating) { + const untaggedSpan = document.createElement("span"); + untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`; + untaggedSpan.dataset.rating = 'untagged'; + const lbl = document.createElement("span"); + lbl.className = "rating-label"; + lbl.textContent = "untagged"; + untaggedSpan.appendChild(lbl); + inner.insertAdjacentElement("afterbegin", untaggedSpan); + } + + // Safeguard: remove any extra rating tags in DOM + const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]'); + if (allRatingEls.length > 1) { + for (let i = 1; i < allRatingEls.length; i++) { + allRatingEls[i].remove(); + } + } + // 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/f0ckm.js b/public/s/js/f0ckm.js index 1830382..e028ca2 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -38,7 +38,7 @@ window.cancelAnimFrame = (function () { }; window.getCurrentItemId = () => { - const idEl = document.querySelector('a.id-link[data-item-id], #a_toggle[data-item-id], #a_favo[data-item-id], #comments-container[data-item-id], [data-item-id]'); + 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); @@ -1299,6 +1299,10 @@ window.cancelAnimFrame = (function () { '#comments-container', '.tag-controls', '.sidebar-tags-container', + '#tags', + '.rating-tag', + '.rating-label', + '.item-sidebar-left', '.xd-score-wrapper', '.user-infobox-block', 'a', @@ -1762,6 +1766,15 @@ window.cancelAnimFrame = (function () { document.body.classList.remove('modal-open'); return; } + const tagAcWrapper = document.querySelector('.tag-ac-wrapper'); + if (tagAcWrapper || (window.TagAutocomplete && typeof window.TagAutocomplete.isOpen === 'function' && window.TagAutocomplete.isOpen())) { + if (window.TagAutocomplete && typeof window.TagAutocomplete.destroy === 'function') { + window.TagAutocomplete.destroy(); + } else if (tagAcWrapper) { + tagAcWrapper.remove(); + } + return; + } if (document.body.classList.contains('onara-modal-open')) { closeOnaraModal(); } @@ -4530,6 +4543,20 @@ window.cancelAnimFrame = (function () { document.addEventListener('click', (e) => { const target = e.target.nodeType === 3 ? e.target.parentElement : e.target; + // Intercept rating cycling immediately before any link/navigation handler can catch it + const ratingTag = target.closest('.rating-tag') || target.closest('button#a_toggle'); + if (ratingTag && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.button === 0) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + const canCycle = ratingTag.classList.contains('can-cycle') || ratingTag.id === 'a_toggle' || !!document.querySelector('#tags[data-can-manage="true"]'); + if (canCycle && window.cycleRating) { + window.cycleRating(ratingTag); + } + return false; + } + // Check for mode selection (only applies to links, not plain buttons) const modeBtn = target.closest('.mode-btn'); if (modeBtn && modeBtn.href && modeBtn.href.includes('/mode/') && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { @@ -4714,10 +4741,11 @@ window.cancelAnimFrame = (function () { .then(r => r.json()) .then(data => { if (data.success && data.items && (data.items.slug || data.items.id)) { - const targetKey = data.items.slug || data.items.id; - const targetId = data.items.id; + const targetKey = data.items.slug || data.items.id; + const targetId = data.items.id; + const targetPage = data.items.page || 1; - // Navigate immediately — don't wait for grid sync + // Navigate immediately if (wUserHall && wUserHallOwner) { loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true, { transition: 'fade-zoom' }); } else if (wFavsUser) { @@ -4726,68 +4754,40 @@ window.cancelAnimFrame = (function () { loadItemAjax(`/${targetKey}`, true, { transition: 'fade-zoom' }); } - // Background grid sync for Onara — fire and forget, user never waits for this + // Background grid sync — page number already in response, no extra fetch needed if (isOnaraActive() && targetId) { - const gridThumbs = Array.from(document.querySelectorAll('.posts > a:not(.notif-item)')); - const alreadyInGrid = gridThumbs.some(a => { - const k = a.pathname.replace(/\/$/, '').split('/').pop(); - return k == targetId || (data.items.slug && k === data.items.slug); - }); - console.log('[random-gridsync] isOnaraActive:', true, 'alreadyInGrid:', alreadyInGrid, 'targetId:', targetId, '_onaraCurrentGridUrl:', window._onaraCurrentGridUrl); + const postsEl = document.querySelector('.posts[data-current-page]'); + const currentPage = postsEl ? parseInt(postsEl.dataset.currentPage, 10) : 1; - if (!alreadyInGrid) { - const pageParams = new URLSearchParams(params); - pageParams.set('id', targetId); - console.log('[random-gridsync] fetching item-page:', `/api/v2/item-page?${pageParams}`); - fetch(`/api/v2/item-page?${pageParams}`) - .then(r => r.json()) - .then(pd => { - console.log('[random-gridsync] item-page response:', pd); - if (!pd.success || !pd.page) return; - const targetPage = pd.page; - const postsEl = document.querySelector('.posts[data-current-page]'); - const currentPage = postsEl ? parseInt(postsEl.dataset.currentPage, 10) : 1; - console.log('[random-gridsync] targetPage:', targetPage, 'currentPage:', currentPage, '_onaraCurrentGridUrl:', window._onaraCurrentGridUrl); - if (targetPage === currentPage) return; + if (targetPage !== currentPage) { + // Build page URL from current grid base + let gridBase = window._onaraCurrentGridUrl + ? window._onaraCurrentGridUrl.replace(/\/p\/\d+/, '').replace(/\/$/, '') + : window.location.pathname + .replace(/\/[a-zA-Z0-9_-]{11}$/, '') + .replace(/\/\d+$/, '') + .replace(/\/p\/\d+/, '') + .replace(/\/$/, ''); + const pageUrl = targetPage === 1 ? (gridBase || '/') : `${gridBase}/p/${targetPage}`; - let gridBase; - if (window._onaraCurrentGridUrl) { - // Strip trailing slash — root '/' becomes '' so '/p/N' builds correctly - gridBase = window._onaraCurrentGridUrl.replace(/\/p\/\d+/, '').replace(/\/$/, ''); - } else { - gridBase = window.location.pathname - .replace(/\/[a-zA-Z0-9_-]{11}$/, '') - .replace(/\/\d+$/, '') - .replace(/\/p\/\d+/, '') - .replace(/\/$/, ''); + fetch(pageUrl) + .then(r => r.text()) + .then(html => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const newPosts = doc.querySelector('.posts'); + const oldPosts = document.querySelector('.posts'); + if (newPosts && oldPosts) { + oldPosts.innerHTML = newPosts.innerHTML; + oldPosts.dataset.currentPage = targetPage; + if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); + } + window._onaraCurrentGridUrl = pageUrl; + if (typeof updateOnaraActiveItem === 'function') { + updateOnaraActiveItem(targetId, `/${targetKey}`); } - const pageUrl = targetPage === 1 ? (gridBase || '/') : `${gridBase}/p/${targetPage}`; - console.log('[random-gridsync] fetching grid page:', pageUrl); - - fetch(pageUrl) - .then(r => r.text()) - .then(html => { - const doc = new DOMParser().parseFromString(html, 'text/html'); - const newPosts = doc.querySelector('.posts'); - const oldPosts = document.querySelector('.posts'); - console.log('[random-gridsync] grid swap: newPosts found:', !!newPosts, 'oldPosts found:', !!oldPosts); - if (newPosts && oldPosts) { - oldPosts.innerHTML = newPosts.innerHTML; - oldPosts.dataset.currentPage = targetPage; - if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); - } - window._onaraCurrentGridUrl = pageUrl; - // Highlight after grid swapped - if (typeof updateOnaraActiveItem === 'function') { - updateOnaraActiveItem(targetId, `/${targetKey}`); - } - }) - .catch((e) => { console.warn('[random-gridsync] grid fetch failed', e); }); }) - .catch((e) => { console.warn('[random-gridsync] item-page fetch failed', e); }); + .catch(() => {}); } - } else { - console.log('[random-gridsync] skipped: isOnaraActive=', isOnaraActive(), 'targetId=', targetId); } } else { window.location.href = link.href; @@ -4827,7 +4827,7 @@ window.cancelAnimFrame = (function () { const anyLink = target.closest('a'); if (anyLink && anyLink.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && anyLink.target !== '_blank') { const pathname = anyLink.pathname; - const isSpecialLink = anyLink.classList.contains('removetag') || anyLink.classList.contains('admin-deltag') || anyLink.classList.contains('mode-btn') || anyLink.classList.contains('btn-approve-async') || anyLink.classList.contains('btn-deny-async') || anyLink.getAttribute('href') === '#'; + const isSpecialLink = anyLink.classList.contains('removetag') || anyLink.classList.contains('admin-deltag') || anyLink.classList.contains('mode-btn') || anyLink.classList.contains('btn-approve-async') || anyLink.classList.contains('btn-deny-async') || anyLink.getAttribute('href') === '#' || !!anyLink.closest('.rating-tag'); const targetUrl = anyLink.href; const currentUrl = window.location.href.split('#')[0]; @@ -5151,168 +5151,175 @@ window.cancelAnimFrame = (function () { fileInput.dataset.itemId = itemId; fileInput.click(); - - } else if (target.closest('button#a_toggle')) { - e.preventDefault(); - const toggleBtn = target.closest('button#a_toggle'); - const itemIdStr = toggleBtn.dataset.itemId || window.getCurrentItemId(); - if (!itemIdStr) return; - const postid = parseInt(itemIdStr, 10); - if (isNaN(postid)) return; - - // Determine current rating - let currentRating = 'sfw'; - if (toggleBtn.classList.contains('is-sfw')) currentRating = 'sfw'; - else if (toggleBtn.classList.contains('is-nsfw')) currentRating = 'nsfw'; - else if (toggleBtn.classList.contains('is-nsfl')) currentRating = 'nsfl'; - else if (toggleBtn.classList.contains('is-untagged')) currentRating = 'untagged'; - - // Cycle SFW -> NSFW -> NSFL (if enabled) -> SFW - let nextRating = 'sfw'; - if (currentRating === 'sfw') { - nextRating = 'nsfw'; - } else if (currentRating === 'nsfw') { - nextRating = (window.f0ckSession && window.f0ckSession.enable_nsfl) ? 'nsfl' : 'sfw'; - } - - const labels = { sfw: 'SFW', nsfw: 'NSFW', nsfl: 'NSFL', untagged: '?' }; - const nextLabel = labels[nextRating] || nextRating.toUpperCase(); - - // Backup current state - const oldClasses = [...toggleBtn.classList]; - const oldInnerHTML = toggleBtn.innerHTML; - - // Track active request ID to ignore out-of-order race conditions on rapid keypresses - const reqId = (toggleBtn._lastCycleReqId || 0) + 1; - toggleBtn._lastCycleReqId = reqId; - - // Increment active requests count to block incoming live SSE tag updates during cycle - toggleBtn._activeRequestsCount = (toggleBtn._activeRequestsCount || 0) + 1; - - // Optimistically apply new state - toggleBtn.classList.remove('is-sfw', 'is-nsfw', 'is-nsfl', 'is-untagged'); - toggleBtn.classList.add(`is-${nextRating}`); - // (icon stays — only CSS class color changes) - - // Optimistically update the sidebar tag list - let originalTags = []; - if (window.renderTags) { - const tagsContainer = document.querySelector("#tags"); - const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null; - if (inner) { - originalTags = [...inner.querySelectorAll(".badge")].filter(badge => { - return !badge.querySelector('#a_addtag') && !badge.querySelector('#a_toggle') && !badge.classList.contains('tag-ac-wrapper'); - }).map(badge => { - const a = badge.querySelector('a[href*="/tag/"]'); - const tagText = a ? a.innerText.trim() : ''; - const normalized = a ? a.getAttribute('href').split('/').pop() : ''; - const badgeClasses = [...badge.classList].filter(c => c !== 'badge' && c !== 'mr-2').join(' '); - return { - tag: tagText, - normalized: normalized, - badge: badgeClasses - }; - }); - - // Create the optimistic rating tag object - const userStr = (window.f0ckSession && window.f0ckSession.user) || ''; - const dispName = (window.f0ckSession && window.f0ckSession.display_name) || userStr; - let newRatingTag = null; - if (nextRating === 'sfw') { - newRatingTag = { id: 1, tag: 'sfw', normalized: 'sfw', badge: 'badge-success', user: userStr, display_name: dispName }; - } else if (nextRating === 'nsfw') { - newRatingTag = { id: 2, tag: 'nsfw', normalized: 'nsfw', badge: 'badge-danger', user: userStr, display_name: dispName }; - } else if (nextRating === 'nsfl') { - const nsfl_id = (window.f0ckSession && window.f0ckSession.nsfl_tag_id) || 3; - newRatingTag = { id: nsfl_id, tag: 'nsfl', normalized: 'nsfl', badge: 'badge-nsfl', user: userStr, display_name: dispName }; - } - - // Combine tags: filter out old rating tags and place the new one at the front - const optimisticTags = []; - if (newRatingTag) { - optimisticTags.push(newRatingTag); - } - originalTags.filter(t => t.normalized !== 'sfw' && t.normalized !== 'nsfw' && t.normalized !== 'nsfl').forEach(t => { - optimisticTags.push(t); - }); - - window.renderTags(optimisticTags); - } - } - - const flashMsg = (window.f0ckI18n && window.f0ckI18n.mode_activated && window.f0ckI18n.mode_activated.replace('{mode}', nextLabel)) || ('RATING UPDATED: ' + nextLabel); - window.flashMessage(flashMsg); - - // Enqueue the request to ensure sequential server execution in correct order - if (!toggleBtn._requestQueue) { - toggleBtn._requestQueue = Promise.resolve(); - } - - toggleBtn._requestQueue = toggleBtn._requestQueue.then(() => { - return fetch(`/api/v2/item/${postid}/rating`, { - method: 'POST', - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": window.f0ckSession?.csrf_token - }, - body: JSON.stringify({ rating: nextRating }) - }) - .then(r => r.json()) - .then(res => { - toggleBtn._activeRequestsCount = Math.max(0, (toggleBtn._activeRequestsCount || 1) - 1); - - // Evict cached item HTML immediately on success so navigating back fetches fresh content, - // even if the user already navigated to a different item while this request was in flight. - if (res && res.success && window.invalidateItemCache) { - window.invalidateItemCache(postid); - } - - // Verify we are still on the same post (prevent dynamic/PJAX page leaks) - const currentIdStr = window.getCurrentItemId(); - const currentPostId = currentIdStr ? parseInt(currentIdStr, 10) : null; - if (currentPostId !== postid) return; - - if (toggleBtn._lastCycleReqId !== reqId) return; // ignore stale responses - if (res.success) { - // Verify visual state and sync tags - toggleBtn.classList.remove('is-sfw', 'is-nsfw', 'is-nsfl', 'is-untagged'); - toggleBtn.classList.add(`is-${res.rating}`); - // icon stays constant — class drives the color - - if (window.renderTags) { - window.renderTags(res.tags); - } - } else { - revert(); - window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error'); - } - }) - .catch(err => { - toggleBtn._activeRequestsCount = Math.max(0, (toggleBtn._activeRequestsCount || 1) - 1); - - // Verify we are still on the same post (prevent dynamic/PJAX page leaks) - const currentIdStr = window.getCurrentItemId(); - const currentPostId = currentIdStr ? parseInt(currentIdStr, 10) : null; - if (currentPostId !== postid) return; - - if (toggleBtn._lastCycleReqId !== reqId) return; - console.error('[RATING_TOGGLE_ERROR]', err); - revert(); - window.flashMessage('Failed to toggle rating', 3000, 'error'); - }); - }); - - function revert() { - toggleBtn.className = ''; - oldClasses.forEach(cls => toggleBtn.classList.add(cls)); - toggleBtn.innerHTML = oldInnerHTML; - if (window.renderTags && originalTags.length > 0) { - window.renderTags(originalTags); - } - } } }); + window._activeRatingCycleRequestsCount = 0; + + window.cycleRating = function(targetEl, explicitPostId) { + 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(); + if (!itemIdStr) return; + const postid = parseInt(itemIdStr, 10); + if (isNaN(postid)) return; + + // Determine current rating + let currentRating = ratingEl.dataset.rating || ''; + if (!currentRating) { + if (ratingEl.classList.contains('is-sfw') || ratingEl.classList.contains('badge-success')) currentRating = 'sfw'; + else if (ratingEl.classList.contains('is-nsfw') || ratingEl.classList.contains('badge-danger')) currentRating = 'nsfw'; + else if (ratingEl.classList.contains('is-nsfl') || ratingEl.classList.contains('badge-nsfl')) currentRating = 'nsfl'; + else if (ratingEl.classList.contains('is-untagged') || ratingEl.classList.contains('badge-untagged')) currentRating = 'untagged'; + else { + const txt = ratingEl.innerText.trim().toLowerCase(); + if (txt === 'sfw') currentRating = 'sfw'; + else if (txt === 'nsfw') currentRating = 'nsfw'; + else if (txt === 'nsfl') currentRating = 'nsfl'; + else currentRating = 'untagged'; + } + } + + // Cycle: SFW -> NSFW -> NSFL (if enabled) -> SFW; untagged -> SFW + let nextRating = 'sfw'; + if (currentRating === 'sfw') { + nextRating = 'nsfw'; + } else if (currentRating === 'nsfw') { + nextRating = (window.f0ckSession && window.f0ckSession.enable_nsfl !== false) ? 'nsfl' : 'sfw'; + } else if (currentRating === 'nsfl') { + nextRating = 'sfw'; + } + + const mapping = { + sfw: { label: 'SFW', text: 'sfw', badgeCls: 'badge-success', isCls: 'is-sfw', toast: '🛡 SFW' }, + nsfw: { label: 'NSFW', text: 'nsfw', badgeCls: 'badge-danger', isCls: 'is-nsfw', toast: '🔥 NSFW' }, + nsfl: { label: 'NSFL', text: 'nsfl', badgeCls: 'badge-nsfl', isCls: 'is-nsfl', toast: '💀 NSFL' }, + untagged: { label: '?', text: 'untagged', badgeCls: 'badge-untagged', isCls: 'is-untagged', toast: '❓ Untagged' } + }; + const info = mapping[nextRating]; + const nextLabel = info.label; + + // Backup current state + const oldClasses = [...ratingEl.classList]; + const oldInnerHTML = ratingEl.innerHTML; + const oldDatasetRating = ratingEl.dataset.rating; + + // Track active request ID to ignore out-of-order race conditions on rapid keypresses + const reqId = (ratingEl._lastCycleReqId || 0) + 1; + ratingEl._lastCycleReqId = reqId; + + // Increment active requests count to block incoming live SSE tag updates during cycle + window._activeRatingCycleRequestsCount = (window._activeRatingCycleRequestsCount || 0) + 1; + ratingEl._activeRequestsCount = (ratingEl._activeRequestsCount || 0) + 1; + + // 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.removeAttribute('title'); + ratingEl.removeAttribute('tooltip'); + ratingEl.removeAttribute('flow'); + + const labelEl = ratingEl.querySelector('.rating-label') || ratingEl; + labelEl.textContent = info.text; + + // Immediately remove any duplicate rating tags from #tags container + const tagsContainer = document.querySelector("#tags"); + if (tagsContainer) { + const allRatingBadges = tagsContainer.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]'); + allRatingBadges.forEach(b => { + if (b !== ratingEl) b.remove(); + }); + } + + // Also update any legacy a_toggle if present + const toggleBtn = document.querySelector('button#a_toggle'); + if (toggleBtn && toggleBtn !== ratingEl) { + toggleBtn.classList.remove('is-sfw', 'is-nsfw', 'is-nsfl', 'is-untagged'); + toggleBtn.classList.add(`is-${nextRating}`); + } + + // Optimistically update media-object data-mode for instant visual blur reaction + const mediaObj = document.querySelector('.media-object'); + if (mediaObj) { + mediaObj.dataset.mode = nextRating; + } + + const flashMsg = (window.f0ckI18n && window.f0ckI18n.mode_activated && window.f0ckI18n.mode_activated.replace('{mode}', nextLabel)) || ('RATING UPDATED: ' + nextLabel); + window.flashMessage(flashMsg); + + // Debounce the network request by 250ms so rapid clicks only dispatch the final state to the server + window._ratingDebounceTimers = window._ratingDebounceTimers || {}; + if (window._ratingDebounceTimers[postid]) { + clearTimeout(window._ratingDebounceTimers[postid]); + } + window._activeRatingCycleRequestsCount = 1; + ratingEl._activeRequestsCount = 1; + + window._ratingDebounceTimers[postid] = setTimeout(() => { + delete window._ratingDebounceTimers[postid]; + const targetRating = ratingEl.dataset.rating || nextRating; + + fetch(`/api/v2/item/${postid}/rating`, { + method: 'POST', + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": window.f0ckSession?.csrf_token + }, + body: JSON.stringify({ rating: targetRating }) + }) + .then(r => r.json()) + .then(res => { + if (!window._ratingDebounceTimers || !window._ratingDebounceTimers[postid]) { + window._activeRatingCycleRequestsCount = 0; + ratingEl._activeRequestsCount = 0; + } + + if (res && res.success && window.invalidateItemCache) { + window.invalidateItemCache(postid); + } + + const currentIdStr = window.getCurrentItemId(); + const currentPostId = currentIdStr ? parseInt(currentIdStr, 10) : null; + if (currentPostId !== postid) return; + + if (ratingEl._lastCycleReqId !== reqId) return; // ignore stale responses + if (res.success) { + if (res.tags && window.renderTags) { + window.renderTags(res.tags); + } + const curMedia = document.querySelector('.media-object'); + if (curMedia && res.rating) { + curMedia.dataset.mode = res.rating; + } + } else { + revert(); + window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error'); + } + }) + .catch(err => { + window._activeRatingCycleRequestsCount = Math.max(0, (window._activeRatingCycleRequestsCount || 1) - 1); + ratingEl._activeRequestsCount = Math.max(0, (ratingEl._activeRequestsCount || 1) - 1); + + const currentIdStr = window.getCurrentItemId(); + const currentPostId = currentIdStr ? parseInt(currentIdStr, 10) : null; + if (currentPostId !== postid) return; + + if (ratingEl._lastCycleReqId !== reqId) return; + console.error('[RATING_TOGGLE_ERROR]', err); + revert(); + window.flashMessage('Failed to toggle rating', 3000, 'error'); + }); + }, 200); + + function revert() { + ratingEl.className = ''; + oldClasses.forEach(cls => ratingEl.classList.add(cls)); + ratingEl.innerHTML = oldInnerHTML; + if (oldDatasetRating !== undefined) ratingEl.dataset.rating = oldDatasetRating; + } + }; + window.addEventListener('popstate', (e) => { // Swallow popstate events fired during Onara close (history.go(-N) unwinding) if (window._onaraSuppressPopstate) return; @@ -5415,7 +5422,13 @@ window.cancelAnimFrame = (function () { "ArrowRight": clickOnNavBinding("#next"), "d": clickOnNavBinding("#next"), "r": clickOnElementBinding("#random, #nav-random"), - "p": clickOnElementBinding("button#a_toggle"), + "p": (e) => { + if (e && e.preventDefault) e.preventDefault(); + const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle'); + if (ratingEl && window.cycleRating) { + window.cycleRating(ratingEl); + } + }, "f": () => { if (localStorage.getItem('fForFullscreen') === 'true') { const fsBtn = document.querySelector('.v0ck_fs_btn'); @@ -9616,8 +9629,9 @@ class NotificationSystem { } // Ignore incoming live SSE tag updates if an optimistic rating cycle is currently active on the page - const toggleBtn = document.querySelector('button#a_toggle'); - if (toggleBtn && toggleBtn._activeRequestsCount > 0) { + const toggleBtn = document.querySelector('button#a_toggle, .rating-tag'); + const hasPendingDebounce = !!(window._ratingDebounceTimers && window._ratingDebounceTimers[data.item_id]); + if (hasPendingDebounce || (window._activeRatingCycleRequestsCount > 0) || (toggleBtn && toggleBtn._activeRequestsCount > 0)) { window.f0ckDebug("[NotificationSystem] Live Tag Update ignored - Optimistic rating cycle in progress."); return; } @@ -9653,21 +9667,60 @@ class NotificationSystem { // Cast to boolean to handle potentially numeric truthy values (1/0) const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator); const hasSession = !!window.f0ckSession; + const canManageItem = !!( + document.querySelector('#tags[data-can-manage="true"]') || + isAdminBySession || + (window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) + ); - window.f0ckDebug(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`); + // Deduplicate rating tags: guarantee at most one rating tag is kept + const ratingTags = data.tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized)); + const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null; + const cleanTags = data.tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag); + + window.f0ckDebug(`[NotificationSystem] Rendering ${cleanTags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`); const fragment = document.createDocumentFragment(); - data.tags.forEach(tag => { + + const hasRatingTag = !!lastRatingTag; + if (!hasRatingTag) { + const untaggedSpan = document.createElement('span'); + untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManageItem ? ' can-cycle' : ''}`; + untaggedSpan.dataset.rating = 'untagged'; + const lbl = document.createElement('span'); + lbl.className = 'rating-label'; + lbl.textContent = 'untagged'; + untaggedSpan.appendChild(lbl); + fragment.appendChild(untaggedSpan); + } + + cleanTags.forEach(tag => { const span = document.createElement('span'); span.className = `badge ${tag.badge}`; - if (hasSession) span.setAttribute('tooltip', tag.display_name || tag.user); + const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized); + if (isRating) { + span.classList.add('rating-tag', `is-${tag.normalized}`); + span.dataset.rating = tag.normalized; + if (canManageItem) { + span.classList.add('can-cycle'); + } + } else if (hasSession) { + span.setAttribute('tooltip', tag.display_name || tag.user); + } - const a = document.createElement('a'); - a.href = `/tag/${tag.normalized}`; - a.textContent = tag.tag; - span.appendChild(a); + let contentEl; + if (isRating) { + contentEl = document.createElement('span'); + contentEl.className = 'rating-label'; + contentEl.textContent = tag.tag; + } else { + contentEl = document.createElement('a'); + contentEl.href = `/tag/${tag.normalized}`; + contentEl.textContent = tag.tag; + } + span.appendChild(contentEl); - if (isAdminBySession) { + if (isAdminBySession && !isRating) { // Match template exactly:   span.insertAdjacentHTML('beforeend', ' '); } @@ -9681,6 +9734,14 @@ class NotificationSystem { } else { inner.appendChild(fragment); } + + // Safeguard against duplicate rating tags in DOM + const allRatingBadges = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]'); + if (allRatingBadges.length > 1) { + for (let i = 1; i < allRatingBadges.length; i++) { + allRatingBadges[i].remove(); + } + } } } diff --git a/public/s/js/tag_autocomplete.js b/public/s/js/tag_autocomplete.js index 174bcd1..285f2f7 100644 --- a/public/s/js/tag_autocomplete.js +++ b/public/s/js/tag_autocomplete.js @@ -18,12 +18,22 @@ window.TagAutocomplete = (() => { const MIN_QUERY_LEN = 1; function destroy() { - if (!activeInstance) return; - const { wrapper } = activeInstance; - if (wrapper && wrapper.parentElement) { - wrapper.parentElement.removeChild(wrapper); + if (activeInstance) { + if (activeInstance.cleanup) { + try { activeInstance.cleanup(); } catch {} + } + const { wrapper } = activeInstance; + if (wrapper && wrapper.parentElement) { + wrapper.parentElement.removeChild(wrapper); + } + activeInstance = null; } - activeInstance = null; + document.querySelectorAll('.tag-ac-wrapper').forEach(el => el.remove()); + } + + function isOpen() { + return !!(activeInstance && activeInstance.wrapper && activeInstance.wrapper.parentElement) || + !!document.querySelector('.tag-ac-wrapper'); } function open(opts) { @@ -216,12 +226,23 @@ window.TagAutocomplete = (() => { input.addEventListener('input', onInput); - input.addEventListener('keydown', (e) => { + const onEscapeKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); destroy(); return; } + }; + + wrapper.addEventListener('keydown', onEscapeKey); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + onEscapeKey(e); + return; + } const items = dropdown.querySelectorAll('.tag-suggestion-item'); if (!items.length || dropdown.style.display === 'none') return; @@ -310,6 +331,14 @@ window.TagAutocomplete = (() => { } }; + const cleanup = () => { + document.removeEventListener('mousedown', onDocMousedown); + document.removeEventListener('touchstart', onDocTouchstart); + if (outsideTapTimer) clearTimeout(outsideTapTimer); + if (debounceTimer) clearTimeout(debounceTimer); + }; + activeInstance.cleanup = cleanup; + // Delay attaching to avoid capturing the opening touch. setTimeout(() => { document.addEventListener('mousedown', onDocMousedown); @@ -338,5 +367,5 @@ window.TagAutocomplete = (() => { }); } - return { open, destroy }; + return { open, destroy, isOpen }; })(); diff --git a/public/s/js/user.js b/public/s/js/user.js index e0431db..3f18549 100644 --- a/public/s/js/user.js +++ b/public/s/js/user.js @@ -47,6 +47,12 @@ if (!tagsContainer) return; const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer; + const canManage = !!( + document.querySelector('#tags[data-can-manage="true"]') || + (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) || + (window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) + ); + // Only remove existing dynamically generated tags [...inner.querySelectorAll(".badge")].forEach(tag => { // Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself @@ -55,24 +61,47 @@ } }); - _tags.reverse().forEach(tag => { - const a = document.createElement("a"); - a.href = `/tag/${tag.normalized}`; - a.style = "color: inherit !important"; - a.textContent = tag.tag; + // Deduplicate: ensure only at most ONE rating tag exists in the tags list + const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized)); + const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null; + const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag); + + const tagsCopy = [...cleanTags]; + tagsCopy.reverse().forEach(tag => { + const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized); + let contentEl; + if (isRating) { + contentEl = document.createElement("span"); + contentEl.className = "rating-label"; + contentEl.textContent = tag.tag; + } else { + contentEl = document.createElement("a"); + contentEl.href = `/tag/${tag.normalized}`; + contentEl.style = "color: inherit !important"; + contentEl.textContent = tag.tag; + } const span = document.createElement("span"); span.classList.add("badge"); if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) { span.classList.add('new-tag-glow'); } - span.setAttribute('tooltip', tag.display_name || tag.user); tag.badge.split(" ").forEach(b => span.classList.add(b)); - span.insertAdjacentElement("beforeend", a); + if (isRating) { + span.classList.add('rating-tag', `is-${tag.normalized}`); + span.dataset.rating = tag.normalized; + if (canManage) { + span.classList.add('can-cycle'); + } + } else if (tag.display_name || tag.user) { + span.setAttribute('tooltip', tag.display_name || tag.user); + } - if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) { + span.insertAdjacentElement("beforeend", contentEl); + + if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator) && !isRating) { const space = document.createTextNode('\u00A0'); //   span.appendChild(space); @@ -83,9 +112,29 @@ span.insertAdjacentElement("beforeend", del); } - inner.insertAdjacentElement("afterbegin", span); + inner.insertAdjacentElement("afterbegin", span); }); + const hasRating = !!lastRatingTag; + if (!hasRating) { + const untaggedSpan = document.createElement("span"); + untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`; + untaggedSpan.dataset.rating = 'untagged'; + const lbl = document.createElement("span"); + lbl.className = "rating-label"; + lbl.textContent = "untagged"; + untaggedSpan.appendChild(lbl); + inner.insertAdjacentElement("afterbegin", untaggedSpan); + } + + // Safeguard: remove any extra rating tags in DOM + const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]'); + if (allRatingEls.length > 1) { + for (let i = 1; i < allRatingEls.length; i++) { + allRatingEls[i].remove(); + } + } + // 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')); @@ -139,25 +188,9 @@ const toggleEvent = async (e) => { if (e) e.preventDefault(); - const ctx = getContext(); - if (!ctx) return; - const { postid } = ctx; - - const res = await (await fetch('/api/v2/tags/' + encodeURIComponent(postid) + '/toggle', { - method: 'PUT', - headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token } - })).json(); - - renderTags(res.tags); - const isNsfw = res.tags.some(t => t.id == 2); - const isUntagged = res.tags.length === 0; - const toggleBtn = document.querySelector('button#a_toggle'); - if (toggleBtn) { - toggleBtn.classList.toggle('is-nsfw', isNsfw && !isUntagged); - toggleBtn.classList.toggle('is-sfw', !isNsfw && !isUntagged); - toggleBtn.classList.toggle('is-untagged', isUntagged); - const labels = { true: 'NSFW', false: 'SFW' }; - toggleBtn.textContent = isUntagged ? '?' : (isNsfw ? 'NSFW' : 'SFW'); + const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle'); + if (window.cycleRating) { + window.cycleRating(ratingEl); } }; diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index 9d5c8e6..6d41ec8 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -290,16 +290,24 @@ export default new class { left join "tags" on "tags".id = "tags_assign".tag_id ${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``} where "tags_assign".item_id = ${+itemid} - order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc + order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc `; + let hasRating = false; + const cleanTags = []; for (let t = 0; t < tags.length; t++) { + const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized); + if (isRating) { + if (hasRating) continue; + hasRating = true; + } tags[t].badge = this.getBadge(tags[t]); if (!hasSession) { delete tags[t].user; delete tags[t].display_name; } + cleanTags.push(tags[t]); } - return tags; + return cleanTags; }; getBadge(tagObj) { if (tagObj.tag.startsWith(">")) diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 531c6dc..1ccc2c8 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -138,7 +138,7 @@ export default (router, tpl) => { const session = data.session; const item = data.item; data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); - data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username)); + data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user)); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 3f4f22f..1815bcb 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -650,12 +650,28 @@ export default router => { }); } - const rows = await db` - SELECT * - FROM "items" - WHERE id = ${data.itemid} AND active = true - LIMIT 1 - `; + // Run item fetch + page lookup in parallel — saves one sequential DB round-trip + const [rows, itemPage] = await Promise.all([ + db` + SELECT * + FROM "items" + WHERE id = ${data.itemid} AND active = true + LIMIT 1 + `, + f0cklib.getItemPage({ + targetItemId: data.itemid, + user, tag, hall, userHall, userHallOwner, mime, + fav: isFav, + mode, + ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, + strict: isStrict, + session: !!req.session, + exclude: req.session?.excluded_tags || [], + user_id: req.session?.id, + is_admin: req.session?.admin + }).catch(() => 1) + ]); + const item = rows[0]; if (!item) { @@ -685,7 +701,8 @@ export default router => { slug: (getEnableItemSlugs() && item.slug) ? item.slug : null, dest: relativeDest, url: directUrl, - direct_url: directUrl + direct_url: directUrl, + page: itemPage } }); }); @@ -1436,49 +1453,63 @@ export default router => { return res.json({ success: false, msg: 'Item not found' }, 404); } - const isOwner = item[0].username === req.session.user; - const isAdmin = req.session.admin || req.session.is_moderator; + const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase()); + const isAdmin = !!(req.session.admin || req.session.is_moderator); if (!isOwner && !isAdmin) { return res.json({ success: false, msg: 'Unauthorized' }, 403); } - const nsfl_id = cfg.nsfl_tag_id || 3; - const existingRating = await db` - SELECT tag_id FROM tags_assign - WHERE item_id = ${itemid} AND tag_id IN (1, 2, ${nsfl_id}) - LIMIT 1 - `; - - const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null; + const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`; + const nsfl_id = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517); let newRatingId; - const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating; - if (reqRating === 'sfw') { - newRatingId = 1; - } else if (reqRating === 'nsfw') { - newRatingId = 2; - } else if (reqRating === 'nsfl') { - newRatingId = nsfl_id; - } else { - // fallback to cycling - if (currentRatingId === 1) { - newRatingId = 2; // SFW -> NSFW - } else if (currentRatingId === 2) { - newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW - } else { - newRatingId = 1; // NSFL or none -> SFW - } - } + let currentRatingId = null; await db.begin(async sql => { - // Remove old rating tags - await sql`DELETE FROM tags_assign WHERE item_id = ${itemid} AND tag_id IN (1, 2, ${nsfl_id})`; + // Lock the item row exclusively so any concurrent rating update for this post MUST wait + await sql`SELECT id FROM items WHERE id = ${itemid} FOR UPDATE`; + + const existingRating = await sql` + SELECT tag_id FROM tags_assign + WHERE item_id = ${itemid} + AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))) + ORDER BY tag_id DESC + LIMIT 1 + `; + + currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null; + const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating; + if (reqRating === 'sfw') { + newRatingId = 1; + } else if (reqRating === 'nsfw') { + newRatingId = 2; + } else if (reqRating === 'nsfl') { + newRatingId = nsfl_id; + } else { + // fallback to cycling + if (currentRatingId === 1) { + newRatingId = 2; // SFW -> NSFW + } else if (currentRatingId === 2) { + newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW + } else { + newRatingId = 1; // NSFL or none -> SFW + } + } + + // Remove ALL existing rating tags for this item atomically + await sql` + DELETE FROM tags_assign + WHERE item_id = ${itemid} + AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))) + `; // Insert new rating tag - await sql` - INSERT INTO tags_assign (item_id, tag_id, user_id) - VALUES (${itemid}, ${newRatingId}, ${req.session.id}) - `; + if (newRatingId > 0) { + await sql` + INSERT INTO tags_assign (item_id, tag_id, user_id) + VALUES (${itemid}, ${newRatingId}, ${req.session.id}) + `; + } // Ensure blurred thumbnail exists await queue.genBlurredThumbnail(itemid).catch(err => { diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs index f77eb36..7d189c4 100644 --- a/src/inc/routes/apiv2/tags.mjs +++ b/src/inc/routes/apiv2/tags.mjs @@ -97,53 +97,91 @@ export default router => { }); }); - group.put(/\/cycle-rating$/, lib.modAuth, async (req, res) => { + group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => { if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' }); const postid = +req.params.postid; - const nsflId = cfg.nsfl_tag_id || 3; - // Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW - const cycle = [1, 2, nsflId]; - const currentTags = await lib.getTags(postid); - const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0; - - let nextTagId; - const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating; - if (reqRating === 'sfw') { - nextTagId = 1; - } else if (reqRating === 'nsfw') { - nextTagId = 2; - } else if (reqRating === 'nsfl') { - nextTagId = nsflId; - } else { - const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW - nextTagId = cycle[(cycleIdx + 1) % cycle.length]; + const item = await db` + SELECT id, username, active, is_deleted + FROM items + WHERE id = ${postid} AND active = true AND is_deleted = false + LIMIT 1 + `; + if (item.length === 0) { + return res.json({ success: false, msg: 'Item not found' }, 404); } - try { - // Remove any existing rating tag - await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`; - if (nextTagId > 0) { - await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`; - } + const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase()); + const isAdmin = !!(req.session.admin || req.session.is_moderator); + if (!isOwner && !isAdmin) { + return res.json({ success: false, msg: 'Unauthorized' }, 403); + } - // Automatically generate/verify blurred thumbnail on cycle - const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`); - try { - await fs.promises.access(blurPath); - } catch { - await queue.genBlurredThumbnail(postid, false); - } + 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 cycle = [1, 2, nsflId]; + let nextTagId; + let ratingTagId = 0; + + try { + await db.begin(async sql => { + // Lock the item row exclusively + await sql`SELECT id FROM items WHERE id = ${postid} FOR UPDATE`; + + const existingRating = await sql` + SELECT tag_id FROM tags_assign + WHERE item_id = ${postid} + AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))) + ORDER BY tag_id DESC + LIMIT 1 + `; + + ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0; + const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating; + if (reqRating === 'sfw') { + nextTagId = 1; + } else if (reqRating === 'nsfw') { + nextTagId = 2; + } else if (reqRating === 'nsfl') { + nextTagId = nsflId; + } else { + const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW + nextTagId = cycle[(cycleIdx + 1) % cycle.length]; + } + + // Remove ALL existing rating tags for this item atomically + await sql` + DELETE FROM tags_assign + WHERE item_id = ${postid} + AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))) + `; + + if (nextTagId > 0) { + await sql` + INSERT INTO tags_assign (item_id, tag_id, user_id) + VALUES (${postid}, ${nextTagId}, ${+req.session.id}) + `; + } + + // Automatically generate/verify blurred thumbnail on cycle + const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`); + try { + await fs.promises.access(blurPath); + } catch { + await queue.genBlurredThumbnail(postid, false); + } + }); const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } }; - const { label, cls } = labels[nextTagId]; + const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' }; - await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }); + await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {}); const freshTags = await lib.getTags(postid); - await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })); + await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {}); return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls }); } catch (err) { + console.error('[CYCLE_RATING_ERROR]', err); return res.json({ success: false, msg: 'Failed to update rating' }); } }); diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 875fbaa..259171d 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -371,7 +371,7 @@ export default (router, tpl) => { // Is the current user a moderator/admin? data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); // Can the current user manage this item (owner, admin, or mod)? - data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username)); + data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); // Is the item's MIME type suitable for metadata extraction? // YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible. data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index 8db9e6a..ebac59b 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -162,7 +162,7 @@ export default (router, tpl) => { const session = data.session; const item = data.item; data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); - data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username)); + data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user)); data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index 58c02c7..2e0649b 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -170,13 +170,24 @@ @endif - + + @if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl) + + untagged + + @endif @if(typeof item.tags !== "undefined") @each(item.tags as tag) + @if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl') + + {!! tag.tag !!} + + @else {!! tag.tag !!}@if(is_mod_or_admin) @endif + @endif @endeach @endif @@ -188,9 +199,6 @@ - @if(can_manage_item) - - @endif @endif diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html index 3b26e8d..621a7a9 100644 --- a/views/item-partial-modern.html +++ b/views/item-partial-modern.html @@ -32,19 +32,27 @@ - @if(can_manage_item) - - @endif @endif