From 2ab4ae06aff137a2c37e7e4da8e923b7d15f878e Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 24 May 2026 09:25:05 +0200 Subject: [PATCH] make quick cycling great again --- public/s/js/f0ckm.js | 181 ++++++++++++++++++++++++++------- src/inc/routes/apiv2/index.mjs | 20 +++- src/inc/routes/apiv2/tags.mjs | 15 ++- views/snippets/footer.html | 1 + 4 files changed, 176 insertions(+), 41 deletions(-) diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index c625422..23768d2 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -3406,44 +3406,150 @@ window.cancelAnimFrame = (function () { if (!idLink) return; const postid = +idLink.innerText; - fetch(`/api/v2/item/${postid}/rating`, { - method: 'POST', - headers: { - "X-CSRF-Token": window.f0ckSession?.csrf_token - } - }) - .then(r => r.json()) - .then(res => { - if (res.success) { - // Update visual state (handled by classes in css) - toggleBtn.classList.remove('is-sfw', 'is-nsfw', 'is-nsfl', 'is-untagged'); - toggleBtn.classList.add(`is-${res.rating}`); - // Update label text - const labels = { sfw: 'SFW', nsfw: 'NSFW', nsfl: 'NSFL', untagged: '?' }; - toggleBtn.textContent = labels[res.rating] || res.rating.toUpperCase(); - - window.flashMessage(`${(window.f0ckI18n && window.f0ckI18n.mode_activated && window.f0ckI18n.mode_activated.replace('{mode}', res.rating.toUpperCase())) || ('RATING UPDATED: ' + res.rating.toUpperCase())}`); + // 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'; - // Update tags in sidebar - if (window.renderTags) { - window.renderTags(res.tags); - } else { - // Fallback: manually update tags if renderTags is not global yet (or if not in admin mode) - const tagsContainer = document.getElementById('tags'); - if (tagsContainer) { - // We could rebuild the whole tag list, but most items already have a rating tag - // A simpler way is to trigger a page refresh if needed, but let's try to be subtle - // For now, let's just show the flash message as the toggle itself changes color - } + // 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 oldTextContent = toggleBtn.textContent; + + // 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}`); + toggleBtn.textContent = nextLabel; + + // 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 }; } - } else { - window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error'); + + // 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); } - }) - .catch(err => { - console.error('[RATING_TOGGLE_ERROR]', err); - window.flashMessage('Failed to toggle rating', 3000, 'error'); + } + + 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); + + // Verify we are still on the same post (prevent dynamic/PJAX page leaks) + const activeIdLink = document.querySelector("a.id-link"); + const currentPostId = activeIdLink ? +activeIdLink.innerText : 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}`); + toggleBtn.textContent = labels[res.rating] || res.rating.toUpperCase(); + + 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 activeIdLink = document.querySelector("a.id-link"); + const currentPostId = activeIdLink ? +activeIdLink.innerText : 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.textContent = oldTextContent; + if (window.renderTags && originalTags.length > 0) { + window.renderTags(originalTags); + } + } } }); @@ -6527,6 +6633,13 @@ class NotificationSystem { return; } + // 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) { + window.f0ckDebug("[NotificationSystem] Live Tag Update ignored - Optimistic rating cycle in progress."); + return; + } + const tagsContainer = document.querySelector('#tags'); if (!tagsContainer) { console.warn("[NotificationSystem] #tags container not found!"); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index f0a6f67..0add98f 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -1064,12 +1064,22 @@ export default router => { const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null; let newRatingId; - if (currentRatingId === 1) { - newRatingId = 2; // SFW -> NSFW - } else if (currentRatingId === 2) { - newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW + 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 { - newRatingId = 1; // NSFL or none -> SFW + // 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 + } } await db.begin(async sql => { diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs index 0ff5bc5..fddefa6 100644 --- a/src/inc/routes/apiv2/tags.mjs +++ b/src/inc/routes/apiv2/tags.mjs @@ -105,8 +105,19 @@ export default router => { const cycle = [1, 2, nsflId]; const currentTags = await lib.getTags(postid); const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0; - const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW - const nextTagId = cycle[(cycleIdx + 1) % cycle.length]; + + 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]; + } try { // Remove any existing rating tag diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 552a5c9..916306c 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -387,6 +387,7 @@ disable_swiping: @if(session && session.disable_swiping) true @else false @endif, show_background: @if(session)@if(session.show_background !== false) true @else false @endif@else @if(show_background_cfg) true @else false @endif@endif, nsfl_tag_id: {{ nsfl_tag_id }}, + enable_nsfl: @if(enable_nsfl) true @else false @endif, user: @if(session) "{{ session.user }}" @else null @endif, display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif, is_admin: @if(session && session.admin) true @else false @endif,