This commit is contained in:
2026-09-29 04:35:35 +02:00
parent 73c0659d4a
commit 6c813143eb
51 changed files with 3708 additions and 743 deletions
+588 -86
View File
@@ -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 ─────────────────────────────────────────────