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