gsdfgsdf
This commit is contained in:
+46
-5
@@ -6871,8 +6871,6 @@ span#tags:not(.tags-expanded) .tags-inner>span:nth-child(n+11) {
|
|||||||
background-color: var(--badge-sfw);
|
background-color: var(--badge-sfw);
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
padding-top: 1.5px;
|
|
||||||
padding-bottom: 1.5px;
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-shadow: 1px 1px var(--black);
|
text-shadow: 1px 1px var(--black);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -6883,8 +6881,6 @@ span#tags:not(.tags-expanded) .tags-inner>span:nth-child(n+11) {
|
|||||||
background-color: var(--badge-nsfw);
|
background-color: var(--badge-nsfw);
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
padding-top: 1.5px;
|
|
||||||
padding-bottom: 1.5px;
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-shadow: 1px 1px var(--black);
|
text-shadow: 1px 1px var(--black);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -13024,7 +13020,7 @@ body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-
|
|||||||
color: #dc3545 !important;
|
color: #dc3545 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rating Button — icon-only, badge-proportioned */
|
/* Rating Button — icon-only, badge-proportioned (backward compatibility) */
|
||||||
#a_toggle.rating-btn {
|
#a_toggle.rating-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -13062,6 +13058,51 @@ body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-
|
|||||||
background-color: #444;
|
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 {
|
.rules {
|
||||||
|
|||||||
@@ -621,7 +621,6 @@
|
|||||||
|
|
||||||
.rating-label {
|
.rating-label {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.1rem 2rem;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|||||||
+66
-14
@@ -51,6 +51,12 @@
|
|||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
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
|
// Only remove existing dynamically generated tags
|
||||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||||
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
// 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 => {
|
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
|
||||||
const a = document.createElement("a");
|
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||||
a.href = `/tag/${tag.normalized}`;
|
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
|
||||||
a.style = "color: inherit !important";
|
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
|
||||||
a.textContent = tag.tag;
|
|
||||||
|
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");
|
const span = document.createElement("span");
|
||||||
span.classList.add("badge");
|
span.classList.add("badge");
|
||||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
span.classList.add('new-tag-glow');
|
span.classList.add('new-tag-glow');
|
||||||
}
|
}
|
||||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
|
||||||
|
|
||||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||||
|
|
||||||
const delbutton = document.createElement("a");
|
if (isRating) {
|
||||||
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
span.classList.add('rating-tag', `is-${tag.normalized}`);
|
||||||
delbutton.href = "javascript:void(0)";
|
span.dataset.rating = tag.normalized;
|
||||||
// Class for delegation
|
if (canManage) {
|
||||||
delbutton.classList.add("admin-deltag", "removetag");
|
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(contentEl);
|
||||||
span.appendChild(document.createTextNode('\u00A0'));
|
|
||||||
span.appendChild(delbutton);
|
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(document.createTextNode('\u00A0'));
|
||||||
|
span.appendChild(delbutton);
|
||||||
|
}
|
||||||
|
|
||||||
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
|
// Handle show more/less toggle visibility and count
|
||||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||||
|
|||||||
+292
-231
@@ -38,7 +38,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.getCurrentItemId = () => {
|
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) {
|
if (idEl && idEl.dataset && idEl.dataset.itemId) {
|
||||||
const parsed = parseInt(idEl.dataset.itemId, 10);
|
const parsed = parseInt(idEl.dataset.itemId, 10);
|
||||||
if (!isNaN(parsed)) return String(parsed);
|
if (!isNaN(parsed)) return String(parsed);
|
||||||
@@ -1299,6 +1299,10 @@ window.cancelAnimFrame = (function () {
|
|||||||
'#comments-container',
|
'#comments-container',
|
||||||
'.tag-controls',
|
'.tag-controls',
|
||||||
'.sidebar-tags-container',
|
'.sidebar-tags-container',
|
||||||
|
'#tags',
|
||||||
|
'.rating-tag',
|
||||||
|
'.rating-label',
|
||||||
|
'.item-sidebar-left',
|
||||||
'.xd-score-wrapper',
|
'.xd-score-wrapper',
|
||||||
'.user-infobox-block',
|
'.user-infobox-block',
|
||||||
'a',
|
'a',
|
||||||
@@ -1762,6 +1766,15 @@ window.cancelAnimFrame = (function () {
|
|||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
return;
|
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')) {
|
if (document.body.classList.contains('onara-modal-open')) {
|
||||||
closeOnaraModal();
|
closeOnaraModal();
|
||||||
}
|
}
|
||||||
@@ -4530,6 +4543,20 @@ window.cancelAnimFrame = (function () {
|
|||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
|
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)
|
// Check for mode selection (only applies to <a href="/mode/..."> links, not plain buttons)
|
||||||
const modeBtn = target.closest('.mode-btn');
|
const modeBtn = target.closest('.mode-btn');
|
||||||
if (modeBtn && modeBtn.href && modeBtn.href.includes('/mode/') && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
|
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.items && (data.items.slug || data.items.id)) {
|
if (data.success && data.items && (data.items.slug || data.items.id)) {
|
||||||
const targetKey = data.items.slug || data.items.id;
|
const targetKey = data.items.slug || data.items.id;
|
||||||
const targetId = 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) {
|
if (wUserHall && wUserHallOwner) {
|
||||||
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true, { transition: 'fade-zoom' });
|
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true, { transition: 'fade-zoom' });
|
||||||
} else if (wFavsUser) {
|
} else if (wFavsUser) {
|
||||||
@@ -4726,68 +4754,40 @@ window.cancelAnimFrame = (function () {
|
|||||||
loadItemAjax(`/${targetKey}`, true, { transition: 'fade-zoom' });
|
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) {
|
if (isOnaraActive() && targetId) {
|
||||||
const gridThumbs = Array.from(document.querySelectorAll('.posts > a:not(.notif-item)'));
|
const postsEl = document.querySelector('.posts[data-current-page]');
|
||||||
const alreadyInGrid = gridThumbs.some(a => {
|
const currentPage = postsEl ? parseInt(postsEl.dataset.currentPage, 10) : 1;
|
||||||
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) {
|
if (targetPage !== currentPage) {
|
||||||
const pageParams = new URLSearchParams(params);
|
// Build page URL from current grid base
|
||||||
pageParams.set('id', targetId);
|
let gridBase = window._onaraCurrentGridUrl
|
||||||
console.log('[random-gridsync] fetching item-page:', `/api/v2/item-page?${pageParams}`);
|
? window._onaraCurrentGridUrl.replace(/\/p\/\d+/, '').replace(/\/$/, '')
|
||||||
fetch(`/api/v2/item-page?${pageParams}`)
|
: window.location.pathname
|
||||||
.then(r => r.json())
|
.replace(/\/[a-zA-Z0-9_-]{11}$/, '')
|
||||||
.then(pd => {
|
.replace(/\/\d+$/, '')
|
||||||
console.log('[random-gridsync] item-page response:', pd);
|
.replace(/\/p\/\d+/, '')
|
||||||
if (!pd.success || !pd.page) return;
|
.replace(/\/$/, '');
|
||||||
const targetPage = pd.page;
|
const pageUrl = targetPage === 1 ? (gridBase || '/') : `${gridBase}/p/${targetPage}`;
|
||||||
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;
|
fetch(pageUrl)
|
||||||
if (window._onaraCurrentGridUrl) {
|
.then(r => r.text())
|
||||||
// Strip trailing slash — root '/' becomes '' so '/p/N' builds correctly
|
.then(html => {
|
||||||
gridBase = window._onaraCurrentGridUrl.replace(/\/p\/\d+/, '').replace(/\/$/, '');
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
} else {
|
const newPosts = doc.querySelector('.posts');
|
||||||
gridBase = window.location.pathname
|
const oldPosts = document.querySelector('.posts');
|
||||||
.replace(/\/[a-zA-Z0-9_-]{11}$/, '')
|
if (newPosts && oldPosts) {
|
||||||
.replace(/\/\d+$/, '')
|
oldPosts.innerHTML = newPosts.innerHTML;
|
||||||
.replace(/\/p\/\d+/, '')
|
oldPosts.dataset.currentPage = targetPage;
|
||||||
.replace(/\/$/, '');
|
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 {
|
} else {
|
||||||
window.location.href = link.href;
|
window.location.href = link.href;
|
||||||
@@ -4827,7 +4827,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
const anyLink = target.closest('a');
|
const anyLink = target.closest('a');
|
||||||
if (anyLink && anyLink.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && anyLink.target !== '_blank') {
|
if (anyLink && anyLink.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && anyLink.target !== '_blank') {
|
||||||
const pathname = anyLink.pathname;
|
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 targetUrl = anyLink.href;
|
||||||
const currentUrl = window.location.href.split('#')[0];
|
const currentUrl = window.location.href.split('#')[0];
|
||||||
@@ -5151,168 +5151,175 @@ window.cancelAnimFrame = (function () {
|
|||||||
|
|
||||||
fileInput.dataset.itemId = itemId;
|
fileInput.dataset.itemId = itemId;
|
||||||
fileInput.click();
|
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) => {
|
window.addEventListener('popstate', (e) => {
|
||||||
// Swallow popstate events fired during Onara close (history.go(-N) unwinding)
|
// Swallow popstate events fired during Onara close (history.go(-N) unwinding)
|
||||||
if (window._onaraSuppressPopstate) return;
|
if (window._onaraSuppressPopstate) return;
|
||||||
@@ -5415,7 +5422,13 @@ window.cancelAnimFrame = (function () {
|
|||||||
"ArrowRight": clickOnNavBinding("#next"),
|
"ArrowRight": clickOnNavBinding("#next"),
|
||||||
"d": clickOnNavBinding("#next"),
|
"d": clickOnNavBinding("#next"),
|
||||||
"r": clickOnElementBinding("#random, #nav-random"),
|
"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": () => {
|
"f": () => {
|
||||||
if (localStorage.getItem('fForFullscreen') === 'true') {
|
if (localStorage.getItem('fForFullscreen') === 'true') {
|
||||||
const fsBtn = document.querySelector('.v0ck_fs_btn');
|
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
|
// Ignore incoming live SSE tag updates if an optimistic rating cycle is currently active on the page
|
||||||
const toggleBtn = document.querySelector('button#a_toggle');
|
const toggleBtn = document.querySelector('button#a_toggle, .rating-tag');
|
||||||
if (toggleBtn && toggleBtn._activeRequestsCount > 0) {
|
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.");
|
window.f0ckDebug("[NotificationSystem] Live Tag Update ignored - Optimistic rating cycle in progress.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9653,21 +9667,60 @@ class NotificationSystem {
|
|||||||
// Cast to boolean to handle potentially numeric truthy values (1/0)
|
// Cast to boolean to handle potentially numeric truthy values (1/0)
|
||||||
const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator);
|
const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator);
|
||||||
const hasSession = !!window.f0ckSession;
|
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();
|
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');
|
const span = document.createElement('span');
|
||||||
span.className = `badge ${tag.badge}`;
|
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');
|
let contentEl;
|
||||||
a.href = `/tag/${tag.normalized}`;
|
if (isRating) {
|
||||||
a.textContent = tag.tag;
|
contentEl = document.createElement('span');
|
||||||
span.appendChild(a);
|
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: <a class="removetag admin-deltag" href="#"><i class="fa-solid fa-xmark"></i></a>
|
// Match template exactly: <a class="removetag admin-deltag" href="#"><i class="fa-solid fa-xmark"></i></a>
|
||||||
span.insertAdjacentHTML('beforeend', ' <a class="removetag admin-deltag" href="#"><i class="fa-solid fa-xmark"></i></a>');
|
span.insertAdjacentHTML('beforeend', ' <a class="removetag admin-deltag" href="#"><i class="fa-solid fa-xmark"></i></a>');
|
||||||
}
|
}
|
||||||
@@ -9681,6 +9734,14 @@ class NotificationSystem {
|
|||||||
} else {
|
} else {
|
||||||
inner.appendChild(fragment);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,22 @@ window.TagAutocomplete = (() => {
|
|||||||
const MIN_QUERY_LEN = 1;
|
const MIN_QUERY_LEN = 1;
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
if (!activeInstance) return;
|
if (activeInstance) {
|
||||||
const { wrapper } = activeInstance;
|
if (activeInstance.cleanup) {
|
||||||
if (wrapper && wrapper.parentElement) {
|
try { activeInstance.cleanup(); } catch {}
|
||||||
wrapper.parentElement.removeChild(wrapper);
|
}
|
||||||
|
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) {
|
function open(opts) {
|
||||||
@@ -216,12 +226,23 @@ window.TagAutocomplete = (() => {
|
|||||||
|
|
||||||
input.addEventListener('input', onInput);
|
input.addEventListener('input', onInput);
|
||||||
|
|
||||||
input.addEventListener('keydown', (e) => {
|
const onEscapeKey = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
destroy();
|
destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapper.addEventListener('keydown', onEscapeKey);
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onEscapeKey(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const items = dropdown.querySelectorAll('.tag-suggestion-item');
|
const items = dropdown.querySelectorAll('.tag-suggestion-item');
|
||||||
if (!items.length || dropdown.style.display === 'none') return;
|
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.
|
// Delay attaching to avoid capturing the opening touch.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('mousedown', onDocMousedown);
|
document.addEventListener('mousedown', onDocMousedown);
|
||||||
@@ -338,5 +367,5 @@ window.TagAutocomplete = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { open, destroy };
|
return { open, destroy, isOpen };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+61
-28
@@ -47,6 +47,12 @@
|
|||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
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
|
// Only remove existing dynamically generated tags
|
||||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||||
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
// 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 => {
|
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
|
||||||
const a = document.createElement("a");
|
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||||
a.href = `/tag/${tag.normalized}`;
|
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
|
||||||
a.style = "color: inherit !important";
|
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
|
||||||
a.textContent = tag.tag;
|
|
||||||
|
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");
|
const span = document.createElement("span");
|
||||||
span.classList.add("badge");
|
span.classList.add("badge");
|
||||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
span.classList.add('new-tag-glow');
|
span.classList.add('new-tag-glow');
|
||||||
}
|
}
|
||||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
|
||||||
|
|
||||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
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'); //
|
const space = document.createTextNode('\u00A0'); //
|
||||||
span.appendChild(space);
|
span.appendChild(space);
|
||||||
|
|
||||||
@@ -83,9 +112,29 @@
|
|||||||
span.insertAdjacentElement("beforeend", del);
|
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
|
// Handle show more/less toggle visibility and count
|
||||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
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) => {
|
const toggleEvent = async (e) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
const ctx = getContext();
|
const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle');
|
||||||
if (!ctx) return;
|
if (window.cycleRating) {
|
||||||
const { postid } = ctx;
|
window.cycleRating(ratingEl);
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -290,16 +290,24 @@ export default new class {
|
|||||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
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``}
|
${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}
|
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++) {
|
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]);
|
tags[t].badge = this.getBadge(tags[t]);
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
delete tags[t].user;
|
delete tags[t].user;
|
||||||
delete tags[t].display_name;
|
delete tags[t].display_name;
|
||||||
}
|
}
|
||||||
|
cleanTags.push(tags[t]);
|
||||||
}
|
}
|
||||||
return tags;
|
return cleanTags;
|
||||||
};
|
};
|
||||||
getBadge(tagObj) {
|
getBadge(tagObj) {
|
||||||
if (tagObj.tag.startsWith(">"))
|
if (tagObj.tag.startsWith(">"))
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default (router, tpl) => {
|
|||||||
const session = data.session;
|
const session = data.session;
|
||||||
const item = data.item;
|
const item = data.item;
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
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.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.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(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
|
|||||||
@@ -650,12 +650,28 @@ export default router => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db`
|
// Run item fetch + page lookup in parallel — saves one sequential DB round-trip
|
||||||
SELECT *
|
const [rows, itemPage] = await Promise.all([
|
||||||
FROM "items"
|
db`
|
||||||
WHERE id = ${data.itemid} AND active = true
|
SELECT *
|
||||||
LIMIT 1
|
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];
|
const item = rows[0];
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -685,7 +701,8 @@ export default router => {
|
|||||||
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
||||||
dest: relativeDest,
|
dest: relativeDest,
|
||||||
url: directUrl,
|
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);
|
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = item[0].username === req.session.user;
|
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;
|
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||||
|
|
||||||
if (!isOwner && !isAdmin) {
|
if (!isOwner && !isAdmin) {
|
||||||
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||||
const existingRating = await db`
|
const nsfl_id = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||||
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;
|
|
||||||
let newRatingId;
|
let newRatingId;
|
||||||
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
let currentRatingId = null;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.begin(async sql => {
|
await db.begin(async sql => {
|
||||||
// Remove old rating tags
|
// Lock the item row exclusively so any concurrent rating update for this post MUST wait
|
||||||
await sql`DELETE FROM tags_assign WHERE item_id = ${itemid} AND tag_id IN (1, 2, ${nsfl_id})`;
|
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
|
// Insert new rating tag
|
||||||
await sql`
|
if (newRatingId > 0) {
|
||||||
INSERT INTO tags_assign (item_id, tag_id, user_id)
|
await sql`
|
||||||
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
|
INSERT INTO tags_assign (item_id, tag_id, user_id)
|
||||||
`;
|
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure blurred thumbnail exists
|
// Ensure blurred thumbnail exists
|
||||||
await queue.genBlurredThumbnail(itemid).catch(err => {
|
await queue.genBlurredThumbnail(itemid).catch(err => {
|
||||||
|
|||||||
@@ -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' });
|
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
|
||||||
|
|
||||||
const postid = +req.params.postid;
|
const postid = +req.params.postid;
|
||||||
const nsflId = cfg.nsfl_tag_id || 3;
|
const item = await db`
|
||||||
// Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW
|
SELECT id, username, active, is_deleted
|
||||||
const cycle = [1, 2, nsflId];
|
FROM items
|
||||||
const currentTags = await lib.getTags(postid);
|
WHERE id = ${postid} AND active = true AND is_deleted = false
|
||||||
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
|
LIMIT 1
|
||||||
|
`;
|
||||||
let nextTagId;
|
if (item.length === 0) {
|
||||||
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
if (reqRating === 'sfw') {
|
|
||||||
nextTagId = 1;
|
|
||||||
} else if (reqRating === 'nsfw') {
|
|
||||||
nextTagId = 2;
|
|
||||||
} else if (reqRating === 'nsfl') {
|
|
||||||
nextTagId = nsflId;
|
|
||||||
} else {
|
|
||||||
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
|
|
||||||
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
|
||||||
// Remove any existing rating tag
|
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||||
await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`;
|
if (!isOwner && !isAdmin) {
|
||||||
if (nextTagId > 0) {
|
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||||
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically generate/verify blurred thumbnail on cycle
|
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||||
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||||
try {
|
const cycle = [1, 2, nsflId];
|
||||||
await fs.promises.access(blurPath);
|
let nextTagId;
|
||||||
} catch {
|
let ratingTagId = 0;
|
||||||
await queue.genBlurredThumbnail(postid, false);
|
|
||||||
}
|
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 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);
|
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 });
|
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[CYCLE_RATING_ERROR]', err);
|
||||||
return res.json({ success: false, msg: 'Failed to update rating' });
|
return res.json({ success: false, msg: 'Failed to update rating' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ export default (router, tpl) => {
|
|||||||
// Is the current user a moderator/admin?
|
// Is the current user a moderator/admin?
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
// Can the current user manage this item (owner, admin, or mod)?
|
// 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?
|
// Is the item's MIME type suitable for metadata extraction?
|
||||||
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
|
// 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])));
|
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])));
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default (router, tpl) => {
|
|||||||
const session = data.session;
|
const session = data.session;
|
||||||
const item = data.item;
|
const item = data.item;
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
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.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.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(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
|
|||||||
@@ -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>
|
<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
|
@endif
|
||||||
</div>
|
</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">
|
<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")
|
@if(typeof item.tags !== "undefined")
|
||||||
@each(item.tags as tag)
|
@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 }}">
|
<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) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
||||||
</span>
|
</span>
|
||||||
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
@@ -188,9 +199,6 @@
|
|||||||
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
|
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -32,19 +32,27 @@
|
|||||||
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
|
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="sidebar-tags-container">
|
<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")
|
@if(typeof item.tags !== "undefined")
|
||||||
@each(item.tags as tag)
|
@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 }}">
|
<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) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
||||||
</span>
|
</span>
|
||||||
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user