From 73ffe02bbb62585c85374f6f851dbd51d3e4b8b0 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 10 Sep 2026 00:07:22 +0200 Subject: [PATCH] onara --- public/s/css/f0ckm.css | 322 ++++++++++++++++-- public/s/js/f0ckm.js | 604 ++++++++++++++++++++++++++------- src/inc/config.mjs | 40 ++- src/inc/queue.mjs | 43 ++- src/inc/routeinc/f0cklib.mjs | 442 ++++++++++++++++++------ src/inc/routes/apiv2/index.mjs | 21 +- src/inc/routes/index.mjs | 99 ++++++ src/index.mjs | 4 + src/rethumb_handler.mjs | 3 +- views/index-partial.html | 2 +- views/snippets/footer.html | 18 +- views/snippets/header.html | 4 +- 12 files changed, 1327 insertions(+), 275 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 7ca5ebb..d70354b 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -9111,7 +9111,7 @@ input#s_avatar { height: 100%; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(5px); - z-index: 10001; + z-index: 100100; display: flex; align-items: center; justify-content: center; @@ -11765,7 +11765,7 @@ input:checked+.slider:before { display: flex; align-items: center; justify-content: center; - z-index: 10000; + z-index: 100100; } body.modal-open { @@ -12941,7 +12941,7 @@ body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item- left: 0; width: 100vw; height: 100vh; - z-index: 10000; + z-index: 100100; overflow: auto; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); @@ -14866,29 +14866,16 @@ span.gchat-post-card--loading { } } -body.layout-modern .gapRight { - justify-content: center; -} - -body.layout-modern .gapRight>svg.iconset, -body.layout-modern .gapRight>i.iconset { - height: 30px; - width: 30px; - margin: 5px; - padding: 5px; - right: 0px; - background: var(--badge-tag); - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 18px; - box-sizing: border-box; -} - +.gapRight, +body.layout-modern .gapRight, body.layout-legacy .gapRight { justify-content: center; } +.gapRight>svg.iconset, +.gapRight>i.iconset, +body.layout-modern .gapRight>svg.iconset, +body.layout-modern .gapRight>i.iconset, body.layout-legacy .gapRight>svg.iconset, body.layout-legacy .gapRight>i.iconset { height: 30px; @@ -14904,6 +14891,7 @@ body.layout-legacy .gapRight>i.iconset { box-sizing: border-box; } +.gapRight>.iconset#a_favo, body.layout-legacy .gapRight>.iconset#a_favo { fill: #ff009b; } @@ -18613,4 +18601,294 @@ a.lazy-thumb.filtered-upload-ghost:hover::after { .item-main-content { padding-left: 15px; padding-right: 15px; +} + +/* ========================================================================== + ONARA ALTERNATIVE FLAVOR: Fullscreen See-Through Modal Wrapper + ========================================================================== */ + +/* Prevent scrolling on background document when Onara modal is active */ +body.onara-modal-open { + overflow: hidden !important; +} + +/* Background main page elements (outside posts) are dimmed and softly blurred */ +body.onara-modal-open .pagewrapper { + pointer-events: none !important; + user-select: none !important; +} + +body.onara-modal-open .pagewrapper > *:not(.index-layout-wrapper):not(#main), +body.onara-modal-open .index-container > *:not(.posts), +body.onara-modal-open #footbar, +body.onara-modal-open .pagination-container-fluid { + opacity: 0.25 !important; + filter: blur(1.5px) !important; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), + filter 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; +} + +/* All non-active background thumbnails are strictly dimmed, blurred, with no outlines or badges */ +body.onara-modal-open .posts > a.thumb:not(.onara-active) { + opacity: 0.25 !important; + filter: blur(1.5px) !important; + box-shadow: none !important; + outline: none !important; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), + filter 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; +} + +body.onara-modal-open .posts > a.thumb:not(.onara-active)::after { + visibility: hidden !important; + opacity: 0 !important; +} + +/* ONLY the single currently active thumbnail in the background shines through with TAB highlight */ +body.onara-modal-open .posts > a.thumb.onara-active { + opacity: 1 !important; + filter: none !important; + box-shadow: 0 0 0 2px var(--white, #fff), 0 0 16px rgba(255, 255, 255, 0.6) !important; + z-index: 25 !important; +} + +body.onara-modal-open .posts > a.thumb.onara-active::after { + visibility: visible !important; + opacity: 1 !important; +} + +/* Navbar and right sidebar stay fully visible, sharp, and interactive */ +body.onara-modal-open nav.navbar { + opacity: 1 !important; + filter: none !important; + pointer-events: auto !important; + z-index: 10060 !important; +} + +body.onara-modal-open .global-sidebar-right { + opacity: 1 !important; + filter: none !important; + pointer-events: auto !important; + z-index: 10060 !important; +} + +body.onara-modal-open #sidebar-drag-zone { + opacity: 1 !important; + pointer-events: auto !important; + z-index: 10061 !important; +} + +/* Modal wrapper covers main content area with subtle dark glassmorphism, respecting sidebar */ +.onara-modal-wrapper { + position: fixed !important; + top: var(--navbar-h, 50px) !important; + bottom: 0 !important; + left: 0 !important; + right: 300px !important; + width: auto !important; + height: calc(100vh - var(--navbar-h, 50px)) !important; + z-index: 10050 !important; + display: none; + flex-direction: column !important; + background: rgba(10, 12, 16, 0.72) !important; + backdrop-filter: blur(5px) saturate(130%) !important; + -webkit-backdrop-filter: blur(5px) saturate(130%) !important; + overflow-y: auto !important; + overflow-x: hidden !important; + box-sizing: border-box !important; + animation: onaraFadeIn 0.22s cubic-bezier(0.16, 1, 0.3, 1) forwards; + transition: right 0.3s ease-in-out !important; + cursor: pointer; +} + +body.onara-modal-open .onara-modal-wrapper { + display: flex !important; +} + +/* Sidebar hidden: modal expands to right edge */ +body.sidebar-right-hidden .onara-modal-wrapper { + right: 0 !important; +} + +/* Tablet (600px - 999px): sidebar is min(280px, 80vw) */ +@media (min-width: 600px) and (max-width: 999px) { + .onara-modal-wrapper { + right: min(280px, 80vw) !important; + } + body.sidebar-right-hidden .onara-modal-wrapper { + right: 0 !important; + } +} + +/* Mobile (<= 599px): sidebar is pure overlay, modal uses full width */ +@media (max-width: 599px) { + .onara-modal-wrapper { + right: 0 !important; + } +} + +@keyframes onaraFadeIn { + from { + opacity: 0; + transform: scale(0.99); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.onara-backdrop { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + min-height: 100% !important; + z-index: 1 !important; + cursor: pointer; +} + +.onara-modal-content { + position: relative !important; + z-index: 2 !important; + width: 100% !important; + min-height: 100% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: flex-start !important; + flex: 1 0 auto !important; + padding: 20px 0 80px 0 !important; + box-sizing: border-box !important; + cursor: pointer; +} + +#onara-item-mount { + width: 100% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: flex-start !important; + cursor: pointer; +} + +/* Center the item layout inside modal and make it 100% scrollable */ +#onara-modal .item-layout-container, +body.layout-legacy #onara-modal .item-layout-container, +body.layout-modern #onara-modal .item-layout-container { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: flex-start !important; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + min-height: auto !important; + max-height: none !important; + overflow: visible !important; + margin: 0 auto !important; + padding: 0 15px !important; + box-sizing: border-box !important; + grid-template-columns: none !important; + grid-template-rows: none !important; + cursor: pointer; +} + +#onara-modal .item-main-content, +body.layout-legacy #onara-modal .item-layout-container .item-main-content, +body.layout-modern #onara-modal .item-layout-container .item-main-content { + cursor: default !important; +} + +#onara-modal .item-main-content, +body.layout-legacy #onara-modal .item-layout-container .item-main-content, +body.layout-modern #onara-modal .item-layout-container .item-main-content { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + width: 100% !important; + max-width: 850px !important; + height: auto !important; + min-height: auto !important; + max-height: none !important; + overflow: visible !important; + margin: 0 auto !important; + padding: 0 !important; + box-sizing: border-box !important; + grid-column: auto !important; +} + +#onara-modal .item-main-content::before, +#onara-modal .item-main-content::after { + display: none !important; +} + +#onara-modal .item-main-content > * { + width: 100% !important; + max-width: 800px !important; + margin-left: auto !important; + margin-right: auto !important; +} + +#onara-modal .item-main-content .metadata { + width: 100% !important; + max-width: 600px !important; + margin-left: auto !important; + margin-right: auto !important; +} + +#onara-modal #comments-container { + display: flex !important; + flex-direction: column !important; + flex: 0 0 auto !important; + height: auto !important; + max-height: none !important; + overflow: visible !important; + width: 100% !important; + max-width: 600px !important; +} + +/* Modern layout: left sidebar flows below media inside modal */ +body.layout-modern #onara-modal .item-sidebar-left { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 600px !important; + height: auto !important; + max-height: none !important; + position: static !important; + order: 2 !important; + margin: 20px auto 0 auto !important; + border-right: none !important; + box-shadow: none !important; + overflow: visible !important; +} + +body.layout-modern #onara-modal .item-main-content { + order: 1 !important; +} + +/* Auxiliary modals, overlays, and dialogs MUST open in front of Onara modal and fixed bars */ +body.onara-modal-open .modal-overlay, +body.onara-modal-open .image-modal-overlay, +body.onara-modal-open .dm-modal-overlay, +body.onara-modal-open .swal2-container, +body.onara-modal-open .modal-backdrop, +body.onara-modal-open .modal, +body.onara-modal-open #image-modal, +body.onara-modal-open #report-modal, +body.onara-modal-open #info-modal, +body.onara-modal-open #visibility-modal, +body.onara-modal-open #halls-modal, +body.onara-modal-open #mod-action-modal, +body.onara-modal-open #warning-modal, +body.onara-modal-open #content-warning-modal, +body.onara-modal-open #login-modal, +body.onara-modal-open #register-modal, +body.onara-modal-open #shortcuts-modal, +body.onara-modal-open #excluded-tags-overlay, +body.onara-modal-open #upload-drag-modal, +body.onara-modal-open #rethumb-capture-modal, +body.onara-modal-open #gchat-img-modal { + z-index: 100100 !important; } \ No newline at end of file diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 8b30a0a..ac949fa 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -117,6 +117,17 @@ window.cancelAnimFrame = (function () { } catch(e) { console.error('Visit tracking error:', e); } }; + const ensureLayoutClass = () => { + if (window.f0ckSession && window.f0ckSession.use_new_layout === false) { + document.body.classList.remove('layout-modern'); + document.body.classList.add('layout-legacy'); + } else if (!document.body.classList.contains('layout-legacy')) { + document.body.classList.add('layout-modern'); + } + }; + window.ensureLayoutClass = ensureLayoutClass; + ensureLayoutClass(); + window.applyThumbCacheBust = (bgUrlStr) => { if (!bgUrlStr) return bgUrlStr; try { @@ -178,8 +189,24 @@ window.cancelAnimFrame = (function () { } catch(e) {} }); + const currentId = typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null; + + // Update audio cover in player if viewing this audio item + const audioCover = document.getElementById('f0ck-audio-cover'); + if (audioCover && currentId === idStr) { + const coverUrl = `/ca/${idStr}.webp?t=${timestamp}`; + audioCover.src = coverUrl; + const parent = audioCover.parentElement; + if (parent) { + parent.style.background = `url('${coverUrl}') no-repeat center / contain black`; + } + const audioEl = document.querySelector('audio#my-video'); + if (audioEl) { + audioEl.setAttribute('poster', coverUrl); + } + } + // Refresh background canvas if it matches the current item - const currentId = window.getCurrentItemId(); if (currentId === idStr && window.initBackground) { window.initBackground(); } @@ -732,10 +759,16 @@ window.cancelAnimFrame = (function () { if (data.success) { window.flashMessage('ALL MODE ACTIVATED'); gridCacheMap.clear(); + const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname); let reloadPromise = null; - if (isGridView) { + if (isOnaraOpen) { + const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); + const urlObj = new URL(bgUrl, window.location.origin); + urlObj.searchParams.delete('mode'); + reloadPromise = window.loadPageAjax ? window.loadPageAjax(urlObj.toString(), true, { skipCache: true, skipPush: true }) : null; + } else if (isGridView) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('mode'); reloadPromise = window.loadPageAjax ? window.loadPageAjax(currentUrl.toString(), true, { skipCache: true }) : null; @@ -790,10 +823,16 @@ window.cancelAnimFrame = (function () { : 'ALL MODE ACTIVATED'; window.flashMessage(label); gridCacheMap.clear(); + const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname); let reloadPromise = null; - if (isGridView) { + if (isOnaraOpen) { + const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); + const urlObj = new URL(bgUrl, window.location.origin); + urlObj.searchParams.delete('mode'); + reloadPromise = window.loadPageAjax ? window.loadPageAjax(urlObj.toString(), true, { skipCache: true, skipPush: true }) : null; + } else if (isGridView) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('mode'); reloadPromise = window.loadPageAjax ? window.loadPageAjax(currentUrl.toString(), true, { skipCache: true }) : null; @@ -1014,6 +1053,159 @@ window.cancelAnimFrame = (function () { }); }; + // ── Onara Alternative Flavor: Fullscreen Modal Wrapper ───────────────────── + const isOnaraActive = () => !!(window.f0ckSession?.onara || window.onara); + + const getOrCreateOnaraModal = () => { + let modal = document.getElementById('onara-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'onara-modal'; + modal.className = 'onara-modal-wrapper'; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + modal.innerHTML = ` +
+
+
+
+ `; + document.body.appendChild(modal); + } + return modal; + }; + + const updateOnaraActiveItem = (itemid, url) => { + if (!isOnaraActive()) return; + // Clear onara-active from any and all elements to guarantee only 1 item is selected + document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); + + let targetThumb = null; + if (url) { + try { + const parsedPath = new URL(url, window.location.origin).pathname; + targetThumb = document.querySelector(`.posts > a.thumb[href="${parsedPath}"], .posts > a.thumb[href="${url}"]`); + } catch {} + } + if (!targetThumb && itemid) { + targetThumb = document.querySelector(`.posts > a.thumb[href$="/${itemid}"], .posts > a.thumb[data-bg*="/${itemid}."]`); + } + + // Blur any other active element so browser native focus cannot highlight a second thumbnail + if (document.activeElement && document.activeElement !== targetThumb && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + + if (targetThumb) { + targetThumb.classList.add('onara-active'); + targetThumb.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + } + }; + window.updateOnaraActiveItem = updateOnaraActiveItem; + + const getOnaraBaseUrl = (itemUrl = window.location.href) => { + try { + const urlObj = new URL(itemUrl, window.location.origin); + let path = urlObj.pathname.replace(/\/+$/, ''); + path = path.replace(/\/(?:\d+|[a-zA-Z0-9_-]{11})$/, ''); + const postsEl = document.querySelector('.posts[data-current-page]'); + const currentPage = postsEl ? parseInt(postsEl.dataset.currentPage, 10) : 1; + if (currentPage > 1 && !path.includes('/p/')) { + path = (path ? path : '') + '/p/' + currentPage; + } + if (!path) path = '/'; + return path + (urlObj.search || '') + (urlObj.hash || ''); + } catch { + return '/'; + } + }; + window.getOnaraBaseUrl = getOnaraBaseUrl; + + const promoteOnaraModals = () => { + const mount = document.getElementById('onara-item-mount'); + if (!mount) return; + const modals = mount.querySelectorAll('#info-modal, #visibility-modal, .modal-overlay'); + modals.forEach(m => { + const existing = document.body.querySelector(`:scope > #${m.id}`); + if (existing && existing !== m) { + existing.remove(); + } + document.body.appendChild(m); + }); + }; + window.promoteOnaraModals = promoteOnaraModals; + + const closeOnaraModal = (options = {}) => { + const modal = document.getElementById('onara-modal'); + if (!modal || modal.style.display === 'none') return; + + // Close any auxiliary modals that may be open + const infoModal = document.getElementById('info-modal'); + if (infoModal) infoModal.style.display = 'none'; + const visModal = document.getElementById('visibility-modal'); + if (visModal) visModal.style.display = 'none'; + const reportModal = document.getElementById('report-modal'); + if (reportModal) reportModal.style.display = 'none'; + const hallsModal = document.getElementById('halls-modal'); + if (hallsModal) hallsModal.style.display = 'none'; + if (typeof window.closeImageModal === 'function') window.closeImageModal(); + document.body.classList.remove('modal-open'); + + stopMedia(); + const mount = document.getElementById('onara-item-mount'); + if (mount) mount.innerHTML = ''; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('onara-modal-open'); + + // Restore background canvas if needed + const canvas = document.getElementById('bg'); + if (canvas) { + canvas.classList.remove('fader-out', 'fast-fade'); + if (window.initBackground) window.initBackground(); + } + + if (!options.skipHistory) { + const returnUrl = window._onaraReturnUrl || getOnaraBaseUrl(); + history.pushState({}, '', returnUrl); + if (window._onaraReturnTitle) { + document.title = window._onaraReturnTitle; + } else if (window.f0ckDomain) { + document.title = window.f0ckDomain; + } + } + window._onaraReturnUrl = null; + window._onaraReturnTitle = null; + + // Maintain focus on active item so user TAB navigation seamlessly resumes from current progress + const activeThumb = document.querySelector('.posts > a.thumb.onara-active'); + document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); + if (activeThumb) { + try { + activeThumb.focus({ preventScroll: true }); + } catch {} + } + }; + window.closeOnaraModal = closeOnaraModal; + window.isOnaraActive = isOnaraActive; + + // Onara modal close click delegation: clicking outside .item-main-content closes modal + document.addEventListener('click', (e) => { + if (!document.body.classList.contains('onara-modal-open')) return; + // Do not close if clicking inside the item content + if (e.target.closest('.item-main-content')) return; + // Do not close if clicking auxiliary modals, dropdowns, or notifications + if (e.target.closest('.modal-overlay:not(#onara-modal), .modal-content, .swal2-container, .tippy-box, #comments-container .emoji-picker')) return; + // Do not close if clicking the global sidebar right or navbar (as modal respects them) + if (e.target.closest('.global-sidebar-right, nav.navbar, #sidebar-drag-zone')) return; + + // Any click outside .item-main-content (e.g. left side, right side, backdrop, modal background) closes + if (e.target.closest('#onara-modal') || e.target.id === 'onara-backdrop') { + e.preventDefault(); + closeOnaraModal(); + } + }); + if (loginModal) { if (loginBtn) { loginBtn.addEventListener('click', (e) => { @@ -1372,8 +1564,49 @@ window.cancelAnimFrame = (function () { closeModal(loginModal); closeModal(registerModal); closeModal(shortcutsModal); + const imageModal = document.getElementById('image-modal'); + if (imageModal && (imageModal.classList.contains('visible') || imageModal.style.display === 'flex' || imageModal.style.display === 'block')) { + if (typeof window.closeImageModal === 'function') window.closeImageModal(); + else { + imageModal.classList.remove('visible'); + imageModal.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + return; + } const infoModal = document.getElementById('info-modal'); - if (infoModal) closeModal(infoModal); + if (infoModal && infoModal.style.display !== 'none') { + infoModal.style.display = 'none'; + document.body.classList.remove('modal-open'); + return; + } + const visModal = document.getElementById('visibility-modal'); + if (visModal && visModal.style.display !== 'none') { + visModal.style.display = 'none'; + document.body.classList.remove('modal-open'); + return; + } + const reportModal = document.getElementById('report-modal'); + if (reportModal && reportModal.style.display !== 'none') { + reportModal.style.display = 'none'; + document.body.classList.remove('modal-open'); + return; + } + const hallsModal = document.getElementById('halls-modal'); + if (hallsModal && hallsModal.style.display !== 'none') { + hallsModal.style.display = 'none'; + document.body.classList.remove('modal-open'); + return; + } + const anyOtherModal = document.querySelector('.modal-overlay:not(#onara-modal):not([style*="display: none"]), .modal.show, .swal2-container'); + if (anyOtherModal && window.getComputedStyle(anyOtherModal).display !== 'none') { + anyOtherModal.style.display = 'none'; + document.body.classList.remove('modal-open'); + return; + } + if (document.body.classList.contains('onara-modal-open')) { + closeOnaraModal(); + } } }); @@ -1519,15 +1752,42 @@ window.cancelAnimFrame = (function () { } }; + const initOnaraInitialState = () => { + if (!isOnaraActive()) return; + promoteOnaraModals(); + if (document.body.classList.contains('onara-modal-open')) { + const modal = document.getElementById('onara-modal'); + if (modal) { + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + } + if (!window._onaraReturnUrl) { + window._onaraReturnUrl = getOnaraBaseUrl(); + window._onaraReturnTitle = window.f0ckDomain || 'f0ck'; + } + const pathSegments = window.location.pathname.split('/'); + const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); + const activeKey = keySegments.length ? keySegments[keySegments.length - 1] : null; + if (activeKey) { + updateOnaraActiveItem(activeKey, window.location.href); + if (typeof window.trackVisit === 'function') { + window.trackVisit(activeKey); + } + } + } + }; + // Initial Load document.addEventListener('DOMContentLoaded', () => { setupMedia(); + initOnaraInitialState(); if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => prefetchNextPrevItems()); } else { setTimeout(prefetchNextPrevItems, 500); } }); + initOnaraInitialState(); // Export init function for dynamic calls window.initBackground = () => { @@ -2380,6 +2640,7 @@ window.cancelAnimFrame = (function () { // Immediately restore scrollability and hide modals if (window.resetGlobalScrollState) window.resetGlobalScrollState(); if (window.hideAllModals) window.hideAllModals(); + if (window.closeOnaraModal) window.closeOnaraModal({ skipHistory: true }); isNavigating = true; @@ -2493,7 +2754,7 @@ window.cancelAnimFrame = (function () { // Keep cache alive — cleared on mode/filter changes via gridCacheMap.clear() if (navbar) navbar.classList.add('pbwork'); - stopMedia(); + if (!document.body.classList.contains('onara-modal-open')) stopMedia(); // Fade out background canvas (item view blur) const canvas = document.getElementById('bg'); @@ -2514,9 +2775,12 @@ window.cancelAnimFrame = (function () { cache.node.style.display = ''; cache.node.classList.remove('cached-grid'); main.className = ''; - document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); - document.body.style.overflow = ''; - document.body.style.height = ''; + document.body.classList.remove('legacy-view'); + ensureLayoutClass(); + if (!document.body.classList.contains('onara-modal-open')) { + document.body.style.overflow = ''; + document.body.style.height = ''; + } const paginationWrapper = document.querySelector('.pagination-wrapper'); if (paginationWrapper && cache.pagination) paginationWrapper.innerHTML = cache.pagination; const restoredPosts = cache.node.querySelector('.posts'); @@ -2527,7 +2791,7 @@ window.cancelAnimFrame = (function () { } // Update URL bar (only for explicit clicks, not browser back/forward which already did it) - if (!options.skipPush) history.pushState({}, '', url); + if (!options.skipPush && !document.body.classList.contains('onara-modal-open')) history.pushState({}, '', url); // Restore scroll position requestAnimationFrame(() => window.scrollTo(0, cache.scroll)); @@ -2576,11 +2840,13 @@ window.cancelAnimFrame = (function () { main.className = ''; // Reset class // Clear legacy-view state (layout-lock) now that we are swapping to a list view - document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); - document.body.style.overflow = ''; - document.body.style.height = ''; - // Toggle button lives permanently in body via header.html — no removal needed - stopMedia(); // Ensure media stops when leaving item view + document.body.classList.remove('legacy-view'); + ensureLayoutClass(); + if (!document.body.classList.contains('onara-modal-open')) { + document.body.style.overflow = ''; + document.body.style.height = ''; + stopMedia(); // Ensure media stops when leaving item view + } // Fade out background canvas for smooth transition to black const canvas = document.getElementById('bg'); @@ -2737,6 +3003,7 @@ window.cancelAnimFrame = (function () { // Ensure we keep a layout class but reset others document.body.classList.remove('item-layout-active', 'meme-layout-active'); // Kill known breakers + ensureLayoutClass(); } // Reset navbar to clean state @@ -2745,9 +3012,11 @@ window.cancelAnimFrame = (function () { navbar.classList.remove('scrolled', 'pbwork'); } - document.body.style.overflow = ''; - document.body.style.height = ''; - document.body.style.minHeight = ''; + if (!document.body.classList.contains('onara-modal-open')) { + document.body.style.overflow = ''; + document.body.style.height = ''; + document.body.style.minHeight = ''; + } window.f0ckDebug("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial")); if (window.updateMimeLabel) window.updateMimeLabel(); @@ -2949,7 +3218,7 @@ window.cancelAnimFrame = (function () { } // UPDATE HISTORY BEFORE INITIALIZING SCRIPTS - if (!options.skipPush) { + if (!options.skipPush && !document.body.classList.contains('onara-modal-open')) { history.pushState({}, '', url); } @@ -3319,6 +3588,15 @@ window.cancelAnimFrame = (function () { isNavigating = true; + const isGridPage = !!document.querySelector('.index-layout-wrapper, .index-container, .posts'); + const isModalOpen = document.body.classList.contains('onara-modal-open'); + const useOnara = isOnaraActive() && (isGridPage || isModalOpen); + + if (useOnara && !isModalOpen) { + window._onaraReturnUrl = window.location.href; + window._onaraReturnTitle = document.title; + } + // ── Scroller-active cleanup (same as loadPageAjax) ─────────────────── if (document.body.classList.contains('scroller-active')) { document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove()); @@ -3349,7 +3627,7 @@ window.cancelAnimFrame = (function () { const currentScroll = window.scrollY; // Save scroll position for current page (only for new navigations) - if (!options.skipPush && !options.keepMedia) { + if (!options.skipPush && !options.keepMedia && !useOnara) { const currentState = history.state || {}; history.replaceState({ ...currentState, scroll: currentScroll }, document.title, window.location.href); } @@ -3521,39 +3799,54 @@ window.cancelAnimFrame = (function () { // ── Render cached HTML (same DOM setup as the normal path below) ── const _html = _cachedItem.html; - let _container = document.querySelector('#main .container') || (document.getElementById('main')?.classList.contains('item-view') ? document.getElementById('main') : null); - const _isStructuralPage = !!document.querySelector('.pagewrapper'); - const _isModeratorPage = !!document.querySelector('.approval-grid, .audit-log-container, .mod-reports-page'); - const _isNotificationsPage = !!document.querySelector('.notifications-page, .notif-history-container'); - const _isIndexPage = !!document.querySelector('.index-container, .posts'); - const _isStaticPage = !!document.querySelector('.static-page, .about-container, .upload-container'); + let _container; - if (!options.keepMedia && (!_container || _isModeratorPage || _isNotificationsPage || _isIndexPage || _isStaticPage || _isStructuralPage)) { - if (main) { - const _iw = main.querySelector('.index-layout-wrapper'); - const _ic = main.querySelector('.index-container'); - const _ntc = _iw || _ic; - if (_ntc && _isIndexPage) { - const _ck = window.location.pathname + window.location.search; - const _pw = document.querySelector('.pagination-wrapper'); - gridCacheMap.set(_ck, { node: _ntc, scroll: currentScroll, pagination: _pw ? _pw.innerHTML : '' }); - _ntc.remove(); + if (useOnara) { + const onaraModal = getOrCreateOnaraModal(); + const onaraMount = document.getElementById('onara-item-mount'); + document.body.classList.add('onara-modal-open'); + onaraModal.style.display = 'flex'; + onaraModal.setAttribute('aria-hidden', 'false'); + if (!options.keepMedia) { + onaraMount.innerHTML = ''; + } + _container = onaraMount; + updateOnaraActiveItem(itemid, url); + } else { + _container = document.querySelector('#main .container') || (document.getElementById('main')?.classList.contains('item-view') ? document.getElementById('main') : null); + const _isStructuralPage = !!document.querySelector('.pagewrapper'); + const _isModeratorPage = !!document.querySelector('.approval-grid, .audit-log-container, .mod-reports-page'); + const _isNotificationsPage = !!document.querySelector('.notifications-page, .notif-history-container'); + const _isIndexPage = !!document.querySelector('.index-container, .posts'); + const _isStaticPage = !!document.querySelector('.static-page, .about-container, .upload-container'); + + if (!options.keepMedia && (!_container || _isModeratorPage || _isNotificationsPage || _isIndexPage || _isStaticPage || _isStructuralPage)) { + if (main) { + const _iw = main.querySelector('.index-layout-wrapper'); + const _ic = main.querySelector('.index-container'); + const _ntc = _iw || _ic; + if (_ntc && _isIndexPage) { + const _ck = window.location.pathname + window.location.search; + const _pw = document.querySelector('.pagination-wrapper'); + gridCacheMap.set(_ck, { node: _ntc, scroll: currentScroll, pagination: _pw ? _pw.innerHTML : '' }); + _ntc.remove(); + } + main.className = 'item-view'; + document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); + if (window.f0ckSession && !window.f0ckSession.use_new_layout) { + document.body.classList.add('layout-legacy'); + } else { + document.body.classList.add('layout-modern'); + } + if (window.syncNavbarHeight) window.syncNavbarHeight(); + main.innerHTML = ''; + _container = main; } - main.className = 'item-view'; - document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); - if (window.f0ckSession && !window.f0ckSession.use_new_layout) { - document.body.classList.add('layout-legacy'); - } else { - document.body.classList.add('layout-modern'); - } - if (window.syncNavbarHeight) window.syncNavbarHeight(); - main.innerHTML = ''; - _container = main; + } else if (_container && !options.keepMedia) { + _container.innerHTML = ''; + _container.removeAttribute('style'); + if (main) main.className = 'item-view'; } - } else if (_container && !options.keepMedia) { - _container.innerHTML = ''; - _container.removeAttribute('style'); - if (main) main.className = 'item-view'; } if (options.keepMedia) { @@ -3568,14 +3861,18 @@ window.cancelAnimFrame = (function () { _container.insertAdjacentHTML('beforeend', _html); } - if (options.skipPush && history.state?.scroll !== undefined) { + if (useOnara) { + promoteOnaraModals(); + const onaraModal = document.getElementById('onara-modal'); + if (onaraModal) onaraModal.scrollTo(0, 0); + } else if (options.skipPush && history.state?.scroll !== undefined) { requestAnimationFrame(() => window.scrollTo(0, history.state.scroll)); } else if (!options.keepMedia) { window.scrollTo(0, 0); } const _hash = new URL(url, window.location.origin).hash; - const itemKey = _cachedItem.slug || itemid; + const itemKey = (window.f0ckSession?.enable_item_slugs !== false && _cachedItem.slug) ? _cachedItem.slug : (_cachedItem.slug || itemid); let _pushUrl = `/${itemKey}`; if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`; else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; } @@ -3585,7 +3882,7 @@ window.cancelAnimFrame = (function () { if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`); if (_hash) _pushUrl += _hash; - if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl); + if (!options.keepMedia && !options.skipPush) history.pushState({ onara: useOnara }, '', _pushUrl); document.title = `${window.f0ckDomain} - ${itemKey}`; if (navbar) navbar.classList.remove('pbwork'); @@ -3678,55 +3975,69 @@ window.cancelAnimFrame = (function () { // Track visit for guests window.trackVisit(itemid); - let container = document.querySelector('#main .container') || (document.getElementById('main') && document.getElementById('main').classList.contains('item-view') ? document.getElementById('main') : null); - const isStructuralPage = !!document.querySelector('.pagewrapper'); - const isModeratorPage = !!document.querySelector('.approval-grid, .audit-log-container, .mod-reports-page'); - const isNotificationsPage = !!document.querySelector('.notifications-page, .notif-history-container'); - const isIndexPage = !!document.querySelector('.index-container, .posts'); - const isStaticPage = !!document.querySelector('.static-page, .about-container, .upload-container'); + let container; + if (useOnara) { + const onaraModal = getOrCreateOnaraModal(); + const onaraMount = document.getElementById('onara-item-mount'); + document.body.classList.add('onara-modal-open'); + onaraModal.style.display = 'flex'; + onaraModal.setAttribute('aria-hidden', 'false'); + if (!options.keepMedia) { + onaraMount.innerHTML = ''; + } + container = onaraMount; + updateOnaraActiveItem(itemid, url); + } else { + container = document.querySelector('#main .container') || (document.getElementById('main') && document.getElementById('main').classList.contains('item-view') ? document.getElementById('main') : null); + const isStructuralPage = !!document.querySelector('.pagewrapper'); + const isModeratorPage = !!document.querySelector('.approval-grid, .audit-log-container, .mod-reports-page'); + const isNotificationsPage = !!document.querySelector('.notifications-page, .notif-history-container'); + const isIndexPage = !!document.querySelector('.index-container, .posts'); + const isStaticPage = !!document.querySelector('.static-page, .about-container, .upload-container'); - if (!options.keepMedia && (!container || isModeratorPage || isNotificationsPage || isIndexPage || isStaticPage || isStructuralPage)) { - // Transition to Item View - if (main) { - // Check if we can cache the existing grid (Index Page only) - const indexWrapper = main.querySelector('.index-layout-wrapper'); - const indexContainer = main.querySelector('.index-container'); - const nodeToCache = indexWrapper || indexContainer; + if (!options.keepMedia && (!container || isModeratorPage || isNotificationsPage || isIndexPage || isStaticPage || isStructuralPage)) { + // Transition to Item View + if (main) { + // Check if we can cache the existing grid (Index Page only) + const indexWrapper = main.querySelector('.index-layout-wrapper'); + const indexContainer = main.querySelector('.index-container'); + const nodeToCache = indexWrapper || indexContainer; - if (nodeToCache && isIndexPage) { - const cacheKey = window.location.pathname + window.location.search; - const paginationWrapper = document.querySelector('.pagination-wrapper'); - gridCacheMap.set(cacheKey, { - node: nodeToCache, - scroll: currentScroll, - pagination: paginationWrapper ? paginationWrapper.innerHTML : '' - }); - nodeToCache.remove(); - } - - main.className = 'item-view'; + if (nodeToCache && isIndexPage) { + const cacheKey = window.location.pathname + window.location.search; + const paginationWrapper = document.querySelector('.pagination-wrapper'); + gridCacheMap.set(cacheKey, { + node: nodeToCache, + scroll: currentScroll, + pagination: paginationWrapper ? paginationWrapper.innerHTML : '' + }); + nodeToCache.remove(); + } + + main.className = 'item-view'; - // Correctly handle body classes for the current layout mode - document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); - if (window.f0ckSession && !window.f0ckSession.use_new_layout) { - document.body.classList.add('layout-legacy'); - } else { - document.body.classList.add('layout-modern'); - } - if (window.syncNavbarHeight) window.syncNavbarHeight(); + // Correctly handle body classes for the current layout mode + document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); + if (window.f0ckSession && !window.f0ckSession.use_new_layout) { + document.body.classList.add('layout-legacy'); + } else { + document.body.classList.add('layout-modern'); + } + if (window.syncNavbarHeight) window.syncNavbarHeight(); - // Toggle button lives permanently in body via header.html — no creation needed + // Toggle button lives permanently in body via header.html — no creation needed - // HTML goes directly into #main without outdated .container wrapper for New Layout - main.innerHTML = ''; - container = main; // HTML goes directly into #main + // HTML goes directly into #main without outdated .container wrapper for New Layout + main.innerHTML = ''; + container = main; // HTML goes directly into #main + } + } else if (container && !options.keepMedia) { + // Already in some kind of item view or compatible layout, just clear it + container.innerHTML = ''; + container.removeAttribute('style'); // Ensure no carry-over styles + if (main) main.className = 'item-view'; } - } else if (container && !options.keepMedia) { - // Already in some kind of item view or compatible layout, just clear it - container.innerHTML = ''; - container.removeAttribute('style'); // Ensure no carry-over styles - if (main) main.className = 'item-view'; } if (options.keepMedia) { @@ -3763,7 +4074,11 @@ window.cancelAnimFrame = (function () { // Handle Scroll Position (Restore or Reset) - if (options.skipPush && history.state && history.state.scroll !== undefined) { + if (useOnara) { + promoteOnaraModals(); + const onaraModal = document.getElementById('onara-modal'); + if (onaraModal) onaraModal.scrollTo(0, 0); + } else if (options.skipPush && history.state && history.state.scroll !== undefined) { requestAnimationFrame(() => window.scrollTo(0, history.state.scroll)); } else if (!options.keepMedia) { window.scrollTo(0, 0); @@ -3775,7 +4090,7 @@ window.cancelAnimFrame = (function () { // Construct proper History URL (Context Aware) // If we inherited context, we should reflect it in the URL const hash = new URL(url, window.location.origin).hash; - const itemKey = responseSlug || itemid; + const itemKey = (window.f0ckSession?.enable_item_slugs !== false && responseSlug) ? responseSlug : (responseSlug || itemid); let pushUrl = `/${itemKey}`; // Logic from ajax.mjs context reconstruction: if (userHall && userHallOwner) { @@ -3799,7 +4114,7 @@ window.cancelAnimFrame = (function () { if (!options.keepMedia && !options.skipPush) { // We overwrite proper URL even if the link clicked was "naked" - history.pushState({}, '', pushUrl); + history.pushState({ onara: useOnara }, '', pushUrl); } if (!options.keepMedia) { @@ -3900,11 +4215,17 @@ window.cancelAnimFrame = (function () { gridCacheMap.clear(); // Refresh content + const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); const isItemView = document.getElementById('prev') || document.getElementById('next'); let reloadPromise = null; - if (isGridView) { + if (isOnaraOpen) { + const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); + const urlObj = new URL(bgUrl, window.location.origin); + urlObj.searchParams.delete('mode'); + reloadPromise = loadPageAjax(urlObj.toString(), true, { skipCache: true, skipPush: true }); + } else if (isGridView) { // Reload current page without ?mode= in the URL — server picks up mode from session/cookie const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('mode'); @@ -3935,6 +4256,13 @@ window.cancelAnimFrame = (function () { const thumbnail = target.closest('.posts > a:not(.notif-item)'); if (thumbnail && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { e.preventDefault(); + if (isOnaraActive()) { + document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); + thumbnail.classList.add('onara-active'); + if (document.activeElement && document.activeElement !== thumbnail && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + } // Thumbnails inherit context (e.g. from Tag Index) loadItemAjax(thumbnail.href, true); return; @@ -3945,6 +4273,25 @@ window.cancelAnimFrame = (function () { // Special check for random if (link.id === 'random' || link.id === 'nav-random') { e.preventDefault(); + + // In Onara mode with a background grid, pick a random item from the grid so it reflects on the main page + if (isOnaraActive()) { + const gridThumbs = Array.from(document.querySelectorAll('.posts > a:not(.notif-item)')); + if (gridThumbs.length > 0) { + const pool = gridThumbs.filter(t => !t.classList.contains('onara-active')); + const chosen = (pool.length > 0 ? pool : gridThumbs)[Math.floor(Math.random() * (pool.length > 0 ? pool : gridThumbs).length)]; + if (chosen) { + document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); + chosen.classList.add('onara-active'); + if (document.activeElement && document.activeElement !== chosen && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + loadItemAjax(chosen.href, true); + return; + } + } + } + stopMedia(); // Abort media BEFORE fetching random item // Fade out background canvas for smooth transition @@ -4590,6 +4937,11 @@ window.cancelAnimFrame = (function () { window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike }); + if (document.body.classList.contains('onara-modal-open') && !isItem) { + closeOnaraModal({ skipHistory: true }); + return; + } + if (isItem) { loadItemAjax(url, true, { skipPush: true }); } else if (isSpecial || isGridLike) { @@ -4872,7 +5224,9 @@ window.cancelAnimFrame = (function () { if (infiniteState.currentPage !== p) { infiniteState.currentPage = p; infiniteState.firstPage = p; - history.replaceState({}, '', buildUrl(p)); + if (!document.body.classList.contains('onara-modal-open')) { + history.replaceState({}, '', buildUrl(p)); + } } delete postsContainer.dataset.currentPage; // Clear after reading @@ -4948,6 +5302,11 @@ window.cancelAnimFrame = (function () { return; } + // Never overwrite item URL with /p/N when Onara modal is open! + if (document.body.classList.contains('onara-modal-open')) { + return; + } + // Update URL infiniteState.visiblePage = newPage; history.replaceState({}, '', buildUrl(newPage)); @@ -9328,8 +9687,12 @@ document.addEventListener("DOMContentLoaded", function () { if (newPath === '') newPath = '/'; url.pathname = newPath; - // Update the browser URL without reloading - history.replaceState({}, '', url.toString()); + const isOnaraOpen = document.body.classList.contains('onara-modal-open'); + + // Update the browser URL without reloading (only when not in onara modal) + if (!isOnaraOpen) { + history.replaceState({}, '', url.toString()); + } // Sync UI immediately (Before AJAX) if (window.updateMimeLabel) window.updateMimeLabel(); @@ -9342,7 +9705,10 @@ document.addEventListener("DOMContentLoaded", function () { // Set flag so hideAllModals (called inside loadPageAjax) skips the filter overlay if (fromModal) window._keepFilterModal = true; - if (postsEl) { + if (isOnaraOpen) { + const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); + reloadPromise = window.loadPageAjax ? window.loadPageAjax(bgUrl, { skipPush: true, skipCache: true }) : null; + } else if (postsEl) { reloadPromise = window.loadPageAjax ? window.loadPageAjax(url.toString(), { skipPush: true }) : null; } else if (document.getElementById('main')?.classList.contains('item-view') || document.querySelector('.item-layout-container, .item-main-content')) { // On item view, refresh the context but KEEP media playing @@ -10191,7 +10557,7 @@ document.addEventListener('DOMContentLoaded', () => { } // Close logic - if (e.target.matches('#report-cancel')) { + if (e.target.matches('#report-cancel') || e.target.id === 'report-modal') { reportModal.style.display = 'none'; document.body.classList.remove('modal-open'); } @@ -11486,7 +11852,7 @@ document.addEventListener('click', (e) => { } const closeBtn = e.target.closest('#info-modal-close, #info-modal-close-icon'); - if (closeBtn) { + if (closeBtn || e.target.id === 'info-modal') { e.preventDefault(); const modal = document.getElementById('info-modal'); if (modal) { @@ -11683,21 +12049,25 @@ document.addEventListener('click', (e) => { if (data.success) { if (window.flashMessage) window.flashMessage('THUMBNAIL REGENERATED'); else if (window.showFlash) window.showFlash('Thumbnail regenerated'); - // Cache-bust thumbnails on page + // Cache-bust thumbnails and player media on page const timestamp = Date.now(); - document.querySelectorAll(`img[src*="/t/${itemId}.webp"]`).forEach(img => { - const url = new URL(img.src, window.location.origin); - url.searchParams.set('t', timestamp); - img.src = url.toString(); - }); - document.querySelectorAll(`.thumb[data-bg*="/t/${itemId}.webp"]`).forEach(thumb => { - const bg = thumb.dataset.bg; - if (bg) { - const url = new URL(bg, window.location.origin); + if (typeof window.refreshItemThumbnails === 'function') { + window.refreshItemThumbnails(itemId, timestamp); + } else { + document.querySelectorAll(`img[src*="/t/${itemId}.webp"]`).forEach(img => { + const url = new URL(img.src, window.location.origin); url.searchParams.set('t', timestamp); - thumb.style.setProperty('--thumb-bg', `url('${url.toString()}')`); - } - }); + img.src = url.toString(); + }); + document.querySelectorAll(`.thumb[data-bg*="/t/${itemId}.webp"]`).forEach(thumb => { + const bg = thumb.dataset.bg; + if (bg) { + const url = new URL(bg, window.location.origin); + url.searchParams.set('t', timestamp); + thumb.style.setProperty('--thumb-bg', `url('${url.toString()}')`); + } + }); + } } else { if (window.flashError) window.flashError(data.msg || 'Regeneration failed'); else alert(data.msg || 'Regeneration failed'); diff --git a/src/inc/config.mjs b/src/inc/config.mjs index cfd8e7e..106f085 100644 --- a/src/inc/config.mjs +++ b/src/inc/config.mjs @@ -39,20 +39,42 @@ const storage = process.env.STORAGE_DIR; const resolvePath = (defaultRel) => { const local = path.resolve(path.join(base, defaultRel)); + let target = local; if (storage) { const absStorage = path.resolve(storage); if (defaultRel.startsWith('public/')) { const sub = defaultRel.replace('public/', ''); if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') { const storagePath = path.join(absStorage, sub.split('/').pop()); - if (fs.existsSync(storagePath)) return path.resolve(storagePath); - return local; + if (fs.existsSync(storagePath)) target = path.resolve(storagePath); + else target = local; + } else { + target = path.resolve(path.join(absStorage, sub)); } - return path.resolve(path.join(absStorage, sub)); + } else { + target = path.resolve(path.join(absStorage, defaultRel)); } - return path.resolve(path.join(absStorage, defaultRel)); } - return local; + try { + if (fs.existsSync(target)) { + return fs.realpathSync(target); + } + const parent = path.dirname(target); + if (fs.existsSync(parent)) { + return path.join(fs.realpathSync(parent), path.basename(target)); + } + } catch (_) {} + return target; +}; + +const resolveImportPath = () => { + const p = storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import')); + try { + if (fs.existsSync(p)) return fs.realpathSync(p); + const parent = path.dirname(p); + if (fs.existsSync(parent)) return path.join(fs.realpathSync(parent), path.basename(p)); + } catch (_) {} + return p; }; config.paths = { @@ -61,7 +83,11 @@ config.paths = { c: resolvePath('public/c'), t: resolvePath('public/t'), ca: resolvePath('public/ca'), - s: path.join(base, 'public/s'), + s: (() => { + const sPath = path.join(base, 'public/s'); + try { if (fs.existsSync(sPath)) return fs.realpathSync(sPath); } catch (_) {} + return sPath; + })(), emojis: resolvePath('public/s/emojis'), koepfe: resolvePath('public/s/koepfe'), fonts: resolvePath('public/s/fonts'), @@ -71,7 +97,7 @@ config.paths = { deleted: resolvePath('deleted'), logs: resolvePath('logs'), tmp: resolvePath('tmp'), - import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import')) + import: resolveImportPath() }; export default config; diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs index b45fdae..11c4cc5 100644 --- a/src/inc/queue.mjs +++ b/src/inc/queue.mjs @@ -354,9 +354,12 @@ export default new class queue { } async genThumbnail(filename, mime, itemid, link, pending = false, size = 512) { - const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b; - const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; - const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca; + let bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b; + let tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; + let cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca; + try { bDir = await fs.promises.realpath(bDir); } catch (_) {} + try { tDir = await fs.promises.realpath(tDir); } catch (_) {} + try { cDir = await fs.promises.realpath(cDir); } catch (_) {} const tmpFile = path.join(os.tmpdir(), itemid + '.png'); const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg'); const thumbSize = (size && size > 128) ? size : 128; @@ -372,7 +375,13 @@ export default new class queue { } } catch (e) {} - const outPath = path.join(tDir, itemid + '.webp'); + let outPath = path.join(tDir, itemid + '.webp'); + try { + const outStat = await fs.promises.lstat(outPath); + if (outStat.isSymbolicLink()) { + await fs.promises.unlink(outPath).catch(() => {}); + } + } catch (_) {} try { @@ -457,17 +466,17 @@ export default new class queue { else if (mime.startsWith('audio/')) { let coverExtracted = false; this._lastCoverExtracted = false; // Reset state for this call - if (link.match(/soundcloud/)) { + if (link && typeof link === 'string' && link.match(/soundcloud/)) { const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : []; let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop(); - if (!cover.match(/default_avatar/)) { + if (cover && !cover.match(/default_avatar/)) { cover = cover.replace(/-(large|original)\./, '-t500x500.'); try { const curlArgs = ['-s', '-L', cover, '-o', tmpJpg]; if (proxyArgs.length > 0) curlArgs.push(...proxyArgs); await this.spawn('curl', curlArgs); const size = (await fs.promises.stat(tmpJpg)).size; - if (size >= 0) { + if (size > 0) { await this.spawn('magick', [tmpJpg, tmpFile]); await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]); coverExtracted = true; @@ -497,6 +506,17 @@ export default new class queue { } } catch (err) { } } + // If no new cover art extracted, check if cover art was already saved previously in cDir + if (!coverExtracted) { + const existingCover = path.join(cDir, itemid + '.webp'); + try { + const stat = await fs.promises.stat(existingCover); + if (stat.size > 0) { + await this.spawn('magick', [existingCover, tmpFile]); + coverExtracted = true; + } + } catch (_) { } + } // If no cover art found, use audio.webp as the thumbnail if (!coverExtracted) { const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp'); @@ -641,9 +661,16 @@ export default new class queue { async genBlurredThumbnail(itemid, pending = false) { - const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; + let tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t; + try { tDir = await fs.promises.realpath(tDir); } catch (_) {} const src = path.join(tDir, `${itemid}.webp`); const dst = path.join(tDir, `${itemid}_blur.webp`); + try { + const dstStat = await fs.promises.lstat(dst); + if (dstStat.isSymbolicLink()) { + await fs.promises.unlink(dst).catch(() => {}); + } + } catch (_) {} try { await this.spawn('magick', [src, '-blur', '0x48', dst]); return true; diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 5f3627c..5e4f64b 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -5,6 +5,7 @@ import { getEnableItemSlugs } from "../settings.mjs"; import { updateHallsCache } from "../halls_cache.mjs"; import queue from "../queue.mjs"; import fs from "fs"; +import path from "path"; import url from "url"; const getGlobalfilter = () => { @@ -234,7 +235,283 @@ async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {} return { isPrivate: true, isAllowed }; } +const buildFeedFilters = async ({ + rawUser, + rawTag, + rawHall, + rawMime, + mode, + ratings, + session, + strict, + exclude, + newer, + minXdScore, + rawUserHall, + rawUserHallOwner, + rawTagger +}) => { + const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; + + // --- title: prefix — search items.title instead of the tags table --- + const _decodedTag = rawTag ? decodeURIComponent(rawTag) : ''; + const isTitleSearch = _decodedTag.startsWith('title:'); + const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null; + + const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null); + let hall = rawHall ?? null; + let hallObj = null; + if (hall) { + const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`; + if (hallData.length) { + hallObj = { name: hallData[0].name, slug: hallData[0].slug, description: hallData[0].description }; + } + } + // User hall context + const userHallSlug = rawUserHall ?? null; + const userHallOwner = rawUserHallOwner ?? null; + let userHallObj = null; + if (userHallSlug && userHallOwner) { + const uhData = await db` + SELECT uh.id, uh.name, uh.slug, uh.description, uh.is_private, u."user" as owner_name + FROM user_halls uh + JOIN "user" u ON u.id = uh.user_id + WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug} + LIMIT 1 + `; + if (uhData.length) userHallObj = uhData[0]; + } + const mime = rawMime ?? null; + + // Support multiple MIME types (comma separated) + const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); + const mimeSQL = mimeParts.length > 0 + ? db`and (${mimeParts.map(m => m === 'flash' + ? (flashMimes.length > 0 + ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) + : db`false`) + : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` + : db``; + + const excludedTags = session && exclude ? (exclude || []) : []; + const newerThan = newer ? parseInt(newer) : null; + const minXd = (minXdScore && +minXdScore > 0) ? +minXdScore : 0; + const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``; + + const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; + const isStrict = strictParams.length > 0; + + const tagger = rawTagger ? lib.escapeLike(rawTagger) : null; + const modequery = computeBaseMode(mode, ratings, session); + + let tagFilter = db``; + let titleFilter = db``; + if (isTitleSearch && titleQuery) { + titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`; + } else if (tagger && tag) { + const terms = tag.split(',').map(t => t.trim()).filter(Boolean); + if (terms.length > 0) { + const conditions = terms.map(term => { + return db`and items.id in ( + select ta.item_id from tags_assign ta + join tags t on t.id = ta.tag_id + join "user" u on u.id = ta.user_id + where t.normalized like '%' || slugify(${term}) || '%' + and u.user ilike ${tagger} + )`; + }); + tagFilter = db`${conditions}`; + } + } else if (tag) { + const terms = tag.split(',').map(t => t.trim()).filter(Boolean); + if (terms.length > 0) { + if (isStrict) { + tagFilter = db`and items.id in ( + select ta.item_id + from tags_assign ta + join tags t on t.id = ta.tag_id + where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x)) + group by ta.item_id + having count(distinct t.normalized) = ${terms.length} + )`; + } else { + const conditions = terms.map(term => { + return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`; + }); + tagFilter = db`${conditions}`; + } + } + } + + let hallFilter = db``; + if (hall) { + hallFilter = db`and items.id in (select item_id from halls_assign join halls on halls.id = halls_assign.hall_id where halls.slug = ${(hall && typeof hall === 'object') ? hall.slug : hall})`; + } + + let userHallFilter = db``; + if (userHallObj) { + userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; + } + + const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator)); + const visibilityFilter = isOwnerOrAdmin + ? db`` + : (session && session.user + ? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})` + : db`and coalesce(items.visibility, 0) = 0`); + + return { + user, + _decodedTag, + isTitleSearch, + titleQuery, + tag, + hall, + hallObj, + userHallSlug, + userHallOwner, + userHallObj, + mime, + mimeSQL, + excludedTags, + newerThan, + minXd, + xdFilter, + strictParams, + isStrict, + tagger, + modequery, + tagFilter, + titleFilter, + hallFilter, + userHallFilter, + visibilityFilter + }; +}; + export default { + getItemPage: async ({ + targetItemId, + targetItemPinned, + user: rawUser, + tag: rawTag, + hall: rawHall, + mime: rawMime, + mode, + ratings, + fav, + session, + limit, + strict, + newer, + exclude, + user_id, + is_admin, + userHall: rawUserHall, + userHallOwner: rawUserHallOwner, + minXdScore, + tagger: rawTagger + } = {}) => { + const numId = await resolveNumericItemId(targetItemId); + if (!numId) return 1; + + let isPinned = targetItemPinned; + if (isPinned === undefined) { + const rows = await db`SELECT is_pinned FROM items WHERE id = ${numId} LIMIT 1`; + if (!rows.length) return 1; + isPinned = Boolean(rows[0].is_pinned); + } else { + isPinned = Boolean(isPinned); + } + + const eps = limit ?? cfg.websrv.eps; + + const filters = await buildFeedFilters({ + rawUser, + rawTag, + rawHall, + rawMime, + mode, + ratings, + session, + strict, + exclude, + newer, + minXdScore, + rawUserHall, + rawUserHallOwner, + rawTagger + }); + + const { + user, + mimeSQL, + excludedTags, + newerThan, + xdFilter, + modequery, + tagFilter, + titleFilter, + hallFilter, + userHallFilter, + visibilityFilter + } = filters; + + // Check if the target item actually matches this feed's criteria + const itemMatch = await db` + select 1 from items + ${fav ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``} + where + items.id = ${numId} + and ${db.unsafe(modequery)} + and items.active = true + ${visibilityFilter} + ${tagFilter} + ${titleFilter} + ${fav ? db`and fav_u.user ilike ${user}` : db``} + ${!fav && user ? db`and items.username ilike ${user}` : db``} + ${mimeSQL} + ${hallFilter} + ${userHallFilter} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} + ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} + ${newerThan ? db`and items.id > ${newerThan}` : db``} + ${xdFilter} + limit 1 + `; + + if (!itemMatch.length) { + return 1; + } + + const countRows = await db` + select count(distinct items.id) as total_before + from items + ${fav ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``} + where + ${db.unsafe(modequery)} + and items.active = true + ${visibilityFilter} + ${tagFilter} + ${titleFilter} + ${fav ? db`and fav_u.user ilike ${user}` : db``} + ${!fav && user ? db`and items.username ilike ${user}` : db``} + ${mimeSQL} + ${hallFilter} + ${userHallFilter} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} + ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} + ${newerThan ? db`and items.id > ${newerThan}` : db``} + ${xdFilter} + and ( + (items.is_pinned is true and ${isPinned} is false) + or (items.is_pinned = ${isPinned} and items.id > ${numId}) + ) + `; + + const totalBefore = Number(countRows[0]?.total_before || 0); + return Math.floor(totalBefore / eps) + 1; + }, getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => { if (fav && rawUser) { const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin }); @@ -247,121 +524,54 @@ export default { } } - const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; + const filters = await buildFeedFilters({ + rawUser, + rawTag, + rawHall, + rawMime, + mode, + ratings, + session, + strict, + exclude, + newer, + minXdScore, + rawUserHall, + rawUserHallOwner, + rawTagger + }); - // --- title: prefix — search items.title instead of the tags table --- - const _decodedTag = rawTag ? decodeURIComponent(rawTag) : ''; - const isTitleSearch = _decodedTag.startsWith('title:'); - const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null; + const { + user, + _decodedTag, + isTitleSearch, + titleQuery, + tag, + hall, + hallObj, + userHallSlug, + userHallOwner, + userHallObj, + mime, + mimeSQL, + excludedTags, + newerThan, + minXd, + xdFilter, + strictParams, + isStrict, + tagger, + modequery, + tagFilter, + titleFilter, + hallFilter, + userHallFilter, + visibilityFilter + } = filters; - const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null); - let hall = rawHall ?? null; - let hallObj = null; - if (hall) { - const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`; - if (hallData.length) { - hallObj = { name: hallData[0].name, slug: hallData[0].slug, description: hallData[0].description }; - } - } - // User hall context - const userHallSlug = rawUserHall ?? null; - const userHallOwner = rawUserHallOwner ?? null; - let userHallObj = null; - if (userHallSlug && userHallOwner) { - const uhData = await db` - SELECT uh.id, uh.name, uh.slug, uh.description, uh.is_private, u."user" as owner_name - FROM user_halls uh - JOIN "user" u ON u.id = uh.user_id - WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug} - LIMIT 1 - `; - if (uhData.length) userHallObj = uhData[0]; - } - const mime = rawMime ?? null; const actPage = +(page ?? 1); - - // Support multiple MIME types (comma separated) - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; const eps = limit ?? cfg.websrv.eps; - const excludedTags = session && exclude ? (exclude || []) : []; - const newerThan = newer ? parseInt(newer) : null; - const minXd = (minXdScore && +minXdScore > 0) ? +minXdScore : 0; - // xD filter: use materialized items.xd_score column (kept live by trigger) for fast indexed lookup - const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``; - - const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; - const isStrict = strictParams.length > 0; - - const tagger = rawTagger ? lib.escapeLike(rawTagger) : null; const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger }; - const modequery = computeBaseMode(mode, ratings, session); - - let tagFilter = db``; - let titleFilter = db``; - if (isTitleSearch && titleQuery) { - // Title search: match items.title ILIKE '%query%' - titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`; - } else if (tagger && tag) { - // Tagger+tag filter: items where the specific user applied this specific tag - const terms = tag.split(',').map(t => t.trim()).filter(Boolean); - if (terms.length > 0) { - const conditions = terms.map(term => { - return db`and items.id in ( - select ta.item_id from tags_assign ta - join tags t on t.id = ta.tag_id - join "user" u on u.id = ta.user_id - where t.normalized like '%' || slugify(${term}) || '%' - and u.user ilike ${tagger} - )`; - }); - tagFilter = db`${conditions}`; - } - } else if (tag) { - const terms = tag.split(',').map(t => t.trim()).filter(Boolean); - if (terms.length > 0) { - if (isStrict) { - tagFilter = db`and items.id in ( - select ta.item_id - from tags_assign ta - join tags t on t.id = ta.tag_id - where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x)) - group by ta.item_id - having count(distinct t.normalized) = ${terms.length} - )`; - } else { - // Non-strict intersection Logic (AND for partials) - // For each term, ensure there is AT LEAST one matching tag assigned to the item - const conditions = terms.map(term => { - return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`; - }); - tagFilter = db`${conditions}`; - } - } - } - - let hallFilter = db``; - if (hall) { - hallFilter = db`and items.id in (select item_id from halls_assign join halls on halls.id = halls_assign.hall_id where halls.slug = ${(hall && typeof hall === 'object') ? hall.slug : hall})`; - } - - let userHallFilter = db``; - if (userHallObj) { - userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; - } - - const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator)); - const visibilityFilter = isOwnerOrAdmin - ? db`` - : (session && session.user - ? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})` - : db`and coalesce(items.visibility, 0) = 0`); const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }); let total = getCachedCount(cacheKey); @@ -926,7 +1136,17 @@ export default { // Efficient coverart fallback - const coverartUrl = actitem.has_coverart + let hasCoverart = actitem.has_coverart; + if (!hasCoverart && actitem.mime?.startsWith('audio/')) { + const caPath = path.join(cfg.paths.ca, `${actitem.id}.webp`); + try { + if (fs.existsSync(caPath)) { + hasCoverart = true; + db`UPDATE items SET has_coverart = TRUE WHERE id = ${actitem.id}`.catch(() => {}); + } + } catch (_) {} + } + const coverartUrl = hasCoverart ? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp` : `/s/img/music.webp`; diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index cdbb4c3..3daaa3f 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -1302,7 +1302,7 @@ export default router => { if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400); const rows = await db` - SELECT id, dest, mime, username + SELECT id, dest, mime, username, src FROM items WHERE id = ${itemid} AND active = true AND is_deleted = false LIMIT 1 @@ -1314,13 +1314,26 @@ export default router => { const isAdmin = !!(req.session.admin || req.session.is_moderator); if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403); - const ok = await queue.genThumbnail(item.dest, item.mime, item.id, '', false); + const ok = await queue.genThumbnail(item.dest, item.mime, item.id, item.src || '', false); await queue.genBlurredThumbnail(item.id, false); + let hasCoverart = false; + if (item.mime && item.mime.startsWith('audio/')) { + const caFile = path.join(cfg.paths.ca, `${item.id}.webp`); + hasCoverart = !!queue._lastCoverExtracted; + if (!hasCoverart) { + try { + const stat = await fs.stat(caFile); + hasCoverart = stat.size > 0; + } catch (_) {} + } + await db`UPDATE items SET has_coverart = ${hasCoverart} WHERE id = ${item.id}`; + } + if (ok) { - db.notify('rethumb', JSON.stringify({ item_id: item.id })).catch(() => {}); + db.notify('rethumb', JSON.stringify({ item_id: item.id, has_coverart: hasCoverart })).catch(() => {}); audit.log(req.session.id, 'rethumb_item', 'item', item.id, {}).catch(() => {}); - return res.json({ success: true, itemid: item.id }); + return res.json({ success: true, itemid: item.id, has_coverart: hasCoverart }); } else { return res.json({ success: false, msg: 'Thumbnail regeneration failed' }, 500); } diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 6f20159..875fbaa 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -408,6 +408,105 @@ export default (router, tpl) => { return res.reply({ body }); } + const isOnara = !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara); + if (mode === 'item' && isOnara) { + const itemHtml = tpl.render('ajax-item', data, req); + + const isRandom = req.cookies.random_mode === '1'; + let itemPage = 1; + if (req.params.page) { + itemPage = parseInt(req.params.page, 10) || 1; + } else if (!isRandom && data.item) { + try { + itemPage = await f0cklib.getItemPage({ + targetItemId: data.item.id, + targetItemPinned: data.item.is_pinned, + user: req.params.user, + tag: reqTag, + mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), + hall: req.params.hall, + fav: req.params.mode == 'favs', + mode: req.mode, + ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), + session: req.session, + user_id: req.session?.id, + is_admin: req.session?.admin, + exclude: req.session ? (req.session.excluded_tags || []) : [], + strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), + minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0), + tagger: req.url.qs?.tagger || null + }); + } catch (err) { + console.error('[ONARA] getItemPage error:', err); + itemPage = 1; + } + } + + let gridData = await f0cklib.getf0cks({ + user: req.params.user, + tag: reqTag, + mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null), + page: itemPage, + hall: req.params.hall, + fav: req.params.mode == 'favs', + mode: req.mode, + ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), + session: req.session, + user_id: req.session?.id, + is_admin: req.session?.admin, + exclude: req.session ? (req.session.excluded_tags || []) : [], + url: decodeURIComponent(req.url.pathname || req.url), + strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), + explicitStrict: !!(req.query?.strict || req.url.qs?.strict), + random: isRandom, + minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0), + lang: req.lang, + tagger: req.url.qs?.tagger || null + }); + + if (!gridData || !gridData.success) { + gridData = { + items: [], + pagination: { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null }, + total: 0, + success: true, + link: { main: '/', path: 'p/', suffix: '' }, + tmp: {} + }; + } + + const activeId = data.item ? data.item.id : req.params.itemid; + const activeSlug = data.item ? data.item.slug : null; + + // Mark the active item in the grid for instant highlight + let foundInGrid = false; + if (Array.isArray(gridData.items)) { + for (const it of gridData.items) { + if ((activeSlug && it.slug === activeSlug) || (it.id === activeId)) { + it.is_onara_active = true; + foundInGrid = true; + } else { + it.is_onara_active = false; + } + } + // If not in first page of feed, unshift it so thumbnail is present in background + if (!foundInGrid && data.item) { + gridData.items.unshift({ ...data.item, is_onara_active: true }); + } + } + + gridData.session = data.session; + gridData.is_onara_item = true; + gridData.onara_item_html = itemHtml; + gridData.item = data.item; + gridData.page_meta = data.page_meta; + gridData.hidePagination = false; + + const body = tpl.render('index', gridData, req); + console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (onara direct item) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); + return res.reply({ body }); + } + const body = tpl.render(mode, data, req); console.log(`[${new Date().toISOString()}] [ROUTE] Render complete in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`); diff --git a/src/index.mjs b/src/index.mjs index 2939bee..f910f67 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1463,6 +1463,8 @@ process.on('uncaughtException', err => { default_font: cfg.websrv.default_font || "", site_description: cfg.websrv.description || "The webs dumpster", enable_nsfl: !!cfg.enable_nsfl, + onara: !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara), + is_onara_item: false, enable_private_uploads: cfg.enable_private_uploads !== false, get enable_expiring_uploads() { return getEnableExpiringUploads(); }, get enable_item_slugs() { return getEnableItemSlugs(); }, @@ -1588,6 +1590,8 @@ process.on('uncaughtException', err => { globals.t = perRequestT; globals.lang = perRequestLang; globals.recaptcha_enabled = perRequestRecaptcha; + globals.is_onara_item = !!(data && data.is_onara_item); + globals.onara_item_html = (data && data.onara_item_html) || ''; // Resolve per-request infobox preference const activeReq = req || effectiveReq; diff --git a/src/rethumb_handler.mjs b/src/rethumb_handler.mjs index 04dcae8..61159e8 100644 --- a/src/rethumb_handler.mjs +++ b/src/rethumb_handler.mjs @@ -124,7 +124,8 @@ export const handleRethumbUpload = async (req, res, itemId) => { } // Generate the thumbnail - const tDir = item.active ? cfg.paths.t : path.join(cfg.paths.pending, 't'); + let tDir = item.active ? cfg.paths.t : path.join(cfg.paths.pending, 't'); + try { tDir = await fs.realpath(tDir); } catch (_) {} const finalPath = path.join(tDir, `${item.id}.webp`); const thumbSpec = '512x512'; diff --git a/views/index-partial.html b/views/index-partial.html index 9441f1f..49f386a 100644 --- a/views/index-partial.html +++ b/views/index-partial.html @@ -3,7 +3,7 @@ @include(snippets/page-title)
@each(items as item) - +
@if(item.is_pinned) diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 9ec5418..bd7a601 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -82,7 +82,7 @@
-