gfds
This commit is contained in:
+588
-86
@@ -6810,6 +6810,9 @@ window.cancelAnimFrame = (function () {
|
||||
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="background" title="Onara & Video Background Settings">
|
||||
<i class="fa-solid fa-film"></i> Background
|
||||
</button>
|
||||
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="flashyank" title="Flash Yank: retro low-res / low-fps / few-colour video filter">
|
||||
<i class="fa-solid fa-bolt-lightning"></i> Flash Yank
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="f0ck-tuner-close" id="f0ck-tuner-close" title="Close">✕</button>
|
||||
</div>
|
||||
@@ -6934,6 +6937,36 @@ window.cancelAnimFrame = (function () {
|
||||
<div id="f0ck-bg-advanced-rows" style="display:none;">${bgRowsAdvancedHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pane 4: Flash Yank (video filter; replaces the old "SWF" button in the player menu) -->
|
||||
<div id="f0ck-tuner-pane-flashyank" class="f0ck-tuner-subtab-pane" style="display: none;">
|
||||
<div class="f0ck-tuner-body">
|
||||
<div class="f0ck-tuner-presets-bar">
|
||||
<div style="font-size: 11px; opacity: 0.85; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
<i class="fa-solid fa-bolt-lightning"></i> Flash Yank
|
||||
</div>
|
||||
<div class="f0ck-tuner-presets-actions">
|
||||
<button type="button" id="fy-tuner-reset" class="f0ck-tuner-btn-factory-reset" title="Reset the yank strength to its default"><i class="fa-solid fa-rotate-left"></i> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f0ck-tuner-mode-bar" style="display: flex; gap: 6px; padding: 6px 12px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<button type="button" id="fy-btn-off" class="f0ck-tuner-mode-btn" style="flex: 1; padding: 6px 10px; font-size: 11px; text-align: center;">Off</button>
|
||||
<button type="button" id="fy-btn-on" class="f0ck-tuner-mode-btn" style="flex: 1; padding: 6px 10px; font-size: 11px; text-align: center;"><i class="fa-solid fa-bolt-lightning"></i> On</button>
|
||||
</div>
|
||||
<div class="f0ck-tuner-row" data-key="flashyank-yank">
|
||||
<div class="f0ck-tuner-row-label">
|
||||
<span>Yank</span>
|
||||
<span id="fy-yank-value">0%</span>
|
||||
</div>
|
||||
<input type="range" id="fy-yank-slider" min="0" max="100" step="1" value="0" />
|
||||
</div>
|
||||
<div id="fy-info" style="padding: 4px 12px 8px; font-size: 11px; opacity: 0.7;"></div>
|
||||
<div style="padding: 0 12px 12px; font-size: 11px; opacity: 0.55; line-height: 1.4;">
|
||||
Degrades the video to a retro look: lower resolution, fewer frames and fewer colours the higher the yank.
|
||||
Toggle it on item pages with the <strong>S</strong> key.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── Sub-Tab Switching Logic ─────────────────────────────────────────────
|
||||
@@ -6960,10 +6993,50 @@ window.cancelAnimFrame = (function () {
|
||||
});
|
||||
|
||||
const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab');
|
||||
if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background')) {
|
||||
if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank')) {
|
||||
switchSubtab(savedSubtab);
|
||||
}
|
||||
|
||||
// ── Flash Yank pane (window.f0ckFlashYank from flash_yank.js) ──────────
|
||||
(() => {
|
||||
const fyOn = panel.querySelector('#fy-btn-on');
|
||||
const fyOff = panel.querySelector('#fy-btn-off');
|
||||
const fySlider = panel.querySelector('#fy-yank-slider');
|
||||
const fyValue = panel.querySelector('#fy-yank-value');
|
||||
const fyInfo = panel.querySelector('#fy-info');
|
||||
const fyReset = panel.querySelector('#fy-tuner-reset');
|
||||
if (!fyOn || !fySlider) return;
|
||||
const api = () => window.f0ckFlashYank || null;
|
||||
const render = (st) => {
|
||||
st = st || (api() && api().getState());
|
||||
const ready = !!st;
|
||||
[fyOn, fyOff, fySlider, fyReset].forEach(el => { el.disabled = !ready; });
|
||||
if (!ready) { fyInfo.textContent = 'Flash Yank is not available in this browser.'; return; }
|
||||
fyOn.classList.toggle('active', st.enabled);
|
||||
fyOff.classList.toggle('active', !st.enabled);
|
||||
fySlider.value = st.yank;
|
||||
fyValue.textContent = st.yank + '%';
|
||||
fyInfo.textContent = st.width + 'px width, ' + st.fps + ' fps, ~' + st.colors + ' colours'
|
||||
+ (st.available ? '' : ' (applies to the video on item pages)');
|
||||
};
|
||||
fyOn.addEventListener('click', () => { if (api()) api().setEnabled(true); });
|
||||
fyOff.addEventListener('click', () => { if (api()) api().setEnabled(false); });
|
||||
fySlider.addEventListener('input', () => {
|
||||
fyValue.textContent = fySlider.value + '%';
|
||||
if (api()) api().setYank(fySlider.value);
|
||||
});
|
||||
fyReset.addEventListener('click', () => { if (api()) api().setYank(api().defaults.yank); });
|
||||
const onChange = (e) => {
|
||||
if (!panel.isConnected) { window.removeEventListener('f0ck:flashyank_changed', onChange); return; }
|
||||
render(e.detail);
|
||||
};
|
||||
window.addEventListener('f0ck:flashyank_changed', onChange);
|
||||
// flash_yank.js may load after the tuner is built: re-read whenever the tab is opened
|
||||
const fyTabBtn = panel.querySelector('.f0ck-tuner-subtab-btn[data-subtab="flashyank"]');
|
||||
if (fyTabBtn) fyTabBtn.addEventListener('click', () => render());
|
||||
render();
|
||||
})();
|
||||
|
||||
// ── Einfach / Erweitert Toggle (Background Tab) ────────────────────────
|
||||
const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows');
|
||||
const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows');
|
||||
@@ -7260,6 +7333,155 @@ window.cancelAnimFrame = (function () {
|
||||
barOpacity: 0.85,
|
||||
barGlow: 10,
|
||||
smoothing: 0.80
|
||||
},
|
||||
// Just the cover: no bars, rings, tunnels or glow; cover also on the blurred background
|
||||
'Cover Only': {
|
||||
useCustomColor: 0,
|
||||
visualizerColor: "#241f31",
|
||||
enableBeatHue: 0,
|
||||
beatHueThreshold: 0.53,
|
||||
beatHueStep: 45,
|
||||
beatHueSmooth: 0.65,
|
||||
beatHueIdleDrift: 0.2,
|
||||
beatHueCooldown: 150,
|
||||
coverSize: 145,
|
||||
glowIntensity: 0,
|
||||
coverGlowBase: 0,
|
||||
glowBrightness: 0,
|
||||
glowSensitivity: 1,
|
||||
glowDynamism: 1.5,
|
||||
glowSmoothness: 0.95,
|
||||
showCoverInEye: 1,
|
||||
showCoverInBg: 1,
|
||||
showCoverArt: 1,
|
||||
solidCover: 0,
|
||||
coverPosX: 0,
|
||||
coverPosY: 0,
|
||||
coverImageScale: 80,
|
||||
coverImagePosX: 0,
|
||||
coverImagePosY: 0,
|
||||
bgCoverOpacity: 0.48,
|
||||
bgCoverBlur: 1,
|
||||
bgCoverScale: 55,
|
||||
bgCoverPosX: 50,
|
||||
bgCoverPosY: 48,
|
||||
coverColor: "#000000",
|
||||
coverOpacity: 1,
|
||||
bassGain: 0.6,
|
||||
bassPower: 1.29,
|
||||
scaleBounce: 0.05,
|
||||
bounceBoost: 11.5,
|
||||
attackSpeed: 0.16,
|
||||
releaseSpeed: 0.47,
|
||||
enableBars: 0,
|
||||
enableInnerBars: 0,
|
||||
useCustomInnerColor: 0,
|
||||
innerBarColor: "#ff0000",
|
||||
innerBarsMode: 1,
|
||||
innerRadius: 30,
|
||||
innerPupilRadius: 0,
|
||||
innerPupilRingOpacity: 1,
|
||||
innerPupilColor: "#000000",
|
||||
enableVoidTunnel: 0,
|
||||
voidTunnelRings: 10,
|
||||
voidTunnelSpeed: 0.6,
|
||||
voidTunnelReactivity: 3,
|
||||
voidTunnelFadeWidth: 0.35,
|
||||
voidTunnelUseAccent: 1,
|
||||
voidTunnelColor: "#ffffff",
|
||||
voidTunnelZone: 0,
|
||||
voidTunnelRingWidth: 2.5,
|
||||
voidTunnelEdgeFade: 0.35,
|
||||
voidTunnelOpacity: 1,
|
||||
voidTunnelDepth: 0.65,
|
||||
voidTunnelZoomSpeed: 0.5,
|
||||
enableSquareTunnel: 0,
|
||||
squareTunnelSpeed: 0.4,
|
||||
squareTunnelReactivity: 1.5,
|
||||
squareTunnelLevels: 6,
|
||||
squareTunnelOpacity: 1,
|
||||
squareTunnelGridWidth: 1.5,
|
||||
squareTunnelWallFillAlpha: 0,
|
||||
squareTunnelFog: 0.75,
|
||||
squareTunnelGlow: 12,
|
||||
squareTunnelWarp: 2,
|
||||
squareTunnelUseAccent: 1,
|
||||
squareTunnelGridColor: "#ffffff",
|
||||
squareTunnelRoomScale: 1.5,
|
||||
squareTunnelFOV: 0.55,
|
||||
squareTunnelHideHUD: 1,
|
||||
squareTunnelWallMode: 0,
|
||||
squareTunnelWallColor: "#1a1a2e",
|
||||
squareTunnelWallColor2: "#000000",
|
||||
squareTunnelBackdropColor: "#000000",
|
||||
squareTunnelBackdropBrightness: 0.55,
|
||||
squareTunnelPatternScale: 40,
|
||||
squareTunnelPatTileAlpha: 1,
|
||||
squareTunnelMaze: 0,
|
||||
squareTunnelTurnStrength: 0.6,
|
||||
squareTunnelTurnSpeed: 0.04,
|
||||
squareTunnelStraightBias: 0.4,
|
||||
squareTunnelDriftFreq: 1.1,
|
||||
squareTunnelReactDrift: 0,
|
||||
squareTunnelReactScale: 0.18,
|
||||
squareTunnelReactBright: 0.4,
|
||||
innerBarCount: 2,
|
||||
innerBarWidth: 1,
|
||||
innerBarHeight: 0.85,
|
||||
innerBarOpacity: 0,
|
||||
outerRingOpacity: 0,
|
||||
eyeDisableGlow: 0,
|
||||
eyeDisableShadow: 0,
|
||||
eyeFOV: 650,
|
||||
eyeZPos: 0,
|
||||
innerHighBoost: 0.34,
|
||||
innerRadialRotation: 105,
|
||||
innerBarGlow: 0,
|
||||
innerHideNote: 0,
|
||||
barHeight: 0.2,
|
||||
barWidth: 17,
|
||||
barGap: 9,
|
||||
barRadius: 0,
|
||||
barOpacity: 0.42,
|
||||
barGlow: 0,
|
||||
smoothing: 0.73,
|
||||
followMouse: 1,
|
||||
followSpeed: 0.02,
|
||||
followRadius: 10,
|
||||
tiltEffect: 1,
|
||||
glowAttack: 0.47,
|
||||
glowDecay: 0.5,
|
||||
enableBgGradient: 0,
|
||||
bgGradientStyle: 1,
|
||||
bgGradientOpacity: 0,
|
||||
bgGradientReactivity: 0,
|
||||
bgGradientSpread: 10,
|
||||
useCustomBgColor: 0,
|
||||
bgGradientColor: "#3a1c71",
|
||||
enableBlink: 0,
|
||||
blinkInterval: 12,
|
||||
beatHueSpeed: 0.1,
|
||||
onaraBgOpacity: 0.4,
|
||||
onaraBackdropBlur: 5,
|
||||
bgCanvasColor: "#000000",
|
||||
bgCanvasColorOpacity: 0.53,
|
||||
bgCanvasOpacity: 0.43,
|
||||
bgBlurMethod: 1,
|
||||
bgCanvasBlur: 60,
|
||||
bgCanvasBrightness: 0.6,
|
||||
bgCanvasSaturate: 1.9,
|
||||
bgCanvasContrast: 1.15,
|
||||
bgCanvasZoom: 1,
|
||||
bgCanvasScale: 2.15,
|
||||
onaraGridDim: 0.38,
|
||||
feedAudioToBg: 0,
|
||||
bgAmbientLighting: 0,
|
||||
bgAmbientIntensity: 1.9,
|
||||
bgAmbientSpread: 0.85,
|
||||
bgAmbientSaturation: 2.1,
|
||||
bgAmbientSmoothness: 0.11,
|
||||
bgCanvasFps: 60,
|
||||
bgAudioReactivity: 1.5
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15676,11 +15898,7 @@ window.cancelAnimFrame = (function () {
|
||||
e.preventDefault();
|
||||
toggleSearch(true);
|
||||
}
|
||||
// Shift + S to toggle strict mode globally
|
||||
if (e.shiftKey && (e.key === 'S' || e.key === 's') && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
||||
e.preventDefault();
|
||||
if (strict) strict.click();
|
||||
}
|
||||
// (Shift + S used to toggle strict mode here; it opens the settings now, see page-modal.js)
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
@@ -17205,29 +17423,48 @@ window.cancelAnimFrame = (function () {
|
||||
const sidebarWidth = sidebar.offsetWidth || 300;
|
||||
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
|
||||
|
||||
// At ≤599px the sidebar is a pure overlay — never animate wrapper padding
|
||||
// At ≤599px the sidebar is a pure overlay — never touch wrapper padding
|
||||
const animateWrappers = window.innerWidth > 599;
|
||||
|
||||
// All wrappers animate in sync with the sidebar so content and sidebar
|
||||
// expand/collapse together rather than staggering.
|
||||
// Smoothness: the sidebar and #bg canvas animate via CSS transitions (compositor, minimal layout).
|
||||
// The content wrappers and Onara modal change width ONCE instead of every frame: before the slide
|
||||
// when opening, after it when closing. The strip the sidebar travels through is therefore always
|
||||
// empty background, so no thumbnails reflow behind it; the single reflow is masked with an opacity fade.
|
||||
const DURATION = 320;
|
||||
const EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
const wrappers = getSidebarWrappers();
|
||||
if (animateWrappers) {
|
||||
const onaraModal = document.getElementById('onara-modal');
|
||||
const bgCanvas = document.getElementById('bg');
|
||||
|
||||
const applyLayout = (open) => {
|
||||
// Let overlays that mirror the content area (page modal) resize at exactly the same moment
|
||||
window.dispatchEvent(new CustomEvent('f0ck:sidebar-right-layout', { detail: { open, width: sidebarWidth, overlay: !animateWrappers } }));
|
||||
if (!animateWrappers) return;
|
||||
wrappers.forEach(w => {
|
||||
const isRightEdge = w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar');
|
||||
const isMessages = w.classList.contains('messages-convo-page');
|
||||
|
||||
const prop = (isRightEdge || isMessages) ? 'right' : 'padding-right';
|
||||
const targetVal = isHidden ? sidebarWidth + 'px' : '0px';
|
||||
|
||||
w.style.setProperty('transition', `${prop} 0.3s ease-in-out`, 'important');
|
||||
w.style.setProperty(prop, targetVal, 'important');
|
||||
w.style.setProperty('transition', 'none', 'important');
|
||||
w.style.setProperty(prop, open ? sidebarWidth + 'px' : '0px', 'important');
|
||||
});
|
||||
void sidebar.offsetWidth; // settle layout before sidebar transform begins
|
||||
}
|
||||
if (onaraModal) {
|
||||
onaraModal.style.setProperty('transition', 'none', 'important');
|
||||
onaraModal.style.setProperty('right', open ? sidebarWidth + 'px' : '0px', 'important');
|
||||
}
|
||||
// #bg canvas is handled separately below — it transitions smoothly alongside the sidebar
|
||||
// Soften the one-time reflow
|
||||
wrappers.forEach(w => {
|
||||
if (w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar')) return;
|
||||
if (typeof w.animate === 'function') w.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' });
|
||||
});
|
||||
if (typeof window.redrawBgCanvas === 'function') requestAnimationFrame(() => window.redrawBgCanvas());
|
||||
};
|
||||
|
||||
// Animate the drag-zone pill in sync with the sidebar so it stays
|
||||
// glued to the sidebar's left edge throughout the transition.
|
||||
edgeZone.style.setProperty('transition', 'right 0.3s ease-in-out', 'important');
|
||||
// Opening: make room first, then slide in over empty space
|
||||
if (isHidden) applyLayout(true);
|
||||
|
||||
// The drag-zone pill follows the sidebar edge (small fixed element: cheap to animate)
|
||||
edgeZone.style.setProperty('transition', `right ${DURATION}ms ${EASING}`, 'important');
|
||||
if (isHidden) {
|
||||
// Opening: pill moves from right-edge → left of sidebar
|
||||
edgeZone.style.right = sidebarWidth + 'px';
|
||||
@@ -17240,28 +17477,24 @@ window.cancelAnimFrame = (function () {
|
||||
edgeZone.style.pointerEvents = 'all';
|
||||
}
|
||||
|
||||
// Animate the Onara modal wrapper's right edge in sync with the sidebar
|
||||
// so the modal resizes at the same time instead of waiting for settle.
|
||||
const onaraModal = document.getElementById('onara-modal');
|
||||
if (onaraModal && animateWrappers) {
|
||||
const targetModalRight = isHidden ? sidebarWidth + 'px' : '0px';
|
||||
onaraModal.style.setProperty('transition',
|
||||
'opacity 0.28s cubic-bezier(0.16,1,0.3,1), transform 0.28s cubic-bezier(0.16,1,0.3,1), right 0.3s ease-in-out',
|
||||
'important');
|
||||
onaraModal.style.setProperty('right', targetModalRight, 'important');
|
||||
}
|
||||
|
||||
// Animate #bg canvas width in sync with sidebar so background stays centered
|
||||
const bgCanvas = document.getElementById('bg');
|
||||
// Commit the starting width for #bg so the transition has a defined origin
|
||||
if (bgCanvas && animateWrappers) {
|
||||
const targetBgWidth = isHidden ? `calc(100vw - ${sidebarWidth}px)` : '100vw';
|
||||
bgCanvas.style.setProperty('transition', 'width 0.3s ease-in-out', 'important');
|
||||
bgCanvas.style.setProperty('width', targetBgWidth, 'important');
|
||||
const bgStartWidth = isHidden ? '100vw' : `calc(100vw - ${sidebarWidth}px)`;
|
||||
bgCanvas.style.setProperty('width', bgStartWidth, 'important');
|
||||
}
|
||||
|
||||
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
|
||||
sidebar.style.willChange = 'transform';
|
||||
void sidebar.offsetWidth; // commit the start state before transitions begin
|
||||
sidebar.style.setProperty('transition', `transform ${DURATION}ms ${EASING}`, 'important');
|
||||
sidebar.style.transform = `translateX(${targetTranslate}px)`;
|
||||
|
||||
// #bg canvas width transitions in sync with the sidebar slide (position:fixed, no layout cost)
|
||||
if (bgCanvas && animateWrappers) {
|
||||
const bgTargetWidth = isHidden ? `calc(100vw - ${sidebarWidth}px)` : '100vw';
|
||||
bgCanvas.style.setProperty('transition', `width ${DURATION}ms ${EASING}`, 'important');
|
||||
bgCanvas.style.setProperty('width', bgTargetWidth, 'important');
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
@@ -17273,12 +17506,15 @@ window.cancelAnimFrame = (function () {
|
||||
if (onaraModal) onaraModal.style.setProperty('transition', 'none', 'important');
|
||||
if (bgCanvas) bgCanvas.style.setProperty('transition', 'none', 'important');
|
||||
const toHidden = !isHidden;
|
||||
// Closing: the sidebar is off screen now, give the content its space back (single reflow)
|
||||
if (toHidden) applyLayout(false);
|
||||
document.body.classList.toggle('sidebar-right-hidden', toHidden);
|
||||
localStorage.setItem('sidebarRightHidden', toHidden);
|
||||
void sidebar.offsetWidth;
|
||||
sidebar.style.transform = '';
|
||||
requestAnimationFrame(() => {
|
||||
sidebar.style.removeProperty('transition');
|
||||
sidebar.style.removeProperty('will-change');
|
||||
edgeZone.style.removeProperty('transition');
|
||||
if (onaraModal) {
|
||||
onaraModal.style.removeProperty('transition');
|
||||
@@ -17305,7 +17541,7 @@ window.cancelAnimFrame = (function () {
|
||||
settle();
|
||||
};
|
||||
sidebar.addEventListener('transitionend', onEnd);
|
||||
setTimeout(settle, 350);
|
||||
setTimeout(settle, DURATION + 50);
|
||||
};
|
||||
|
||||
// Click on edgeZone: only toggle when the click lands on the pill
|
||||
@@ -17685,7 +17921,8 @@ if (sbtForm) {
|
||||
class NotificationSystem {
|
||||
// Notification type categorization
|
||||
static USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||
static SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
|
||||
static SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning', 'invite_approved', 'invite_denied'];
|
||||
static ADMIN_TYPES = ['invite_request'];
|
||||
|
||||
constructor() {
|
||||
this.bell = document.getElementById('nav-notif-btn');
|
||||
@@ -17701,6 +17938,7 @@ class NotificationSystem {
|
||||
this.activeTab = activeTabEl ? activeTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user');
|
||||
this._cachedUser = [];
|
||||
this._cachedSystem = [];
|
||||
this._cachedAdmin = [];
|
||||
|
||||
// Generate/retrieve unique tab ID
|
||||
this.tabId = sessionStorage.getItem('f0ck_tab_id');
|
||||
@@ -18889,10 +19127,12 @@ class NotificationSystem {
|
||||
// Split into user and system categories
|
||||
this._cachedUser = notifications.filter(n => NotificationSystem.USER_TYPES.includes(n.type));
|
||||
this._cachedSystem = notifications.filter(n => NotificationSystem.SYSTEM_TYPES.includes(n.type));
|
||||
this._cachedAdmin = notifications.filter(n => NotificationSystem.ADMIN_TYPES.includes(n.type));
|
||||
|
||||
const userUnread = this._cachedUser.filter(n => !n.is_read).length;
|
||||
const systemUnread = this._cachedSystem.filter(n => !n.is_read).length;
|
||||
const totalUnread = userUnread + systemUnread;
|
||||
const adminUnread = this._cachedAdmin.filter(n => !n.is_read).length;
|
||||
const totalUnread = userUnread + systemUnread + adminUnread;
|
||||
|
||||
// Update main bell badge (total unread)
|
||||
if (totalUnread > 0) {
|
||||
@@ -18905,6 +19145,7 @@ class NotificationSystem {
|
||||
// Update per-tab badges
|
||||
const userBadge = document.getElementById('notif-tab-badge-user');
|
||||
const systemBadge = document.getElementById('notif-tab-badge-system');
|
||||
const adminBadge = document.getElementById('notif-tab-badge-admin');
|
||||
if (userBadge) {
|
||||
userBadge.textContent = userUnread;
|
||||
userBadge.style.display = userUnread > 0 ? '' : 'none';
|
||||
@@ -18913,6 +19154,10 @@ class NotificationSystem {
|
||||
systemBadge.textContent = systemUnread;
|
||||
systemBadge.style.display = systemUnread > 0 ? '' : 'none';
|
||||
}
|
||||
if (adminBadge) {
|
||||
adminBadge.textContent = adminUnread;
|
||||
adminBadge.style.display = adminUnread > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
// Forward count to Abyss scroller notification badge if active
|
||||
if (typeof window._scrollerNotifHook === 'function') {
|
||||
@@ -18964,7 +19209,7 @@ class NotificationSystem {
|
||||
const historyContainer = document.querySelector('.notifications-list-full');
|
||||
if (historyContainer) {
|
||||
const historyTab = historyContainer.dataset.tab || 'user';
|
||||
const tabNotifs = historyTab === 'system' ? this._cachedSystem : this._cachedUser;
|
||||
const tabNotifs = historyTab === 'admin' ? this._cachedAdmin : (historyTab === 'system' ? this._cachedSystem : this._cachedUser);
|
||||
tabNotifs.forEach(n => {
|
||||
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
|
||||
if (!existing) {
|
||||
@@ -18984,7 +19229,7 @@ class NotificationSystem {
|
||||
|
||||
_renderActiveTab() {
|
||||
if (!this.list) return;
|
||||
const items = this.activeTab === 'system' ? this._cachedSystem : this._cachedUser;
|
||||
const items = this.activeTab === 'admin' ? this._cachedAdmin : (this.activeTab === 'system' ? this._cachedSystem : this._cachedUser);
|
||||
if (items.length === 0) {
|
||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
return;
|
||||
@@ -18999,6 +19244,51 @@ class NotificationSystem {
|
||||
let msg = '';
|
||||
let user = n.from_display_name || n.from_user || 'System';
|
||||
|
||||
if (n.type === 'invite_request') {
|
||||
const reason = n.data?.reason || '';
|
||||
const username = n.data?.username || '???';
|
||||
return `
|
||||
<a href="/admin/tokens" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--accent);font-size:1.5em;"><i class="fa-solid fa-inbox"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>${username}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>Invite request</strong>
|
||||
${reason ? '<div class="notif-reason">' + reason + '</div>' : ''}
|
||||
</div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_approved') {
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:#51cf66;font-size:1.5em;"><i class="fa-solid fa-circle-check"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong style="color: #51cf66;">System</strong></div>
|
||||
<div class="notif-msg"><strong>Your invite request was approved!</strong><div style="font-size: 0.9em; margin-top: 2px;">Go to Settings to register with your token.</div></div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_denied') {
|
||||
const isRevoked = n.data?.revoked === true;
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:${isRevoked ? '#e67700' : '#ff6b6b'};font-size:1.5em;"><i class="fa-solid ${isRevoked ? 'fa-ban' : 'fa-circle-xmark'}"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong style="color: ${isRevoked ? '#e67700' : '#ff6b6b'};">System</strong></div>
|
||||
<div class="notif-msg"><strong>${isRevoked ? 'Your invite token was revoked' : 'Your invite request was denied'}</strong><div style="font-size: 0.9em; margin-top: 2px;">You can submit a new request.</div></div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'deny' || n.type === 'item_deleted') {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const isDeleted = n.type === 'item_deleted';
|
||||
@@ -19089,6 +19379,55 @@ class NotificationSystem {
|
||||
|
||||
renderItem(n) {
|
||||
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||
|
||||
if (n.type === 'invite_request') {
|
||||
const reason = n.data?.reason || '';
|
||||
const username = n.data?.username || '???';
|
||||
return `
|
||||
<a href="/admin/tokens" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--accent);font-size:1.3em;"><i class="fa-solid fa-inbox"></i></div>
|
||||
<div class="notif-content">
|
||||
<div>
|
||||
<strong>Invite request</strong>
|
||||
<div style="color: var(--text-muted); margin-top: 2px;">from <span style="color: #5bc0be; font-weight: 600;">${username}</span>${reason ? ' — ' + reason : ''}</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_approved') {
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:#51cf66;font-size:1.3em;"><i class="fa-solid fa-circle-check"></i></div>
|
||||
<div class="notif-content">
|
||||
<div>
|
||||
<strong style="color: #51cf66;">Invite request approved!</strong>
|
||||
<div style="font-size: 0.85em; color: var(--text-muted); margin-top: 2px;">Your invite token is ready. Go to Settings to register.</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_denied') {
|
||||
const isRevoked = n.data?.revoked === true;
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:${isRevoked ? '#e67700' : '#ff6b6b'};font-size:1.3em;"><i class="fa-solid ${isRevoked ? 'fa-ban' : 'fa-circle-xmark'}"></i></div>
|
||||
<div class="notif-content">
|
||||
<div>
|
||||
<strong style="color: ${isRevoked ? '#e67700' : '#ff6b6b'};">${isRevoked ? 'Invite token revoked' : 'Invite request denied'}</strong>
|
||||
<div style="color: var(--text-muted); margin-top: 2px;">${isRevoked ? 'Your token was revoked. You can submit a new request.' : 'Your invite request was not approved. You can try again.'}</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'approve') {
|
||||
const link = `/${itemKey}`;
|
||||
return `
|
||||
@@ -19238,7 +19577,7 @@ class NotificationSystem {
|
||||
return false;
|
||||
};
|
||||
|
||||
[this._cachedUser, this._cachedSystem].forEach(list => {
|
||||
[this._cachedUser, this._cachedSystem, this._cachedAdmin].forEach(list => {
|
||||
if (!Array.isArray(list)) return;
|
||||
list.forEach(n => {
|
||||
if (matchesItem(n) && !n.is_read) {
|
||||
@@ -19284,7 +19623,8 @@ class NotificationSystem {
|
||||
// Recalculate unread counts
|
||||
const userUnread = (this._cachedUser || []).filter(n => !n.is_read).length;
|
||||
const systemUnread = (this._cachedSystem || []).filter(n => !n.is_read).length;
|
||||
const totalUnread = userUnread + systemUnread;
|
||||
const adminUnread = (this._cachedAdmin || []).filter(n => !n.is_read).length;
|
||||
const totalUnread = userUnread + systemUnread + adminUnread;
|
||||
|
||||
if (this.countBadge) {
|
||||
this.countBadge.textContent = totalUnread;
|
||||
@@ -19292,6 +19632,7 @@ class NotificationSystem {
|
||||
}
|
||||
const userBadge = document.getElementById('notif-tab-badge-user');
|
||||
const systemBadge = document.getElementById('notif-tab-badge-system');
|
||||
const adminBadge = document.getElementById('notif-tab-badge-admin');
|
||||
if (userBadge) {
|
||||
userBadge.textContent = userUnread;
|
||||
userBadge.style.display = userUnread > 0 ? '' : 'none';
|
||||
@@ -19300,6 +19641,10 @@ class NotificationSystem {
|
||||
systemBadge.textContent = systemUnread;
|
||||
systemBadge.style.display = systemUnread > 0 ? '' : 'none';
|
||||
}
|
||||
if (adminBadge) {
|
||||
adminBadge.textContent = adminUnread;
|
||||
adminBadge.style.display = adminUnread > 0 ? '' : 'none';
|
||||
}
|
||||
if (typeof window._scrollerNotifHook === 'function') {
|
||||
window._scrollerNotifHook(totalUnread);
|
||||
}
|
||||
@@ -19336,43 +19681,83 @@ class NotificationSystem {
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/read', {
|
||||
const tab = this.activeTab || 'user';
|
||||
const res = await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.markAllReadUI();
|
||||
this.markAllReadUI(tab);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to mark all read', e);
|
||||
}
|
||||
}
|
||||
|
||||
markAllReadUI() {
|
||||
if (this.countBadge) this.countBadge.style.display = 'none';
|
||||
// Clear per-tab badges
|
||||
const userBadge = document.getElementById('notif-tab-badge-user');
|
||||
const systemBadge = document.getElementById('notif-tab-badge-system');
|
||||
if (userBadge) userBadge.style.display = 'none';
|
||||
if (systemBadge) systemBadge.style.display = 'none';
|
||||
// Clear cached data
|
||||
this._cachedUser = [];
|
||||
this._cachedSystem = [];
|
||||
if (this.list) {
|
||||
markAllReadUI(tab) {
|
||||
if (!tab) {
|
||||
// No tab specified — clear everything (backward compat)
|
||||
if (this.countBadge) this.countBadge.style.display = 'none';
|
||||
const userBadge = document.getElementById('notif-tab-badge-user');
|
||||
const systemBadge = document.getElementById('notif-tab-badge-system');
|
||||
const adminBadge = document.getElementById('notif-tab-badge-admin');
|
||||
if (userBadge) userBadge.style.display = 'none';
|
||||
if (systemBadge) systemBadge.style.display = 'none';
|
||||
if (adminBadge) adminBadge.style.display = 'none';
|
||||
this._cachedUser = [];
|
||||
this._cachedSystem = [];
|
||||
this._cachedAdmin = [];
|
||||
if (this.list) {
|
||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
}
|
||||
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread'));
|
||||
document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
|
||||
if (typeof gridCacheMap !== 'undefined' && gridCacheMap) {
|
||||
gridCacheMap.forEach(cacheEntry => {
|
||||
if (cacheEntry?.node) cacheEntry.node.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
|
||||
});
|
||||
}
|
||||
if (typeof window._scrollerNotifHook === 'function') {
|
||||
window._scrollerNotifHook(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab-specific clear
|
||||
const badge = document.getElementById('notif-tab-badge-' + tab);
|
||||
if (badge) badge.style.display = 'none';
|
||||
if (tab === 'user') this._cachedUser = [];
|
||||
else if (tab === 'system') this._cachedSystem = [];
|
||||
else if (tab === 'admin') this._cachedAdmin = [];
|
||||
|
||||
// Recalculate total unread across remaining tabs
|
||||
const userUnread = (this._cachedUser || []).filter(n => !n.is_read).length;
|
||||
const systemUnread = (this._cachedSystem || []).filter(n => !n.is_read).length;
|
||||
const adminUnread = (this._cachedAdmin || []).filter(n => !n.is_read).length;
|
||||
const totalUnread = userUnread + systemUnread + adminUnread;
|
||||
if (this.countBadge) {
|
||||
if (totalUnread > 0) {
|
||||
this.countBadge.textContent = totalUnread;
|
||||
this.countBadge.style.display = '';
|
||||
} else {
|
||||
this.countBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the dropdown list for the active tab
|
||||
if (this.list && this.activeTab === tab) {
|
||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
}
|
||||
// Also update History page items if present
|
||||
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread'));
|
||||
// Remove .has-notif highlights
|
||||
document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
|
||||
if (typeof gridCacheMap !== 'undefined' && gridCacheMap) {
|
||||
gridCacheMap.forEach(cacheEntry => {
|
||||
if (cacheEntry?.node) cacheEntry.node.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
|
||||
});
|
||||
}
|
||||
// Update History page items if present — remove unread from items belonging to this tab's types
|
||||
const tabTypes = tab === 'user' ? NotificationSystem.USER_TYPES
|
||||
: tab === 'admin' ? NotificationSystem.ADMIN_TYPES
|
||||
: NotificationSystem.SYSTEM_TYPES;
|
||||
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => {
|
||||
if (tabTypes.includes(el.dataset.type)) el.classList.remove('unread');
|
||||
});
|
||||
if (typeof window._scrollerNotifHook === 'function') {
|
||||
window._scrollerNotifHook(0);
|
||||
window._scrollerNotifHook(totalUnread);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22199,6 +22584,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!el) return null;
|
||||
const steuerungEl = el.closest && el.closest('.steuerung a, .steuerung button, .steuerung [role="button"]');
|
||||
if (steuerungEl) return steuerungEl;
|
||||
// "Back to top" below the comments: same click sound / haptics / press state as the nav controls
|
||||
const toTop = el.closest && el.closest('.mobile-scroll-to-top');
|
||||
if (toTop) return toTop;
|
||||
if (el.id === 'prev' || (el.classList && el.classList.contains('nav-prev')) || (el.closest && el.closest('.arrow-prev, .previous-post'))) {
|
||||
return document.querySelector('.steuerung .nav-prev') || el;
|
||||
}
|
||||
@@ -22291,18 +22679,129 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mobile scroll-to-top button ───────────────────────────────────────────────
|
||||
// Visible only on mobile (≤999px), appears below #comments-container so users
|
||||
// can easily get back to the top after reading comments. Only shown once the relevant scroller is
|
||||
// scrolled far enough that getting back up by hand is a chore (1.5 screens, at least 600px).
|
||||
// ── Mouse wheel scrolls horizontal bars sideways ────────────────────────────────
|
||||
// For single-line bars that overflow horizontally without a visible scrollbar. A vertical wheel turn
|
||||
// moves the bar sideways while it can still move that way; at either end the page scrolls as usual.
|
||||
// Trackpad sideways swipes (deltaX dominant) are left to the browser.
|
||||
(() => {
|
||||
const scrollerFor = (btn) => btn.closest('#onara-modal') || document.querySelector('.index-container') || null;
|
||||
const WHEEL_X_SEL = '.f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, #settings-quicknav';
|
||||
document.addEventListener('wheel', (e) => {
|
||||
if (e.ctrlKey || e.defaultPrevented) return; // ctrl+wheel = zoom
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const bar = e.target.closest && e.target.closest(WHEEL_X_SEL);
|
||||
if (!bar || bar.scrollWidth <= bar.clientWidth + 1) return;
|
||||
const max = bar.scrollWidth - bar.clientWidth;
|
||||
// Wheel down moves the bar right, wheel up moves it left
|
||||
const dir = Math.sign(e.deltaY);
|
||||
// The wheel stays with the bar while the pointer is on it (no page scroll)
|
||||
e.preventDefault();
|
||||
if ((dir < 0 && bar.scrollLeft <= 0) || (dir > 0 && bar.scrollLeft >= max - 1)) {
|
||||
// At the end: short rubber-band stretch past the edge, then ease back. A running stretch is not
|
||||
// restarted, so a continuous wheel spin reads as one bounce instead of a jitter.
|
||||
const items = Array.from(bar.children).filter(el => typeof el.animate === 'function' && el.offsetParent !== null);
|
||||
if (!items.length || (bar._edgeAnim && bar._edgeAnim.playState === 'running')) return;
|
||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
// Stretch the items, not the bar: the one at the edge being pulled moves the full distance, the
|
||||
// far end barely moves, so the gaps open a little and close again. Pull out with a soft ease-out,
|
||||
// settle back slowly (spring-like, no overshoot).
|
||||
const n = items.length;
|
||||
items.forEach((el, i) => {
|
||||
const weight = dir > 0 ? (i + 1) / n : (n - i) / n;
|
||||
const px = -dir * 22 * weight;
|
||||
const anim = el.animate(
|
||||
[
|
||||
{ transform: 'translateX(0)', easing: 'cubic-bezier(0.25, 0.8, 0.4, 1)' },
|
||||
{ transform: 'translateX(' + px.toFixed(2) + 'px)', offset: 0.3, easing: 'cubic-bezier(0.45, 0, 0.25, 1)' },
|
||||
{ transform: 'translateX(0)' }
|
||||
],
|
||||
{ duration: 900 }
|
||||
);
|
||||
if (i === 0) bar._edgeAnim = anim;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// deltaMode 1 = lines (Firefox mouse wheels), 2 = pages
|
||||
const amount = Math.abs(e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * bar.clientWidth : e.deltaY);
|
||||
bar.scrollLeft = Math.max(0, Math.min(max, bar.scrollLeft + dir * amount));
|
||||
}, { passive: false });
|
||||
})();
|
||||
|
||||
// Clicking a chip in one of these bars brings it fully into view (smooth, sideways only). For tuner
|
||||
// sub-tabs the tuner also scrolls so the opened pane starts in view.
|
||||
(() => {
|
||||
const revealInBar = (bar, el) => {
|
||||
if (!bar || !el || bar.scrollWidth <= bar.clientWidth + 1) return;
|
||||
const br = bar.getBoundingClientRect(), er = el.getBoundingClientRect(), pad = 12;
|
||||
let target = bar.scrollLeft;
|
||||
if (er.left < br.left + pad) target += er.left - br.left - pad;
|
||||
else if (er.right > br.right - pad) target += er.right - br.right + pad;
|
||||
else return;
|
||||
bar.scrollTo({ left: Math.max(0, target), behavior: 'smooth' });
|
||||
};
|
||||
document.addEventListener('click', (e) => {
|
||||
const chip = e.target.closest && e.target.closest('.f0ck-tuner-subtab-btn, .f0ck-tuner-mode-btn');
|
||||
if (!chip) return;
|
||||
const bar = chip.closest('.f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar');
|
||||
// After the tab switch has laid out the new pane
|
||||
requestAnimationFrame(() => {
|
||||
revealInBar(bar, chip);
|
||||
if (!chip.dataset.subtab) return;
|
||||
const pane = document.getElementById('f0ck-tuner-pane-' + chip.dataset.subtab);
|
||||
const sc = pane && window._f0ckScrollerFor ? window._f0ckScrollerFor(pane) : null;
|
||||
if (!pane || !sc) return;
|
||||
const pr = pane.getBoundingClientRect(), sr = sc.getBoundingClientRect();
|
||||
// Pane top above the visible area (scrolled away): bring it back so the section reads from its start
|
||||
const header = pane.parentElement && pane.parentElement.querySelector('.f0ck-tuner-header');
|
||||
const headerBottom = header && getComputedStyle(header).position === 'sticky' ? header.getBoundingClientRect().bottom : sr.top;
|
||||
if (pr.top < headerBottom - 1) sc.scrollBy({ top: pr.top - headerBottom, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Scroll-to-top button ─────────────────────────────────────────────────────
|
||||
// All screen sizes (class name .mobile-scroll-to-top is historical); sits below the comments.
|
||||
// Shown once the container it lives in is scrolled more than ~half a screen: measured on the element
|
||||
// that really scrolls (Onara modal, .index-container or the window), found by walking up from the button.
|
||||
(() => {
|
||||
const isScroller = (el) => {
|
||||
if (!el || el === document.body || el === document.documentElement) return false;
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
return (oy === 'auto' || oy === 'scroll' || oy === 'overlay') && el.scrollHeight > el.clientHeight + 1;
|
||||
};
|
||||
// null = the window scrolls
|
||||
const scrollerFor = (btn) => {
|
||||
for (let el = btn.parentElement; el; el = el.parentElement) if (isScroller(el)) return el;
|
||||
return null;
|
||||
};
|
||||
window._f0ckScrollerFor = scrollerFor;
|
||||
|
||||
// Horizontal centre of the button's content column, minus whatever the right sidebar covers of it
|
||||
// (the column can run underneath an open sidebar). Applied as `left` in px against its offsetParent.
|
||||
const SIDEBAR_SEL = '.global-sidebar-right, .item-sidebar-right, .index-sidebar-right';
|
||||
const centreButton = (btn) => {
|
||||
const col = btn.parentElement;
|
||||
const op = btn.offsetParent;
|
||||
if (!col || !op) return;
|
||||
const cr = col.getBoundingClientRect();
|
||||
let L = Math.max(0, cr.left), R = Math.min(document.documentElement.clientWidth, cr.right);
|
||||
const rs = document.querySelector(SIDEBAR_SEL);
|
||||
if (rs && !btn.closest(SIDEBAR_SEL) && !document.body.classList.contains('sidebar-right-hidden')) {
|
||||
const r = rs.getBoundingClientRect();
|
||||
const cs = getComputedStyle(rs);
|
||||
if (r.width > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && r.left < R && r.right > L) R = Math.min(R, r.left);
|
||||
}
|
||||
if (R - L < 10) return;
|
||||
const or = op.getBoundingClientRect();
|
||||
btn.style.left = Math.round((L + R) / 2 - or.left - op.clientLeft) + 'px';
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
document.querySelectorAll('.mobile-scroll-to-top').forEach((btn) => {
|
||||
centreButton(btn);
|
||||
const sc = scrollerFor(btn);
|
||||
const top = sc ? sc.scrollTop : (window.scrollY || document.documentElement.scrollTop);
|
||||
const viewH = sc ? sc.clientHeight : window.innerHeight;
|
||||
btn.classList.toggle('is-visible', top > Math.max(600, viewH * 1.5));
|
||||
btn.classList.toggle('is-visible', top > Math.max(250, viewH * 0.6));
|
||||
});
|
||||
};
|
||||
let queued = false;
|
||||
@@ -22314,23 +22813,26 @@ document.addEventListener('click', (e) => {
|
||||
// Capture: scroll events don't bubble, and the scroller may be the window, .index-container or the Onara modal
|
||||
document.addEventListener('scroll', schedule, { capture: true, passive: true });
|
||||
window.addEventListener('resize', schedule, { passive: true });
|
||||
// New page content (AJAX navigation, Onara) arrives already scrolled sometimes: re-check then too
|
||||
document.addEventListener('f0ck:contentLoaded', schedule);
|
||||
window.addEventListener('popstate', schedule);
|
||||
// Sidebar open/close/resize moves the centre: the toggle announces it, the body class flips after the slide
|
||||
window.addEventListener('f0ck:sidebar-right-layout', () => { schedule(); setTimeout(schedule, 350); });
|
||||
new MutationObserver(schedule).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
document.addEventListener('transitionend', (e) => {
|
||||
if (e.target && e.target.matches && e.target.matches(SIDEBAR_SEL)) schedule();
|
||||
});
|
||||
})();
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.mobile-scroll-to-top')) return;
|
||||
const btn = e.target.closest('.mobile-scroll-to-top');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const onaraModal = e.target.closest('#onara-modal') || (document.body.classList.contains('onara-modal-open') && document.getElementById('onara-modal'));
|
||||
if (onaraModal) {
|
||||
onaraModal.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
const ic = document.querySelector('.index-container');
|
||||
if (ic) {
|
||||
ic.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
if (navigator.vibrate) navigator.vibrate(20);
|
||||
// Scroll the same container the visibility is measured on
|
||||
const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(btn) : null;
|
||||
if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// Sound + haptics come from the shared steuerung feedback (resolveSteuerungTarget)
|
||||
});
|
||||
|
||||
// ── Spoiler Tags Event Delegation ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user