This commit is contained in:
2026-09-11 19:00:58 +02:00
parent 8d6edfef5e
commit d72eae7509
14 changed files with 681 additions and 373 deletions
+292 -231
View File
@@ -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 <a href="/mode/..."> 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: &nbsp;<a class="removetag admin-deltag" href="#"><i class="fa-solid fa-xmark"></i></a>
span.insertAdjacentHTML('beforeend', '&nbsp;<a class="removetag admin-deltag" href="#"><i class="fa-solid fa-xmark"></i></a>');
}
@@ -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();
}
}
}
}