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
+46 -5
View File
@@ -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 {
-1
View File
@@ -621,7 +621,6 @@
.rating-label {
display: block;
padding: 0.1rem 2rem;
border-radius: 0;
border: 2px solid transparent;
transition: all 0.2s;
+59 -7
View File
@@ -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));
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(contentEl);
if (!isRating) {
const delbutton = document.createElement("a");
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
delbutton.href = "javascript:void(0)";
// Class for delegation
delbutton.classList.add("admin-deltag", "removetag");
span.appendChild(a);
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'));
+202 -141
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) {
@@ -4716,8 +4743,9 @@ window.cancelAnimFrame = (function () {
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 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,43 +4754,21 @@ 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);
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;
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
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}`;
console.log('[random-gridsync] fetching grid page:', pageUrl);
fetch(pageUrl)
.then(r => r.text())
@@ -4770,24 +4776,18 @@ window.cancelAnimFrame = (function () {
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,167 +5151,174 @@ 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();
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 = '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';
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
// 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) ? 'nsfl' : 'sfw';
nextRating = (window.f0ckSession && window.f0ckSession.enable_nsfl !== false) ? 'nsfl' : 'sfw';
} else if (currentRating === 'nsfl') {
nextRating = 'sfw';
}
const labels = { sfw: 'SFW', nsfw: 'NSFW', nsfl: 'NSFL', untagged: '?' };
const nextLabel = labels[nextRating] || nextRating.toUpperCase();
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 = [...toggleBtn.classList];
const oldInnerHTML = toggleBtn.innerHTML;
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 = (toggleBtn._lastCycleReqId || 0) + 1;
toggleBtn._lastCycleReqId = reqId;
const reqId = (ratingEl._lastCycleReqId || 0) + 1;
ratingEl._lastCycleReqId = reqId;
// Increment active requests count to block incoming live SSE tag updates during cycle
toggleBtn._activeRequestsCount = (toggleBtn._activeRequestsCount || 0) + 1;
window._activeRatingCycleRequestsCount = (window._activeRatingCycleRequestsCount || 0) + 1;
ratingEl._activeRequestsCount = (ratingEl._activeRequestsCount || 0) + 1;
// Optimistically apply new state
// 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}`);
// (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);
}
// 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);
// Enqueue the request to ensure sequential server execution in correct order
if (!toggleBtn._requestQueue) {
toggleBtn._requestQueue = Promise.resolve();
// 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;
toggleBtn._requestQueue = toggleBtn._requestQueue.then(() => {
return fetch(`/api/v2/item/${postid}/rating`, {
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: nextRating })
body: JSON.stringify({ rating: targetRating })
})
.then(r => r.json())
.then(res => {
toggleBtn._activeRequestsCount = Math.max(0, (toggleBtn._activeRequestsCount || 1) - 1);
if (!window._ratingDebounceTimers || !window._ratingDebounceTimers[postid]) {
window._activeRatingCycleRequestsCount = 0;
ratingEl._activeRequestsCount = 0;
}
// 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 (ratingEl._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) {
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 => {
toggleBtn._activeRequestsCount = Math.max(0, (toggleBtn._activeRequestsCount || 1) - 1);
window._activeRatingCycleRequestsCount = Math.max(0, (window._activeRatingCycleRequestsCount || 1) - 1);
ratingEl._activeRequestsCount = Math.max(0, (ratingEl._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;
if (ratingEl._lastCycleReqId !== reqId) return;
console.error('[RATING_TOGGLE_ERROR]', err);
revert();
window.flashMessage('Failed to toggle rating', 3000, 'error');
});
});
}, 200);
function revert() {
toggleBtn.className = '';
oldClasses.forEach(cls => toggleBtn.classList.add(cls));
toggleBtn.innerHTML = oldInnerHTML;
if (window.renderTags && originalTags.length > 0) {
window.renderTags(originalTags);
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)
@@ -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();
}
}
}
}
+32 -3
View File
@@ -18,13 +18,23 @@ window.TagAutocomplete = (() => {
const MIN_QUERY_LEN = 1;
function destroy() {
if (!activeInstance) return;
if (activeInstance) {
if (activeInstance.cleanup) {
try { activeInstance.cleanup(); } catch {}
}
const { wrapper } = activeInstance;
if (wrapper && wrapper.parentElement) {
wrapper.parentElement.removeChild(wrapper);
}
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) {
const { postid, existingTags, anchorEl, onSubmit, renderTags } = 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 };
})();
+60 -27
View File
@@ -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'); // &nbsp;
span.appendChild(space);
@@ -86,6 +115,26 @@
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);
}
};
+10 -2
View File
@@ -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(">"))
+1 -1
View File
@@ -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(',') : '';
+44 -13
View File
@@ -650,12 +650,28 @@ export default router => {
});
}
const rows = await db`
// 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,22 +1453,31 @@ 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`
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;
let currentRatingId = null;
await db.begin(async sql => {
// 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})
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
`;
const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
let newRatingId;
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;
@@ -1470,15 +1496,20 @@ export default router => {
}
}
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})`;
// 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
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 => {
+51 -13
View File
@@ -97,17 +97,46 @@ 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;
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);
}
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 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;
@@ -120,11 +149,18 @@ export default router => {
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
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[])`;
// 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 db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
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
@@ -134,16 +170,18 @@ export default router => {
} 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' });
}
});
+1 -1
View File
@@ -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])));
+1 -1
View File
@@ -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(',') : '';
+12 -4
View File
@@ -170,13 +170,24 @@
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@endif
</div>
<span class="badge badge-dark" id="tags">
<span class="badge badge-dark" id="tags" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}">
<span class="tags-inner">
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
<span class="badge badge-untagged rating-tag is-untagged @if(can_manage_item) can-cycle @endif" data-rating="untagged">
<span class="rating-label">untagged</span>
</span>
@endif
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_manage_item) can-cycle @endif" data-rating="{{ tag.normalized }}">
<span class="rating-label">{!! tag.tag !!}</span>
</span>
@else
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endif
@endeach
@endif
</span>
@@ -188,9 +199,6 @@
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
<i class="fa-solid fa-plus"></i>
</a>
@if(can_manage_item)
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@endif
</div>
@endif
</span>
+12 -4
View File
@@ -32,19 +32,27 @@
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
<i class="fa-solid fa-plus"></i>
</a>
@if(can_manage_item)
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@endif
</div>
@endif
<div class="sidebar-tags-container">
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
<span class="badge badge-dark" id="tags" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
<span class="badge badge-untagged rating-tag is-untagged @if(can_manage_item) can-cycle @endif" data-rating="untagged">
<span class="rating-label">untagged</span>
</span>
@endif
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_manage_item) can-cycle @endif" data-rating="{{ tag.normalized }}">
<span class="rating-label">{!! tag.tag !!}</span>
</span>
@else
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endif
@endeach
@endif
</span>