make quick cycling great again

This commit is contained in:
2026-05-24 09:25:05 +02:00
parent 2bce856153
commit 2ab4ae06af
4 changed files with 176 additions and 41 deletions

View File

@@ -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!");

View File

@@ -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 => {

View File

@@ -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

View File

@@ -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,