From d97c095635927277006556da6250276e02592e40 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 11 Sep 2026 17:59:01 +0200 Subject: [PATCH] jdf --- public/s/css/f0ckm.css | 84 ++++++- public/s/js/comments.js | 62 +++-- public/s/js/f0ckm.js | 446 +++++++++++++++++++++++++-------- src/inc/routes/apiv2/index.mjs | 39 ++- views/snippets/header.html | 2 +- views/snippets/navbar.html | 1 + 6 files changed, 503 insertions(+), 131 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 8a7eeb7..c910d47 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -2777,19 +2777,23 @@ body.sidebar-right-hidden #sidebar-drag-zone { /* Body-level left-sidebar hide — set by blocking inline script before first paint to prevent the sidebar from flashing visible. Mirrors the .sidebar-hidden rules above but activates from the body class so it works before the container element exists. */ - body.layout-modern.sidebar-left-hidden .item-layout-container { + body.layout-modern.sidebar-left-hidden .item-layout-container, + body.layout-modern.comments-hidden .item-layout-container { grid-template-columns: 0px minmax(0, 1fr) 400px; } - body.layout-modern.sidebar-left-hidden.sidebar-right-hidden .item-layout-container { + body.layout-modern.sidebar-left-hidden.sidebar-right-hidden .item-layout-container, + body.layout-modern.comments-hidden.sidebar-right-hidden .item-layout-container { grid-template-columns: 0px minmax(0, 1fr) 0px; } - body.layout-modern.sidebar-left-hidden .item-layout-container .item-sidebar-left { - display: none; + body.layout-modern.sidebar-left-hidden .item-layout-container .item-sidebar-left, + body.layout-modern.comments-hidden .item-layout-container .item-sidebar-left { + display: none !important; } - body.layout-modern.sidebar-left-hidden .item-layout-container .item-main-content { + body.layout-modern.sidebar-left-hidden .item-layout-container .item-main-content, + body.layout-modern.comments-hidden .item-layout-container .item-main-content { grid-column: 2; } @@ -3324,6 +3328,27 @@ body.layout-legacy #comments-container.faded-out { pointer-events: none; } +/* Full hidden state for comments & left sidebar — overrides flex !important so no plopping or ghost spacing occurs */ +body.sidebar-left-hidden #comments-container, +body.comments-hidden #comments-container, +body.layout-legacy.sidebar-left-hidden #comments-container, +body.layout-legacy.comments-hidden #comments-container, +body.layout-legacy .item-layout-container.sidebar-hidden #comments-container, +body.layout-modern.sidebar-left-hidden #comments-container, +body.layout-modern.comments-hidden #comments-container, +body.layout-modern.sidebar-left-hidden .item-sidebar-left, +body.layout-modern.comments-hidden .item-sidebar-left, +body.layout-modern .item-layout-container.sidebar-hidden .item-sidebar-left, +body.sidebar-left-hidden #onara-modal #comments-container, +body.comments-hidden #onara-modal #comments-container, +body.sidebar-left-hidden #onara-modal .item-sidebar-left, +body.comments-hidden #onara-modal .item-sidebar-left, +#onara-modal #comments-container.is-hidden, +#comments-container.is-hidden, +.item-sidebar-left.is-hidden { + display: none !important; +} + body.layout-legacy .comment-input { border-top: 1px solid var(--nav-border-color); border-left: 1px solid var(--nav-border-color); @@ -5772,6 +5797,49 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid { will-change: transform, opacity; } +.content.fade-in-zoom, +._204863.fade-in-zoom, +.item_title.fade-in-zoom, +#onara-modal .content.fade-in-zoom, +#onara-modal ._204863.fade-in-zoom, +#onara-modal .item_title.fade-in-zoom { + animation: contentFadeInZoom 0.35s cubic-bezier(0.16, 1, 0.3, 1) both !important; + will-change: transform, opacity; +} + +.content.fade-out-zoom, +._204863.fade-out-zoom, +.item_title.fade-out-zoom, +#onara-modal .content.fade-out-zoom, +#onara-modal ._204863.fade-out-zoom, +#onara-modal .item_title.fade-out-zoom { + animation: contentFadeOutZoom 0.18s cubic-bezier(0.3, 0, 0.8, 1) forwards !important; + pointer-events: none; + will-change: transform, opacity; +} + +@keyframes contentFadeInZoom { + 0% { + opacity: 0; + transform: scale(1.03); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes contentFadeOutZoom { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.97); + } +} + @keyframes contentSlideIn { 0% { opacity: 0; @@ -19028,9 +19096,9 @@ body.layout-modern #onara-modal .item-layout-container .item-main-content { margin-right: auto !important; } -#onara-modal .content:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right), -#onara-modal ._204863:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right), -#onara-modal .item_title:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right) { +#onara-modal .content:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom), +#onara-modal ._204863:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom), +#onara-modal .item_title:not(.slide-next):not(.slide-prev):not(.slide-out-left):not(.slide-out-right):not(.fade-in-zoom):not(.fade-out-zoom) { animation: contentSlideIn 0.55s cubic-bezier(0.16, 1, 0.3, 1) both !important; will-change: transform, opacity; } diff --git a/public/s/js/comments.js b/public/s/js/comments.js index b208ddf..3789876 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -48,18 +48,30 @@ class CommentSystem { const isHidden = localStorage.getItem('comments_hidden') === 'true'; // Force show if hash is present if (window.location.hash && window.location.hash.startsWith('#c')) { - this.container.classList.remove('faded-out'); - this.container.style.display = 'block'; + this.container.classList.remove('faded-out', 'is-hidden'); + this.container.style.removeProperty('display'); + this.container.style.display = ''; localStorage.setItem('comments_hidden', 'false'); - document.body.classList.remove('sidebar-left-hidden'); + document.body.classList.remove('sidebar-left-hidden', 'comments-hidden'); const layout = this.container.closest('.item-layout-container'); if (layout) layout.classList.remove('sidebar-hidden'); - } else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) { - this.container.classList.add('faded-out'); - this.container.style.display = 'none'; - document.body.classList.add('sidebar-left-hidden'); + const sidebar = this.container.closest('.item-sidebar-left'); + if (sidebar) { + sidebar.classList.remove('is-hidden'); + sidebar.style.removeProperty('display'); + sidebar.style.display = ''; + } + } else if (isHidden) { + this.container.classList.add('faded-out', 'is-hidden'); + this.container.style.setProperty('display', 'none', 'important'); + document.body.classList.add('sidebar-left-hidden', 'comments-hidden'); const layout = this.container.closest('.item-layout-container'); if (layout) layout.classList.add('sidebar-hidden'); + const sidebar = this.container.closest('.item-sidebar-left'); + if (sidebar) { + sidebar.classList.add('is-hidden'); + sidebar.style.setProperty('display', 'none', 'important'); + } } } @@ -3878,7 +3890,12 @@ class CommentSystem { e.preventDefault(); // If comments are hidden, show them first - const isHidden = cs.container.classList.contains('faded-out') || cs.container.style.display === 'none'; + const isHidden = cs.container.classList.contains('faded-out') || + cs.container.classList.contains('is-hidden') || + cs.container.style.display === 'none' || + document.body.classList.contains('sidebar-left-hidden') || + document.body.classList.contains('comments-hidden') || + localStorage.getItem('comments_hidden') === 'true'; if (isHidden) cs.toggleComments(); // Legacy layout: comments are in the normal page flow under .item-main-content @@ -3959,7 +3976,7 @@ class CommentSystem { toggleComments() { if (!this.container) return; - if (document.body.classList.contains('layout-modern')) { + if (document.body.classList.contains('layout-modern') && !document.body.classList.contains('onara-modal-open')) { if (window.innerWidth < 1000) return; if (typeof window.toggleSidebarLeft === 'function') { window.toggleSidebarLeft(); @@ -3974,27 +3991,44 @@ class CommentSystem { ] : []; // Check if currently hidden (or slid out) - const isHidden = this.container.classList.contains('faded-out') || this.container.style.display === 'none'; + const isHidden = this.container.classList.contains('faded-out') || + this.container.classList.contains('is-hidden') || + this.container.style.display === 'none' || + document.body.classList.contains('sidebar-left-hidden') || + document.body.classList.contains('comments-hidden') || + localStorage.getItem('comments_hidden') === 'true'; if (isHidden) { // SHOW: expand grid first, then slide content in if (layout) layout.classList.remove('sidebar-hidden'); - document.body.classList.remove('sidebar-left-hidden'); + document.body.classList.remove('sidebar-left-hidden', 'comments-hidden'); + this.container.classList.remove('is-hidden'); + this.container.style.removeProperty('display'); this.container.style.display = ''; + if (sidebar) { + sidebar.classList.remove('is-hidden'); + sidebar.style.removeProperty('display'); + sidebar.style.display = ''; + } localStorage.setItem('comments_hidden', 'false'); void this.container.offsetWidth; // force reflow so transition fires this.container.classList.remove('faded-out'); siblings.forEach(el => el.classList.remove('faded-out')); } else { - // HIDE: slide content out first, then collapse grid + // HIDE: mark state immediately so quick navigations stay hidden, fade out content, then collapse localStorage.setItem('comments_hidden', 'true'); this.container.classList.add('faded-out'); siblings.forEach(el => el.classList.add('faded-out')); setTimeout(() => { if (!this.container.classList.contains('faded-out')) return; - this.container.style.display = 'none'; + this.container.classList.add('is-hidden'); + this.container.style.setProperty('display', 'none', 'important'); + if (sidebar) { + sidebar.classList.add('is-hidden'); + sidebar.style.setProperty('display', 'none', 'important'); + } if (layout) layout.classList.add('sidebar-hidden'); - document.body.classList.add('sidebar-left-hidden'); + document.body.classList.add('sidebar-left-hidden', 'comments-hidden'); }, 300); } } diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 01881ad..1830382 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1135,6 +1135,21 @@ window.cancelAnimFrame = (function () { }; window.updateOnaraActiveItem = updateOnaraActiveItem; + const isItemPath = (pathOrUrl) => { + try { + const p = new URL(pathOrUrl, window.location.origin).pathname; + const parts = p.split('/').filter(Boolean); + return !p.match(/\/p\//) && ( + /^\/\d+/.test(p) || + /^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/.test(p) || + (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) + ); + } catch { + return false; + } + }; + window.isItemPath = isItemPath; + const getOnaraBaseUrl = (itemUrl = window.location.href) => { try { const urlObj = new URL(itemUrl, window.location.origin); @@ -1142,7 +1157,8 @@ window.cancelAnimFrame = (function () { 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/')) { + const currentGridPath = window._onaraCurrentGridUrl ? new URL(window._onaraCurrentGridUrl, window.location.origin).pathname.replace(/\/p\/\d+/, '').replace(/\/+$/, '') : ''; + if (currentPage > 1 && !path.includes('/p/') && (!currentGridPath || currentGridPath === path)) { path = (path ? path : '') + '/p/' + currentPage; } if (!path) path = '/'; @@ -1202,15 +1218,30 @@ window.cancelAnimFrame = (function () { 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; + const returnTitle = window._onaraReturnTitle || window.f0ckDomain || ''; + // Always jump directly to the return URL — never step back through stacked item history. + // history.back() would re-open the previous item via popstate; replaceState exits cleanly. + const depth = window._onaraOpenHistoryLength != null + ? history.length - window._onaraOpenHistoryLength + : 0; + if (depth > 0) { + // Suppress popstate re-navigation while we jump out + window._onaraSuppressPopstate = true; + history.go(-depth); + // history.go is async — replace the landing state after it fires + setTimeout(() => { + history.replaceState({}, '', returnUrl); + document.title = returnTitle; + window._onaraSuppressPopstate = false; + }, 50); + } else { + history.replaceState({}, '', returnUrl); + document.title = returnTitle; } } window._onaraReturnUrl = null; window._onaraReturnTitle = null; + window._onaraOpenHistoryLength = null; // Maintain focus on active item so user TAB navigation seamlessly resumes from current progress const activeThumb = document.querySelector('.posts > a.thumb.onara-active'); @@ -1806,6 +1837,21 @@ window.cancelAnimFrame = (function () { const _preloadedMediaUrls = new Set(); + let _prefetchAbortController = null; + let _prefetchDebounceTimer = null; + + const cancelPrefetch = () => { + if (_prefetchDebounceTimer) { + clearTimeout(_prefetchDebounceTimer); + _prefetchDebounceTimer = null; + } + if (_prefetchAbortController) { + try { + _prefetchAbortController.abort(); + } catch (_) {} + _prefetchAbortController = null; + } + }; const preloadMediaFromHtml = (html) => { if (!html) return; @@ -1813,7 +1859,7 @@ window.cancelAnimFrame = (function () { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); - // 1. Preload Image + // 1. Preload Image only (standard image pipeline, non-blocking) const img = doc.querySelector('#f0ck-image, .embed-responsive-image img, .media-object img'); if (img) { const src = img.getAttribute('src'); @@ -1837,20 +1883,9 @@ window.cancelAnimFrame = (function () { } } - // 3. Preload Video / Audio Media (fetch initial bytes into browser cache) - const mediaEl = doc.querySelector('video#my-video, audio#my-video, .media-object video, .media-object audio'); - if (mediaEl) { - const mediaSrc = mediaEl.getAttribute('src'); - if (mediaSrc && !mediaSrc.startsWith('blob:') && !_preloadedMediaUrls.has(mediaSrc)) { - _preloadedMediaUrls.add(mediaSrc); - fetch(mediaSrc, { - headers: { Range: 'bytes=0-2097151' }, // 2MB initial chunk preload - credentials: 'include', - priority: 'low' - }).catch(() => {}); - window.f0ckDebug('[prefetch] Preloaded media buffer:', mediaSrc); - } - } + // Note: Video & Audio binary streams are deliberately NOT fetched via fetch() in JS. + // HTML5 media elements stream natively upon mounting. Fetching multi-megabyte chunks + // in the background exhausts the browser's 6-connection HTTP pool, stalling navigation. if (_preloadedMediaUrls.size > 150) { _preloadedMediaUrls.clear(); @@ -1861,11 +1896,20 @@ window.cancelAnimFrame = (function () { }; const prefetchNextPrevItems = () => { + // Abort prior in-flight prefetch requests + if (_prefetchAbortController) { + try { _prefetchAbortController.abort(); } catch (_) {} + } + _prefetchAbortController = new AbortController(); + const signal = _prefetchAbortController.signal; + + // Prioritize next item (most frequent user journey), then prev const nextBtn = document.getElementById('next'); const prevBtn = document.getElementById('prev'); [nextBtn, prevBtn].forEach(btn => { if (!btn || !btn.href || btn.href.endsWith('#')) return; + if (signal.aborted) return; const pathSegments = new URL(btn.href, window.location.origin).pathname.split('/'); const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); @@ -1907,17 +1951,17 @@ window.cancelAnimFrame = (function () { return; } - fetch(ajaxUrl, { credentials: 'include', priority: 'low' }) + fetch(ajaxUrl, { credentials: 'include', priority: 'low', signal }) .then(r => r.ok ? r.text() : null) .then(rawText => { - if (!rawText) return; + if (!rawText || signal.aborted) return; let html = rawText; try { const data = JSON.parse(rawText); if (data && typeof data.html === 'string') html = data.html; } catch (_) {} - if (html) { + if (html && !signal.aborted) { itemCacheMap.set(ajaxUrl, { html, ts: Date.now() }); if (itemCacheMap.size > ITEM_CACHE_MAX) { itemCacheMap.delete(itemCacheMap.keys().next().value); @@ -1926,10 +1970,26 @@ window.cancelAnimFrame = (function () { preloadMediaFromHtml(html); } }) - .catch(() => {}); + .catch(err => { + if (err.name !== 'AbortError') { + window.f0ckDebug('[prefetch] Prefetch error or cancelled:', err.message); + } + }); }); }; + const schedulePrefetch = (delay = 600) => { + cancelPrefetch(); + _prefetchDebounceTimer = setTimeout(() => { + _prefetchDebounceTimer = null; + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(() => prefetchNextPrevItems(), { timeout: 1500 }); + } else { + prefetchNextPrevItems(); + } + }, delay); + }; + const setupMedia = () => { const elem = document.querySelector("#my-video") || document.querySelector("audio#my-video"); if (elem) { @@ -1948,6 +2008,7 @@ window.cancelAnimFrame = (function () { window._onaraReturnUrl = getOnaraBaseUrl(); window._onaraReturnTitle = window.f0ckDomain || 'f0ck'; } + window._onaraCurrentGridUrl = window._onaraReturnUrl; 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; @@ -1957,6 +2018,8 @@ window.cancelAnimFrame = (function () { window.trackVisit(activeKey); } } + } else { + window._onaraCurrentGridUrl = window.location.pathname + window.location.search; } }; @@ -1964,11 +2027,7 @@ window.cancelAnimFrame = (function () { document.addEventListener('DOMContentLoaded', () => { setupMedia(); initOnaraInitialState(); - if (typeof requestIdleCallback === 'function') { - requestIdleCallback(() => prefetchNextPrevItems()); - } else { - setTimeout(prefetchNextPrevItems, 500); - } + schedulePrefetch(800); }); initOnaraInitialState(); @@ -2820,6 +2879,10 @@ window.cancelAnimFrame = (function () { const loadPageAjax = async (url, replace = true, options = {}) => { if (isNavigating) return; + const fromOnaraModal = document.body.classList.contains('onara-modal-open') || + (document.getElementById('onara-modal') && document.getElementById('onara-modal').style.display !== 'none'); + const shouldReplaceHistory = options.replaceHistory ?? (fromOnaraModal && !options.skipPush); + // Immediately restore scrollability and hide modals if (window.resetGlobalScrollState) window.resetGlobalScrollState(); if (window.hideAllModals) window.hideAllModals(); @@ -2882,7 +2945,7 @@ window.cancelAnimFrame = (function () { const currentScroll = currentContainer ? currentContainer.scrollTop : window.scrollY; // Save scroll position for the current page before we leave it (only for new navigations) - if (!options.skipPush) { + if (!options.skipPush && !shouldReplaceHistory) { const currentState = history.state || {}; history.replaceState({ ...currentState, scroll: currentScroll }, document.title, window.location.href); } @@ -2977,7 +3040,13 @@ window.cancelAnimFrame = (function () { } // Update URL bar (only for explicit clicks, not browser back/forward which already did it) - if (!options.skipPush && !document.body.classList.contains('onara-modal-open')) history.pushState({}, '', url); + if (!options.skipPush && !document.body.classList.contains('onara-modal-open')) { + if (shouldReplaceHistory) { + history.replaceState({}, '', url); + } else { + history.pushState({}, '', url); + } + } // Restore scroll position requestAnimationFrame(() => { @@ -3001,6 +3070,7 @@ window.cancelAnimFrame = (function () { if (window.initSidebarRightToggle) window.initSidebarRightToggle(); if (window.syncNavbarHeight) window.syncNavbarHeight(); window.NotificationSystemInstance?.pollDebounced?.(); + window._onaraCurrentGridUrl = urlObj.pathname + urlObj.search; isNavigating = false; return; // SKIP FETCH } @@ -3185,7 +3255,7 @@ window.cancelAnimFrame = (function () { }); // Sync body classes (CRITICAL for layout triggers) - const persistClasses = ['sidebar-right-hidden', 'bg-active']; + const persistClasses = ['sidebar-right-hidden', 'bg-active', 'sidebar-left-hidden', 'comments-hidden']; const currentlyActive = persistClasses.filter(c => document.body.classList.contains(c)); document.body.className = doc.body.className; @@ -3412,7 +3482,11 @@ window.cancelAnimFrame = (function () { // UPDATE HISTORY BEFORE INITIALIZING SCRIPTS if (!options.skipPush && !document.body.classList.contains('onara-modal-open')) { - history.pushState({}, '', url); + if (shouldReplaceHistory) { + history.replaceState({}, '', url); + } else { + history.pushState({}, '', url); + } } // Sync sidebar and comments-list layout (Legacy View Only) @@ -3551,6 +3625,7 @@ window.cancelAnimFrame = (function () { }, 500); } + window._onaraCurrentGridUrl = urlObj.pathname + urlObj.search; return; } @@ -3618,7 +3693,11 @@ window.cancelAnimFrame = (function () { } } - history.pushState({}, '', pushUrl); + if (shouldReplaceHistory) { + history.replaceState({}, '', pushUrl); + } else { + history.pushState({}, '', pushUrl); + } } // Update pagination (CRITICAL: Do this BEFORE initializing scroll so it captures fresh HTML) @@ -3655,6 +3734,7 @@ window.cancelAnimFrame = (function () { if (window.initSidebarRightToggle) window.initSidebarRightToggle(); // Sync has-notif highlights after grid loads — handles PWA where visibilitychange doesn't fire window.NotificationSystemInstance?.pollDebounced?.(); + window._onaraCurrentGridUrl = urlObj.pathname + urlObj.search; // Instant jump to hash (e.g. #c123) if (hash) { @@ -3781,6 +3861,7 @@ window.cancelAnimFrame = (function () { const loadItemAjax = async (url, inheritContext = true, options = {}) => { if (isNavigating) return; + cancelPrefetch(); // Immediately restore scrollability and hide modals if (window.resetGlobalScrollState) window.resetGlobalScrollState(); @@ -3793,8 +3874,14 @@ window.cancelAnimFrame = (function () { const useOnara = isOnaraActive() && (isGridPage || isModalOpen); if (useOnara && !isModalOpen) { - window._onaraReturnUrl = window.location.href; + if (options.skipPush || isItemPath(window.location.href)) { + window._onaraReturnUrl = getOnaraBaseUrl(url); + } else { + window._onaraReturnUrl = window.location.href; + } window._onaraReturnTitle = document.title; + // Record history depth so we can jump past all stacked item entries on close + window._onaraOpenHistoryLength = history.length; } // ── Scroller-active cleanup (same as loadPageAjax) ─────────────────── @@ -4000,8 +4087,8 @@ window.cancelAnimFrame = (function () { const _itemCacheKey = ajaxUrl; const _cachedItem = !options.skipCache ? itemCacheMap.get(_itemCacheKey) : null; - // Allow slide-out animation to visibly start before content swap - if (options.direction && document.querySelector('.content')) { + // Allow slide-out or fade-out animation to visibly start before content swap + if ((options.direction || options.transition === 'fade-zoom') && document.querySelector('.content')) { await new Promise(r => setTimeout(r, 180)); } @@ -4065,13 +4152,39 @@ window.cancelAnimFrame = (function () { if (_o && _n) _o.replaceWith(_n.cloneNode(true)); }); } else { + const isCommentsHidden = localStorage.getItem('comments_hidden') === 'true'; + if (isCommentsHidden) { + document.body.classList.add('sidebar-left-hidden', 'comments-hidden'); + } else { + document.body.classList.remove('sidebar-left-hidden', 'comments-hidden'); + } _container.insertAdjacentHTML('beforeend', _html); + if (isCommentsHidden) { + const _newLayout = _container.querySelector('.item-layout-container'); + if (_newLayout) _newLayout.classList.add('sidebar-hidden'); + const _newComments = _container.querySelector('#comments-container'); + if (_newComments) { + _newComments.classList.add('faded-out', 'is-hidden'); + _newComments.style.setProperty('display', 'none', 'important'); + } + const _newLeftSb = _container.querySelector('.item-sidebar-left'); + if (_newLeftSb) { + _newLeftSb.classList.add('is-hidden'); + _newLeftSb.style.setProperty('display', 'none', 'important'); + } + } if (options.direction) { const _newEls = _container.querySelectorAll('.content, ._204863, .item_title'); _newEls.forEach(el => { - el.classList.remove('slide-next', 'slide-prev', 'slide-out-left', 'slide-out-right'); + el.classList.remove('slide-next', 'slide-prev', 'slide-out-left', 'slide-out-right', 'fade-out-zoom', 'fade-in-zoom'); el.classList.add(options.direction === 'next' ? 'slide-next' : 'slide-prev'); }); + } else if (options.transition === 'fade-zoom') { + const _newEls = _container.querySelectorAll('.content, ._204863, .item_title'); + _newEls.forEach(el => { + el.classList.remove('slide-next', 'slide-prev', 'slide-out-left', 'slide-out-right', 'fade-out-zoom', 'fade-in-zoom'); + el.classList.add('fade-in-zoom'); + }); } } @@ -4135,11 +4248,7 @@ window.cancelAnimFrame = (function () { }) .catch(() => {}); - if (typeof requestIdleCallback === 'function') { - requestIdleCallback(() => prefetchNextPrevItems()); - } else { - setTimeout(prefetchNextPrevItems, 100); - } + schedulePrefetch(600); return; // Done — served from cache } @@ -4279,13 +4388,39 @@ window.cancelAnimFrame = (function () { } }); } else { + const isCommentsHidden = localStorage.getItem('comments_hidden') === 'true'; + if (isCommentsHidden) { + document.body.classList.add('sidebar-left-hidden', 'comments-hidden'); + } else { + document.body.classList.remove('sidebar-left-hidden', 'comments-hidden'); + } container.insertAdjacentHTML('beforeend', html); + if (isCommentsHidden) { + const newLayout = container.querySelector('.item-layout-container'); + if (newLayout) newLayout.classList.add('sidebar-hidden'); + const newComments = container.querySelector('#comments-container'); + if (newComments) { + newComments.classList.add('faded-out', 'is-hidden'); + newComments.style.setProperty('display', 'none', 'important'); + } + const newLeftSb = container.querySelector('.item-sidebar-left'); + if (newLeftSb) { + newLeftSb.classList.add('is-hidden'); + newLeftSb.style.setProperty('display', 'none', 'important'); + } + } if (options.direction) { const newEls = container.querySelectorAll('.content, ._204863, .item_title'); newEls.forEach(el => { - el.classList.remove('slide-next', 'slide-prev', 'slide-out-left', 'slide-out-right'); + el.classList.remove('slide-next', 'slide-prev', 'slide-out-left', 'slide-out-right', 'fade-out-zoom', 'fade-in-zoom'); el.classList.add(options.direction === 'next' ? 'slide-next' : 'slide-prev'); }); + } else if (options.transition === 'fade-zoom') { + const newEls = container.querySelectorAll('.content, ._204863, .item_title'); + newEls.forEach(el => { + el.classList.remove('slide-next', 'slide-prev', 'slide-out-left', 'slide-out-right', 'fade-out-zoom', 'fade-in-zoom'); + el.classList.add('fade-in-zoom'); + }); } // Note: Prefetching video src immediately after DOM injection has been removed @@ -4365,11 +4500,7 @@ window.cancelAnimFrame = (function () { console.error("updateStrictLinks error:", err); } - if (typeof requestIdleCallback === 'function') { - requestIdleCallback(() => prefetchNextPrevItems()); - } else { - setTimeout(prefetchNextPrevItems, 200); - } + schedulePrefetch(600); // After comments have rendered (f0ck:contentLoaded above triggers synchronous render), // constrain the comments-list height via direct measurement (flex chain unreliable in AJAX). @@ -4496,25 +4627,14 @@ window.cancelAnimFrame = (function () { 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; - } - } - } + const outEls = document.querySelectorAll('.content, ._204863, .item_title'); + outEls.forEach(el => { + el.classList.remove('slide-out-left', 'slide-out-right', 'slide-next', 'slide-prev', 'fade-out-zoom', 'fade-in-zoom'); + el.classList.add('fade-out-zoom'); + }); - stopMedia(); // Abort media BEFORE fetching random item + // Pause media without stripping src so it doesn't flash black before fetch returns + document.querySelectorAll('video, audio').forEach(m => { try { m.pause(); } catch (e) {} }); // Fade out background canvas for smooth transition const canvas = document.getElementById('bg'); @@ -4595,14 +4715,79 @@ window.cancelAnimFrame = (function () { .then(data => { if (data.success && data.items && (data.items.slug || data.items.id)) { const targetKey = data.items.slug || data.items.id; - // Navigate in the same context (user hall, favs, tag, etc.) + const targetId = data.items.id; + + // Navigate immediately — don't wait for grid sync if (wUserHall && wUserHallOwner) { - loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true); + loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true, { transition: 'fade-zoom' }); } else if (wFavsUser) { - // Preserve /user/:name/favs/:id context so next/prev arrows stay within favs - loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true); + loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true, { transition: 'fade-zoom' }); } else { - loadItemAjax(`/${targetKey}`, true); + loadItemAjax(`/${targetKey}`, true, { transition: 'fade-zoom' }); + } + + // Background grid sync for Onara — fire and forget, user never waits for this + if (isOnaraActive() && targetId) { + const gridThumbs = Array.from(document.querySelectorAll('.posts > a:not(.notif-item)')); + const alreadyInGrid = gridThumbs.some(a => { + const k = a.pathname.replace(/\/$/, '').split('/').pop(); + return k == targetId || (data.items.slug && k === data.items.slug); + }); + console.log('[random-gridsync] isOnaraActive:', true, 'alreadyInGrid:', alreadyInGrid, 'targetId:', targetId, '_onaraCurrentGridUrl:', window._onaraCurrentGridUrl); + + if (!alreadyInGrid) { + const pageParams = new URLSearchParams(params); + pageParams.set('id', targetId); + console.log('[random-gridsync] fetching item-page:', `/api/v2/item-page?${pageParams}`); + fetch(`/api/v2/item-page?${pageParams}`) + .then(r => r.json()) + .then(pd => { + console.log('[random-gridsync] item-page response:', pd); + if (!pd.success || !pd.page) return; + const targetPage = pd.page; + const postsEl = document.querySelector('.posts[data-current-page]'); + const currentPage = postsEl ? parseInt(postsEl.dataset.currentPage, 10) : 1; + console.log('[random-gridsync] targetPage:', targetPage, 'currentPage:', currentPage, '_onaraCurrentGridUrl:', window._onaraCurrentGridUrl); + if (targetPage === currentPage) return; + + let gridBase; + if (window._onaraCurrentGridUrl) { + // Strip trailing slash — root '/' becomes '' so '/p/N' builds correctly + gridBase = window._onaraCurrentGridUrl.replace(/\/p\/\d+/, '').replace(/\/$/, ''); + } else { + gridBase = window.location.pathname + .replace(/\/[a-zA-Z0-9_-]{11}$/, '') + .replace(/\/\d+$/, '') + .replace(/\/p\/\d+/, '') + .replace(/\/$/, ''); + } + const pageUrl = targetPage === 1 ? (gridBase || '/') : `${gridBase}/p/${targetPage}`; + console.log('[random-gridsync] fetching grid page:', pageUrl); + + fetch(pageUrl) + .then(r => r.text()) + .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); }); + } + } else { + console.log('[random-gridsync] skipped: isOnaraActive=', isOnaraActive(), 'targetId=', targetId); } } else { window.location.href = link.href; @@ -4697,20 +4882,15 @@ window.cancelAnimFrame = (function () { } const parts = pathname.split('/').filter(Boolean); - const isItemLink = !pathname.match(/\/p\//) && ( - pathname.match(/^\/\d+/) || - pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || - (parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || - (parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || - (parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) - ); + const isItemLink = isItemPath(pathname); if (isItemLink) { // Links inside comment bodies or MOTD should not inherit tag/hall/user context const _inComment = anyLink.closest(".comment, .comment-content, .comment-body, .motd-content"); loadItemAjax(targetUrl, !_inComment); } else { const isBrand = anyLink.classList.contains('navbar-brand'); - loadPageAjax(targetUrl, true, { bypassCache: isBrand }); + const fromModal = document.body.classList.contains('onara-modal-open') || !!anyLink.closest('#onara-modal'); + loadPageAjax(targetUrl, true, { bypassCache: isBrand, replaceHistory: fromModal }); } return false; } @@ -5134,6 +5314,9 @@ window.cancelAnimFrame = (function () { }); window.addEventListener('popstate', (e) => { + // Swallow popstate events fired during Onara close (history.go(-N) unwinding) + if (window._onaraSuppressPopstate) return; + const url = window.location.href; const p = window.location.pathname; @@ -5158,30 +5341,51 @@ window.cancelAnimFrame = (function () { // Item detection logic MUST match loadPageAjax/loadItemAjax analysis // Priorities: Item first, then Special/Grid - const parts = p.split('/').filter(Boolean); - const isItem = !p.match(/\/p\//) && ( - p.match(/^\/\d+/) || - p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || - (parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || - (parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || - (parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) - ); + const isItem = isItemPath(p); const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking'); const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/'; window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike }); - if (document.body.classList.contains('onara-modal-open') && !isItem) { + const isOnaraModalCurrentlyOpen = document.body.classList.contains('onara-modal-open') || + (document.getElementById('onara-modal') && document.getElementById('onara-modal').style.display !== 'none'); + + if (isOnaraModalCurrentlyOpen && !isItem) { closeOnaraModal({ skipHistory: true }); + if (isOnaraActive()) { + const currentGrid = window._onaraCurrentGridUrl || (document.querySelector('.posts') ? (document.querySelector('.posts').dataset.currentUrl || '/') : '/'); + const targetPath = new URL(url, window.location.origin).pathname; + const currentPath = new URL(currentGrid, window.location.origin).pathname; + if (currentPath !== targetPath) { + window._onaraCurrentGridUrl = url; + loadPageAjax(url, true, { skipPush: true }); + } + } return; } if (isItem) { + if (isOnaraActive()) { + const expectedBaseUrl = getOnaraBaseUrl(url); + const currentGrid = window._onaraCurrentGridUrl || (document.querySelector('.posts') ? (document.querySelector('.posts').dataset.currentUrl || '/') : '/'); + const expectedPath = new URL(expectedBaseUrl, window.location.origin).pathname; + const currentPath = new URL(currentGrid, window.location.origin).pathname; + if (currentPath !== expectedPath) { + window._onaraCurrentGridUrl = expectedBaseUrl; + loadPageAjax(expectedBaseUrl, true, { skipPush: true }); + } + } loadItemAjax(url, true, { skipPush: true }); } else if (isSpecial || isGridLike) { + if (isOnaraActive()) { + window._onaraCurrentGridUrl = url; + } loadPageAjax(url, true, { skipPush: true }); } else { // Fallback + if (isOnaraActive()) { + window._onaraCurrentGridUrl = url; + } loadPageAjax(url, true, { skipPush: true }); } }); @@ -5358,17 +5562,32 @@ window.cancelAnimFrame = (function () { const imageModal = document.getElementById('image-modal'); if (imageModal && imageModal.classList.contains('visible')) return; - const navBtn = event.target.closest('.nav-next, .nav-prev'); - const isNavHover = !!navBtn; - const isMediaHover = !isNavHover && !!event.target.closest('.media-object') && !event.target.closest('#favs'); + // Yield if scroller/abyss page is active + if (document.body && document.body.classList.contains('scroller-active')) return; - // For media-object, user preference 'wheelNavEnabled' must be enabled. - // For .nav-next / .nav-prev, scrolling always triggers next/prev navigation directly. - if (!isNavHover && (!isMediaHover || localStorage.getItem('wheelNavEnabled') !== 'true')) { + // Don't hijack if user is inside an input, textarea, or contenteditable element + if (event.target && (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.isContentEditable)) { return; } - // Always prevent default page scroll when scrolling over nav-next / nav-prev or media + const isShiftWheel = event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey; + const navBtn = event.target.closest('.nav-next, .nav-prev'); + const isNavHover = !!navBtn; + const isMediaHover = !isNavHover && !isShiftWheel && !!event.target.closest('.media-object') && !event.target.closest('#favs'); + + // Trigger conditions: + // 1. Shift + scrollwheel shortcut anywhere on post view + // 2. Hovering over .nav-next / .nav-prev + // 3. Hovering over media-object when 'wheelNavEnabled' preference is true + if (!isShiftWheel && !isNavHover && (!isMediaHover || localStorage.getItem('wheelNavEnabled') !== 'true')) { + return; + } + + // Only intercept if item navigation buttons exist on the page + const hasNav = !!document.querySelector("#next:not([href='#']), .nav-next:not([href='#']), #prev:not([href='#']), .nav-prev:not([href='#'])"); + if (!hasNav) return; + + // Always prevent default page scroll when shortcut or target is matched event.preventDefault(); // Prevent rapid multi-firing while navigating or during throttle cooldown @@ -7519,11 +7738,20 @@ window.cancelAnimFrame = (function () { sb.style.setProperty('transition', 'none', 'important'); if (isHidden) { layout.classList.remove('sidebar-hidden'); - document.body.classList.remove('sidebar-left-hidden'); + document.body.classList.remove('sidebar-left-hidden', 'comments-hidden'); + sb.style.removeProperty('display'); sb.style.display = ''; - sb.querySelectorAll('.faded-out').forEach(el => el.classList.remove('faded-out')); + sb.classList.remove('is-hidden'); + sb.querySelectorAll('.faded-out, .is-hidden').forEach(el => { + el.classList.remove('faded-out', 'is-hidden'); + el.style.removeProperty('display'); + }); const c = sb.querySelector('#comments-container'); - if (c) { c.classList.remove('faded-out'); c.style.display = ''; } + if (c) { + c.classList.remove('faded-out', 'is-hidden'); + c.style.removeProperty('display'); + c.style.display = ''; + } void sb.offsetWidth; // commit display + class change before setting transform sb.style.transform = `translateX(${-sw}px)`; } else { @@ -7544,14 +7772,15 @@ window.cancelAnimFrame = (function () { sb.style.removeProperty('transition'); if (shouldOpen) { sb.style.removeProperty('transform'); + sb.style.removeProperty('display'); localStorage.setItem('comments_hidden', 'false'); - document.body.classList.remove('sidebar-left-hidden'); + document.body.classList.remove('sidebar-left-hidden', 'comments-hidden'); } else { // Hide BEFORE removing transform to prevent snap-back flash - sb.style.display = 'none'; + sb.style.setProperty('display', 'none', 'important'); layout.classList.add('sidebar-hidden'); localStorage.setItem('comments_hidden', 'true'); - document.body.classList.add('sidebar-left-hidden'); + document.body.classList.add('sidebar-left-hidden', 'comments-hidden'); sb.style.removeProperty('transform'); } requestAnimationFrame(() => syncLeftEdgeZone()); @@ -7772,7 +8001,11 @@ window.cancelAnimFrame = (function () { if (window.innerWidth < 1000) return; const { sb, layout } = getLS(); if (!sb || !layout) return; - const isHidden = layout.classList.contains('sidebar-hidden'); + const isHidden = layout.classList.contains('sidebar-hidden') || + document.body.classList.contains('sidebar-left-hidden') || + document.body.classList.contains('comments-hidden') || + localStorage.getItem('comments_hidden') === 'true'; + localStorage.setItem('comments_hidden', isHidden ? 'false' : 'true'); const sw = isHidden ? Math.round(Math.min(400, window.innerWidth * 0.30)) : sb.offsetWidth; prepareDrag(sb, layout, isHidden, sw); // rAF ensures the initial transform state is painted before the transition starts @@ -9569,9 +9802,8 @@ class NotificationSystem { btn.style.visibility = 'visible'; }); - if (typeof prefetchNextPrevItems === 'function') { - if (typeof requestIdleCallback === 'function') requestIdleCallback(() => prefetchNextPrevItems()); - else setTimeout(prefetchNextPrevItems, 200); + if (typeof schedulePrefetch === 'function') { + schedulePrefetch(800); } } } diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 3daaa3f..3f4f22f 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -570,6 +570,43 @@ export default router => { res.end("api lol"); }); + // Lightweight page-lookup for Onara grid sync. + // Called async after random navigation — user never waits for this. + group.get(/\/item-page$/, async (req, res) => { + const id = parseInt(req.url.qs.id, 10) || 0; + if (!id) return res.json({ success: false, page: 1 }); + + const tag = req.url.qs.tag || null; + const hall = req.url.qs.hall || null; + const userHall = req.url.qs.userHall || null; + const userHallOwner= req.url.qs.userHallOwner|| null; + const user = req.url.qs.user || null; + const mime = req.url.qs.mime || null; + const isFav = req.url.qs.fav === 'true'; + const isStrict = req.url.qs.strict === '1'; + const mode = req.mode ?? 0; + const ratingsRaw = req.cookies.ratings; + const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; + + try { + const page = await f0cklib.getItemPage({ + targetItemId: id, + 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 + }); + return res.json({ success: true, page }); + } catch (e) { + return res.json({ success: false, page: 1 }); + } + }); + group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => { const pathParts = req.url.pathname.split('/'); // /api/v2/random/user/name -> segments are ["", "api", "v2", "random", "user", "name"] @@ -629,7 +666,7 @@ export default router => { } const isYouTube = item.mime === 'video/youtube'; - const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i; + const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i; let ytDest = item.dest; if (isYouTube && (!ytDest || !ytDest.startsWith('yt:'))) { const m = item.src && item.src.match(ytSrcRegex); diff --git a/views/snippets/header.html b/views/snippets/header.html index 37c61b1..f8d878e 100644 --- a/views/snippets/header.html +++ b/views/snippets/header.html @@ -98,7 +98,7 @@ - + @if(!private_society || session) @endif diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html index 670c97f..b45a41a 100644 --- a/views/snippets/navbar.html +++ b/views/snippets/navbar.html @@ -319,6 +319,7 @@
{{ t('shortcuts.next_prev') }}
{{ t('shortcuts.volume') }}
Scroll{{ t('shortcuts.scroll_nav') }}
+
Shift + Scroll{{ t('shortcuts.next_prev') }}
, .{{ t('shortcuts.frame_skip') }}
?{{ t('shortcuts.show_help') }}