diff --git a/deleted/.gitkeep b/deleted/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pending/.gitkeep b/pending/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pending/ca/.gitkeep b/pending/ca/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pending/t/.gitkeep b/pending/t/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/memes/orakel_mystic.png b/public/memes/orakel_mystic.png new file mode 100644 index 0000000..e3f48ae Binary files /dev/null and b/public/memes/orakel_mystic.png differ diff --git a/public/memes/user_orakel.png b/public/memes/user_orakel.png new file mode 100644 index 0000000..6256853 Binary files /dev/null and b/public/memes/user_orakel.png differ diff --git a/public/memes/von10_orakel.png b/public/memes/von10_orakel.png new file mode 100644 index 0000000..5c931ba Binary files /dev/null and b/public/memes/von10_orakel.png differ diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index b8fca92..a81d88f 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -966,23 +966,114 @@ html[theme="4d"] { } .notif-header { - padding: 10px; + padding: 8px 10px 6px; border-bottom: 1px solid var(--nav-border-color); display: flex; justify-content: space-between; align-items: center; + gap: 8px; font-size: 0.9rem; color: var(--white); background: var(--nav-bg); } -.notif-header button { +.notif-header button#mark-all-read { background: none; border: none; color: var(--accent); cursor: pointer; - font-size: 0.8rem; + font-size: 0.7rem; padding: 0; + white-space: nowrap; + flex-shrink: 0; +} + +/* Notification Tabs (dropdown) */ +.notif-tabs { + display: flex; + gap: 2px; + flex: 1; + min-width: 0; +} + +.notif-tab { + background: none; + border: none; + color: #888; + font-size: 0.78rem; + font-weight: 600; + padding: 4px 10px; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + position: relative; +} + +.notif-tab:hover { + color: #ccc; + background: rgba(255, 255, 255, 0.05); +} + +.notif-tab.active { + color: var(--accent); + background: rgba(var(--accent-rgb, 31, 178, 176), 0.1); +} + +.notif-tab-badge { + display: inline-block; + background: var(--badge-nsfw); + color: #fff; + font-size: 9px; + min-width: 14px; + height: 14px; + line-height: 14px; + text-align: center; + border-radius: 7px; + padding: 0 3px; + margin-left: 3px; + font-weight: 700; + vertical-align: middle; +} + +/* Notification Page Tabs */ +.notif-page-tabs { + display: flex; + gap: 0; + margin-bottom: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.15); + border-radius: 4px 4px 0 0; + overflow: hidden; +} + +.notif-page-tab { + flex: 1; + background: none; + border: none; + color: #888; + font-size: 0.9rem; + font-weight: 600; + padding: 10px 20px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 1px; + transition: all 0.2s ease; + border-bottom: 2px solid transparent; + position: relative; +} + +.notif-page-tab:hover { + color: #ccc; + background: rgba(255, 255, 255, 0.03); +} + +.notif-page-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(var(--accent-rgb, 31, 178, 176), 0.05); } .notif-list { @@ -3420,6 +3511,69 @@ span.placeholder { } } +/* Danmaku toggle button on Ruffle embeds — appears on hover */ +.ruffle-danmaku-toggle { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.45); + font-size: 16px; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.embed-responsive:hover .ruffle-danmaku-toggle, +#ruffle-container:hover .ruffle-danmaku-toggle { + opacity: 1; + pointer-events: auto; +} + +.ruffle-danmaku-toggle:hover { + background: rgba(0, 0, 0, 0.75); + border-color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.85); +} + +.ruffle-danmaku-toggle.active { + color: var(--accent, #9f0); + border-color: var(--accent, #9f0); + background: rgba(0, 0, 0, 0.65); +} + +.ruffle-danmaku-toggle.active:hover { + background: rgba(0, 0, 0, 0.8); +} + +/* Strikethrough line when inactive */ +.ruffle-danmaku-toggle:not(.active)::after { + content: ''; + position: absolute; + top: 50%; + left: 15%; + width: 70%; + height: 2px; + background: rgba(255, 80, 80, 0.8); + transform: rotate(-45deg); + border-radius: 1px; + pointer-events: none; +} + + + + .embed-responsive-image { position: absolute; top: 0; @@ -10110,6 +10264,223 @@ body.layout-modern .tag-controls { max-width: 200px; } +/* ── DM Post Preview Card ─────────────────────────────────── */ +a.dm-post-card, +span.dm-post-card { + display: flex; + align-items: stretch; + gap: 0; + border-radius: 10px; + overflow: hidden; + text-decoration: none; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(0,0,0,0.3); + margin: 6px 0 2px; + max-width: 320px; + transition: border-color 0.15s, background 0.15s; + cursor: pointer; +} + +a.dm-post-card:hover { + border-color: var(--accent); + background: rgba(0,0,0,0.4); +} + +/* Loading state */ +span.dm-post-card--loading { + opacity: 0.55; +} + +.dm-post-card__thumb-wrap { + position: relative; + flex-shrink: 0; + width: 72px; + height: 72px; + background: #111; + overflow: hidden; +} + +.dm-post-card__thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.dm-post-card__thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #555; + font-size: 1.2em; +} + +/* Media type badge (top-right of thumb) */ +.dm-post-card__type-badge { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0,0,0,0.7); + color: var(--accent, #aaa); + font-size: 0.65em; + padding: 2px 4px; + border-radius: 4px; + line-height: 1.2; + pointer-events: none; +} + +.dm-post-card__info { + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 8px 12px; + min-width: 160px; + flex: 1; +} + +.dm-post-card__id { + font-weight: 700; + font-size: 0.88em; + color: var(--accent, #aaa); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dm-post-card__uploader, +.dm-post-card__comments { + font-size: 0.75em; + color: rgba(255,255,255,0.5); + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dm-post-card__uploader i, +.dm-post-card__comments i { + font-size: 0.85em; + opacity: 0.7; + flex-shrink: 0; +} + +/* Invert text colors inside a "mine" bubble so card still reads well */ +.dm-msg-mine .dm-post-card__uploader, +.dm-msg-mine .dm-post-card__comments { + color: rgba(255,255,255,0.65); +} + +.dm-msg-mine a.dm-post-card { + background: rgba(0,0,0,0.2); + border-color: rgba(255,255,255,0.15); +} + +/* ── Global Chat Post Preview Card ──────────────────────────── */ +a.gchat-post-card, +span.gchat-post-card { + display: inline-flex; + align-items: stretch; + border-radius: 8px; + overflow: hidden; + text-decoration: none; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(0,0,0,0.3); + margin: 4px 0 2px; + max-width: 280px; + transition: border-color 0.15s, background 0.15s; + vertical-align: middle; + cursor: pointer; +} + +a.gchat-post-card:hover { + border-color: var(--accent); + background: rgba(0,0,0,0.45); +} + +span.gchat-post-card--loading { + opacity: 0.55; +} + +.gchat-post-card__thumb-wrap { + position: relative; + flex-shrink: 0; + width: 60px; + height: 60px; + background: #111; + overflow: hidden; +} + +.gchat-post-card__thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.gchat-post-card__thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #555; + font-size: 1em; +} + +.gchat-post-card__type-badge { + position: absolute; + top: 3px; + right: 3px; + background: rgba(0,0,0,0.75); + color: var(--accent, #aaa); + font-size: 0.6em; + padding: 2px 3px; + border-radius: 3px; + line-height: 1.2; + pointer-events: none; +} + +.gchat-post-card__info { + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; + padding: 6px 10px; + min-width: 120px; + flex: 1; +} + +.gchat-post-card__id { + font-weight: 700; + font-size: 0.82em; + color: var(--accent, #aaa); + white-space: nowrap; +} + +.gchat-post-card__uploader, +.gchat-post-card__comments { + font-size: 0.72em; + color: rgba(255,255,255,0.45); + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gchat-post-card__uploader i, +.gchat-post-card__comments i { + font-size: 0.85em; + opacity: 0.7; + flex-shrink: 0; +} + /* Köpfe — random corner image */ #koepfe-img { position: fixed; @@ -10909,7 +11280,7 @@ textarea#profile_description { } .meta-suggestion:hover { - background: var(--accent); + background: var(--bg); border-color: var(--accent); color: #000 !important; transform: translateY(-1px); @@ -11101,6 +11472,23 @@ body.scroller-active #gchat-widget { opacity: 1; transform: scale(1.08); } +.gchat-bubble-badge { + position: absolute; + top: -5px; + right: -5px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 999px; + background: #e53935; + color: #fff; + font-size: 0.68em; + font-weight: 700; + line-height: 18px; + text-align: center; + pointer-events: none; + animation: gchat-badge-pop 0.2s ease; +} body.sidebar-right-hidden #gchat-reopen-bubble { right: 18px; } @@ -11226,6 +11614,68 @@ body.scroller-active #gchat-reopen-bubble { /* Messages area */ /* Pinned topic bar */ +/* Online users bar */ +#gchat-online { + display: none; + flex-shrink: 0; + border-bottom: 1px solid var(--nav-border-color, rgba(255,255,255,0.07)); + background: rgba(0,0,0,0.15); +} +.gchat-online-inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + gap: 8px; +} +.gchat-online-count { + font-size: 0.68em; + color: rgba(255,255,255,0.45); + white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; +} +.gchat-online-count::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #4caf50; + flex-shrink: 0; +} +.gchat-online-avatars { + display: flex; + align-items: center; + flex-direction: row-reverse; /* stack right-to-left */ +} +.gchat-online-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--bg, #1a1a1a); + margin-left: -6px; + flex-shrink: 0; + transition: transform 0.1s; +} +.gchat-online-avatar:last-child { + margin-left: 0; +} +.gchat-online-avatars:hover .gchat-online-avatar { + transform: scale(1.1); +} +.gchat-online-extra { + font-size: 0.65em; + color: rgba(255,255,255,0.5); + background: rgba(255,255,255,0.08); + border-radius: 999px; + padding: 1px 5px; + margin-left: -2px; + white-space: nowrap; +} + #gchat-topic { padding: 5px 10px; font-size: 0.78em; @@ -11691,7 +12141,7 @@ body.scroller-active #gchat-reopen-bubble { .gchat-embed-video video { max-width: 100%; - max-height: 180px; + max-height: 350px; border-radius: 6px; display: block; } @@ -11831,3 +12281,245 @@ body.scroller-active #gchat-reopen-bubble { cursor: default; } + +/* ── Same-site item preview card ───────────────────────────────────────────── */ +.gchat-item-card { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 6px; + border-radius: 8px; + overflow: hidden; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + text-decoration: none; + color: inherit; + transition: background 0.15s, border-color 0.15s; + max-width: 260px; + width: 100%; +} +.gchat-item-card:hover { + background: rgba(255,255,255,0.1); + border-color: var(--accent, rgba(255,255,255,0.2)); + text-decoration: none; +} +.gchat-item-card-thumb { + position: relative; + flex-shrink: 0; + width: 80px; + height: 60px; + overflow: hidden; + background: rgba(0,0,0,0.3); +} +.gchat-item-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.gchat-item-card-icon { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0,0,0,0.6); + border-radius: 4px; + padding: 2px 5px; + font-size: 0.72rem; + color: #fff; + line-height: 1; + pointer-events: none; +} +.gchat-item-card-info { + flex: 1; + min-width: 0; + padding: 6px 8px 6px 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.gchat-item-card-id { + font-size: 0.8rem; + font-weight: 700; + color: var(--accent, #f2ef0b); + white-space: nowrap; +} +.gchat-item-card-meta { + font-size: 0.7rem; + opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Same-site item preview card ───────────────────────────────────────────── */ +.gchat-item-card { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 6px; + border-radius: 8px; + overflow: hidden; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + text-decoration: none; + color: inherit; + transition: background 0.15s, border-color 0.15s; + max-width: 260px; + width: 100%; +} +.gchat-item-card:hover { + background: rgba(255,255,255,0.1); + border-color: var(--accent, rgba(255,255,255,0.2)); + text-decoration: none; +} +.gchat-item-card-thumb { + position: relative; + flex-shrink: 0; + width: 80px; + height: 60px; + overflow: hidden; + background: rgba(0,0,0,0.3); +} +.gchat-item-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.gchat-item-card-icon { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0,0,0,0.6); + border-radius: 4px; + padding: 2px 5px; + font-size: 0.72rem; + color: #fff; + line-height: 1; + pointer-events: none; +} +.gchat-item-card-info { + flex: 1; + min-width: 0; + padding: 6px 8px 6px 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.gchat-item-card-id { + font-size: 0.8rem; + font-weight: 700; + color: var(--accent, #f2ef0b); + white-space: nowrap; +} +.gchat-item-card-meta { + font-size: 0.7rem; + opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ============================================= + SETTINGS PAGE – MOBILE OVERFLOW FIXES + Prevents long i18n strings (Deutsch / Zange) + from expanding past the viewport on mobile. + ============================================= */ + +.settings { + max-width: 100%; + overflow-x: hidden; + box-sizing: border-box; + word-break: break-word; + overflow-wrap: anywhere; +} + +.settings h1, +.settings h2, +.settings h4, +.settings label, +.settings span, +.settings small, +.settings p, +.settings legend { + overflow-wrap: anywhere; + word-break: break-word; +} + +.settings fieldset { + min-width: 0; /* prevent fieldsets from blowing out grid */ + max-width: 100%; + box-sizing: border-box; +} + +@media (max-width: 600px) { + .settings { + padding: 0 4px; + } + + /* Account info table: stack label and value */ + .settings .account-info-table td { + display: block; + width: 100% !important; + padding: 2px 0; + } + .settings .account-info-table tr { + display: block; + margin-bottom: 8px; + } + + /* Display name row – stack input & button vertically */ + .settings .account-info-table td[style*="display: flex"] { + flex-wrap: wrap; + } + + /* Account actions grid – single column on mobile */ + .settings .account-actions-grid { + grid-template-columns: 1fr !important; + } + + /* Prevent flex rows from overflowing */ + .settings .setting-item label[style*="display: flex"] { + flex-wrap: wrap; + } + + .settings .setting-item label span { + flex: 1; + min-width: 0; + } + + /* Hint text under checkboxes */ + .settings small.text-muted { + display: block; + margin-left: 0 !important; + margin-top: 2px; + word-break: break-word; + overflow-wrap: anywhere; + } + + /* Username color row */ + .settings .setting-item div[style*="display: flex"] { + flex-wrap: wrap; + } + + /* Linked accounts wrapper */ + .settings .linked-accounts-wrapper, + .settings .preferences-settings-wrapper, + .settings .account-settings-wrapper, + .settings .profile-settings-wrapper { + max-width: 100%; + overflow-x: hidden; + box-sizing: border-box; + } + + .settings .account-settings-wrapper { + padding: 12px !important; + } + + /* Fieldset legend text */ + .settings fieldset legend { + font-size: 1em !important; + max-width: 100%; + overflow-wrap: anywhere; + } +} diff --git a/public/s/js/admin.js b/public/s/js/admin.js index bb327bb..3b9d690 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -444,5 +444,33 @@ } }; + window.adminReassignUploads = async (btn) => { + const id = btn.dataset.id; + const name = btn.dataset.name; + const username = btn.dataset.username; + + if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); + + ModAction.confirm( + 'Reassign Uploads', + 'Enter the target username to transfer all uploads from ' + escHTML(name) + ' to:', + async (targetUsername) => { + const payload = { target_username: targetUsername }; + if (id) { + payload.source_user_id = id; + } else { + payload.source_username = username; + } + const res = await post('/api/v2/admin/users/reassign-uploads', payload); + if (res.success) { + showFlash(res.msg, 'success'); + } else { + throw new Error(res.msg || 'Reassignment failed'); + } + }, + { hideReason: false, confirmText: 'Reassign', placeholder: 'target username' } + ); + }; + })(); diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 42abb47..1fc5c4d 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1183,9 +1183,15 @@ window.cancelAnimFrame = (function () { }); // --- Gesture Support (Mobile & Desktop) --- - // Inject HUD and Overlay + // Inject HUD, Overlay, and Danmaku toggle const existingHUD = container.querySelector('.v0ck_hud'); if (!existingHUD) { + // Determine initial danmaku state + const dmConfigDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined) + ? !!window.f0ckSession.enable_danmaku : true; + const dmSaved = localStorage.getItem('danmaku'); + const dmOn = (dmSaved !== null) ? (dmSaved !== 'false') : dmConfigDefault; + container.insertAdjacentHTML('beforeend', `
@@ -1194,7 +1200,51 @@ window.cancelAnimFrame = (function () {
+ `); + + // Wire up danmaku toggle + const dmBtn = container.querySelector('.ruffle-danmaku-toggle'); + if (dmBtn) { + dmBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (window.danmakuInstance) { + window.danmakuInstance.toggle(); + const on = window.danmakuInstance.isEnabled(); + dmBtn.classList.toggle('active', on); + localStorage.setItem('danmaku', on ? 'true' : 'false'); + if (window.flashMessage) window.flashMessage(on ? 'Danmaku ON' : 'Danmaku OFF', 1800); + } else { + dmBtn.classList.toggle('active'); + const newVal = dmBtn.classList.contains('active'); + localStorage.setItem('danmaku', newVal ? 'true' : 'false'); + if (window.flashMessage) window.flashMessage(newVal ? 'Danmaku ON' : 'Danmaku OFF', 1800); + } + }); + + // Mobile: show button briefly on tap, then auto-hide + const isMobileDevice = /Mobi/i.test(navigator.userAgent) || ('ontouchstart' in window); + if (isMobileDevice) { + let dmHideTimer = null; + const showDmBtn = () => { + dmBtn.style.opacity = '1'; + dmBtn.style.pointerEvents = 'auto'; + clearTimeout(dmHideTimer); + dmHideTimer = setTimeout(() => { + dmBtn.style.opacity = ''; + dmBtn.style.pointerEvents = ''; + }, 3000); + }; + container.addEventListener('touchstart', showDmBtn, { passive: true }); + // Keep visible while interacting with the button itself + dmBtn.addEventListener('touchstart', (e) => { + e.stopPropagation(); + showDmBtn(); + }, { passive: true }); + } + } } const hud = container.querySelector('.v0ck_hud'); @@ -1379,7 +1429,7 @@ window.cancelAnimFrame = (function () { // that were applied so the main-site layout is fully restored. if (document.body.classList.contains('scroller-active')) { document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove()); - document.body.classList.remove('scroller-active'); + document.body.classList.remove('scroller-active', 'gallery-open'); // Restore body ['overflow', 'background', 'height'].forEach(p => document.body.style.removeProperty(p)); @@ -1403,6 +1453,10 @@ window.cancelAnimFrame = (function () { // Restore #main (element persists across PJAX, its inline styles must be cleared) const _m = document.getElementById('main'); if (_m) ['height', 'overflow'].forEach(p => _m.style.removeProperty(p)); + + // Stop all media + document.querySelectorAll('video, audio').forEach(el => { try { el.pause(); el.removeAttribute('src'); el.load(); } catch {} }); + document.querySelectorAll('ruffle-player').forEach(el => { try { el.remove(); } catch {} }); } // Immediately close image modal on any navigation @@ -2224,7 +2278,7 @@ window.cancelAnimFrame = (function () { if (visualizerRafId) window.cancelAnimFrame(visualizerRafId); - const media = document.querySelectorAll('video, audio'); + const media = document.querySelectorAll('video:not(#gchat-messages video), audio:not(#gchat-messages audio)'); media.forEach(m => { try { @@ -2301,6 +2355,26 @@ window.cancelAnimFrame = (function () { if (isNavigating) return; isNavigating = true; + // ── Scroller-active cleanup (same as loadPageAjax) ─────────────────── + if (document.body.classList.contains('scroller-active')) { + document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove()); + document.body.classList.remove('scroller-active', 'gallery-open'); + ['overflow', 'background', 'height'].forEach(p => document.body.style.removeProperty(p)); + const _nav = document.querySelector('nav.navbar'); + if (_nav) _nav.style.removeProperty('display'); + const _sb = document.querySelector('.global-sidebar-right'); + if (_sb) _sb.style.removeProperty('display'); + const _dz = document.getElementById('sidebar-drag-zone'); + if (_dz) _dz.style.removeProperty('display'); + const _pw = document.querySelector('.pagewrapper'); + if (_pw) ['height', 'padding', 'margin', 'overflow'].forEach(p => _pw.style.removeProperty(p)); + const _m = document.getElementById('main'); + if (_m) ['height', 'overflow'].forEach(p => _m.style.removeProperty(p)); + // Stop all media + document.querySelectorAll('video:not(#gchat-messages video), audio:not(#gchat-messages audio)').forEach(el => { try { el.pause(); el.removeAttribute('src'); el.load(); } catch {} }); + document.querySelectorAll('ruffle-player').forEach(el => { try { el.remove(); } catch {} }); + } + // Dispatch pjax:start so navigation-aware listeners (e.g. metadata modal, image modal) can react if (!options.keepMedia) { window.dispatchEvent(new Event('pjax:start')); @@ -3059,6 +3133,25 @@ window.cancelAnimFrame = (function () { const url = window.location.href; const p = window.location.pathname; + // ── Abyss handling ─────────────────────────────────────────────────── + const wasOnAbyss = document.body.classList.contains('scroller-active'); + + if (p.startsWith('/abyss')) { + if (wasOnAbyss) { + // Within-abyss back/forward — let the scroller's own popstate handler manage it + return; + } + // Coming BACK to abyss from a different page — full reload to reinitialize + window.location.reload(); + return; + } + + // Leaving abyss — stop all media + if (wasOnAbyss) { + document.querySelectorAll('video, audio').forEach(el => { try { el.pause(); el.removeAttribute('src'); el.load(); } catch {} }); + document.querySelectorAll('ruffle-player').forEach(el => { try { el.remove(); } catch {} }); + } + // Item detection logic MUST match loadPageAjax/loadItemAjax analysis // Priorities: Item first, then Special/Grid const parts = p.split('/').filter(Boolean); @@ -3458,6 +3551,11 @@ window.cancelAnimFrame = (function () { params.append('strict', '1'); } + if (ctx.notif) { + const notifTab = document.getElementById('notifications-container')?.dataset?.tab; + if (notifTab) params.append('tab', notifTab); + } + const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` : ctx.tags ? `/api/tags?ajax=true&${params.toString()}` : ctx.subs ? `/ajax/subscriptions?${params.toString()}` : @@ -3551,6 +3649,11 @@ window.cancelAnimFrame = (function () { params.append('strict', '1'); } + if (ctx.notif) { + const notifTab = document.getElementById('notifications-container')?.dataset?.tab; + if (notifTab) params.append('tab', notifTab); + } + const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` : ctx.tags ? `/api/tags?ajax=true&${params.toString()}` : ctx.subs ? `/ajax/subscriptions?${params.toString()}` : @@ -4971,6 +5074,10 @@ if (sbtForm) { // Notification System 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']; + constructor() { this.bell = document.getElementById('nav-notif-btn'); this.dropdown = document.getElementById('notif-dropdown'); @@ -4981,6 +5088,9 @@ class NotificationSystem { this.retryCount = 0; this.maxRetries = 20; // Increased retries this.pendingNotifIds = new Set(); // item IDs notified before thumbnail was in the grid + this.activeTab = 'user'; // 'user' or 'system' + this._cachedUser = []; + this._cachedSystem = []; // Generate/retrieve unique tab ID this.tabId = sessionStorage.getItem('f0ck_tab_id'); @@ -5086,6 +5196,12 @@ class NotificationSystem { } console.log("[NotificationSystem] Tab visible, signaling active..."); + // If SSE died while hidden, restart it now + if (!this.es) { + console.log("[NotificationSystem] SSE was dead, restarting on tab visible."); + this.retryCount = 0; + this.initSSE(); + } if (this.pollDebounced) this.pollDebounced(); if (this.checkForNewItems) this.checkForNewItems(); // Catch-up on emojis if they were updated while this tab was pruned/backgrounded @@ -5326,6 +5442,8 @@ class NotificationSystem { document.dispatchEvent(new CustomEvent('f0ck:global_chat_background', { detail: data.data })); } else if (data.type === 'global_chat_topic') { document.dispatchEvent(new CustomEvent('f0ck:global_chat_topic', { detail: data.data })); + } else if (data.type === 'global_chat_presence') { + document.dispatchEvent(new CustomEvent('f0ck:global_chat_presence', { detail: data.data })); } } catch (err) { console.error('SSE data parse error', err); @@ -5385,20 +5503,23 @@ class NotificationSystem { this.es = null; } - // Stop retrying if tab is inactive/hidden to save resources + // If tab is hidden, don't retry now — visibilitychange will restart SSE when visible again if (document.hidden) { - console.log("[NotificationSystem] Tab hidden, suspended SSE retries."); + console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible."); return; } - // Exponential backoff for reconnection + // Exponential backoff, capped at 30s + const delay = Math.min(Math.pow(2, this.retryCount) * 1000, 30000); if (this.retryCount < this.maxRetries) { - const delay = Math.pow(2, this.retryCount) * 1000; - console.log(`[NotificationSystem] Retrying SSE connection in ${delay}ms... (Attempt ${this.retryCount + 1}/${this.maxRetries})`); + console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`); setTimeout(() => this.initSSE(), delay); this.retryCount++; } else { - console.error("[NotificationSystem] Max SSE retries reached. Realtime updates disabled."); + // Past max retries — keep trying every 30s indefinitely, reset counter so backoff starts fresh + console.warn("[NotificationSystem] Max SSE retries reached, falling back to 30s polling."); + this.retryCount = 0; + setTimeout(() => this.initSSE(), 30000); } }; } @@ -5470,6 +5591,25 @@ class NotificationSystem { this.markAllBtn.addEventListener('click', () => this.markAllRead()); } + // Tab switching in dropdown + if (this.dropdown) { + this.dropdown.querySelectorAll('.notif-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const tabName = tab.dataset.tab; + if (tabName === this.activeTab) return; + this.activeTab = tabName; + // Update active class + this.dropdown.querySelectorAll('.notif-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + if (this.list) this.list.dataset.activeTab = tabName; + // Re-render with cached data + this._renderActiveTab(); + }); + }); + } + // Single Notification Click Handler (Delegated) // Handles both Dropdown and History Page const handleNotificationClick = (e) => { @@ -5706,30 +5846,45 @@ class NotificationSystem { updateUI(notifications) { if (!this.countBadge || !this.list) return; - const unreadCount = notifications.filter(n => !n.is_read).length; + // 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)); - if (unreadCount > 0) { - this.countBadge.textContent = unreadCount; + const userUnread = this._cachedUser.filter(n => !n.is_read).length; + const systemUnread = this._cachedSystem.filter(n => !n.is_read).length; + const totalUnread = userUnread + systemUnread; + + // Update main bell badge (total unread) + if (totalUnread > 0) { + this.countBadge.textContent = totalUnread; this.countBadge.style.display = 'block'; } else { this.countBadge.style.display = 'none'; } + + // Update per-tab badges + const userBadge = document.getElementById('notif-tab-badge-user'); + const systemBadge = document.getElementById('notif-tab-badge-system'); + if (userBadge) { + userBadge.textContent = userUnread; + userBadge.style.display = userUnread > 0 ? '' : 'none'; + } + if (systemBadge) { + systemBadge.textContent = systemUnread; + systemBadge.style.display = systemUnread > 0 ? '' : 'none'; + } + // Forward count to Abyss scroller notification badge if active if (typeof window._scrollerNotifHook === 'function') { - window._scrollerNotifHook(unreadCount); + window._scrollerNotifHook(totalUnread); } // Sync .has-notif highlights on main grid thumbnails for all unread notifications. - // This catches the case where the SSE event was missed (tab was backgrounded). - // IMPORTANT: this sweep must run BEFORE the early-return for empty notifications, - // because when all are read in another tab the server returns [] and we must still - // clear any stale .has-notif highlights that are already in the grid. const currentPath = window.location.pathname; const unreadItemIds = new Set(); notifications.forEach(n => { if (!n.is_read && n.item_id) { unreadItemIds.add(String(n.item_id)); - // Skip if the user is currently viewing this item (no need to highlight) if (currentPath === `/${n.item_id}` || currentPath === `/${n.item_id}/`) return; document.querySelectorAll(`a.thumb[href$="/${n.item_id}"], a.lazy-thumb[href$="/${n.item_id}"]`).forEach(el => { el.classList.add('has-notif'); @@ -5738,8 +5893,6 @@ class NotificationSystem { }); // Remove .has-notif from any thumb whose item is no longer in the unread set. - // /api/notifications only returns unread items, so we can't rely on n.is_read === true - // being present — instead we sweep all highlighted thumbs in the grid. document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => { const match = el.getAttribute('href')?.match(/\/(\d+)$/); if (match && !unreadItemIds.has(match[1])) { @@ -5747,26 +5900,23 @@ class NotificationSystem { } }); - if (notifications.length === 0) { - this.list.innerHTML = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`; - return; - } - - this.list.innerHTML = Sanitizer.clean(notifications.map(n => this.renderItem(n)).join('')); + // Render the active tab + this._renderActiveTab(); // Live update for History Page const historyContainer = document.querySelector('.notifications-list-full'); if (historyContainer) { - notifications.forEach(n => { + const historyTab = historyContainer.dataset.tab || 'user'; + const tabNotifs = historyTab === 'system' ? this._cachedSystem : this._cachedUser; + tabNotifs.forEach(n => { const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`); if (!existing) { console.log("[NotificationSystem] Adding new item to history:", n.id); const html = this.renderHistoryItem(n); - // Create temp container to turn string into node const temp = document.createElement('div'); temp.innerHTML = Sanitizer.clean(html); const node = temp.firstElementChild; - node.classList.add('new-item-fade'); // We can add CSS for this later if desired + node.classList.add('new-item-fade'); historyContainer.prepend(node); } else { console.log("[NotificationSystem] Item already exists:", n.id); @@ -5775,6 +5925,16 @@ class NotificationSystem { } } + _renderActiveTab() { + if (!this.list) return; + const items = this.activeTab === 'system' ? this._cachedSystem : this._cachedUser; + if (items.length === 0) { + this.list.innerHTML = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`; + return; + } + this.list.innerHTML = Sanitizer.clean(items.map(n => this.renderItem(n)).join('')); + } + renderHistoryItem(n) { let link = `/${n.item_id}`; let msg = ''; @@ -5992,6 +6152,14 @@ class NotificationSystem { markAllReadUI() { 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) { this.list.innerHTML = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`; } diff --git a/public/s/js/globalchat.js b/public/s/js/globalchat.js index 6190d6d..3432c98 100644 --- a/public/s/js/globalchat.js +++ b/public/s/js/globalchat.js @@ -24,13 +24,26 @@ const ytOembedCache = new Map(); // videoId → {title, author_name} function updateBadge() { - const badge = document.getElementById('gchat-badge'); - if (!badge) return; + const badge = document.getElementById('gchat-badge'); + const bubble = document.getElementById('gchat-reopen-bubble'); if (unreadCount > 0) { - badge.textContent = unreadCount > 99 ? '99+' : unreadCount; - badge.style.display = 'inline-flex'; + const label = unreadCount > 99 ? '99+' : String(unreadCount); + if (badge) { badge.textContent = label; badge.style.display = 'inline-flex'; } + // Bubble badge — create it lazily if it doesn't exist yet + if (bubble) { + let bb = bubble.querySelector('.gchat-bubble-badge'); + if (!bb) { + bb = document.createElement('span'); + bb.className = 'gchat-bubble-badge'; + bubble.appendChild(bb); + } + bb.textContent = label; + bb.style.display = ''; + } } else { - badge.style.display = 'none'; + if (badge) badge.style.display = 'none'; + const bb = document.getElementById('gchat-reopen-bubble')?.querySelector('.gchat-bubble-badge'); + if (bb) bb.style.display = 'none'; } } @@ -70,6 +83,7 @@ +
@@ -211,10 +225,50 @@ ``; }); - // 6e. Remaining plain https URLs (not already wrapped in a tag) → clickable link + // 6d.5 Same-site item page links → post preview card (resolved async) + // Only catches /digits paths — direct media file URLs are handled by 6a-6c & 6e. + const siteHostEsc = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const siteItemRx = new RegExp( + `https?:\/\/${siteHostEsc}\/(\\d+)(?=[\\s<"']|$)`, + 'gi' + ); + html = html.replace(siteItemRx, (match, itemId) => + `` + + `` + + `#${itemId}` + + `` + ); + + // 6e. Remaining https URLs → embed media if from allowed host, else plain link html = html.replace(/(^|[\s>])(https?:\/\/[^\s<"]+)/g, (match, pre, url) => { - // Don't double-wrap already-embedded URLs - if (match.includes(' + host === h || host.endsWith('.' + h) + ); + if (isSameSite || isMediaHost || isAllowedHoster) { + if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path)) + return `${pre}`; + if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path)) + return `${pre}`; + if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path)) + return `${pre}`; + } + } catch (_) {} + return `${pre}${url}`; }); @@ -277,6 +331,70 @@ if (authorEl) authorEl.textContent = meta.author_name || ''; } + // Resolve same-site item links → post preview card + const itemPreviewCache = new Map(); // id → { item, meta } | null + async function fetchItemPreview(wrapEl) { + const id = wrapEl.dataset.itemId; + if (!id) return; + + let cached = itemPreviewCache.get(id); + if (cached === undefined) { + try { + const [itemRes, metaRes] = await Promise.all([ + fetch(`/api/v2/item/${id}`), + fetch(`/api/v2/scroller/meta?ids=${id}`) + ]); + const itemData = await itemRes.json(); + const metaData = await metaRes.json(); + const item = (itemData.success && itemData.rows) ? itemData.rows : null; + const meta = metaData[id] || null; + cached = item ? { item, meta } : null; + } catch (_) { + cached = null; + } + itemPreviewCache.set(id, cached); + } + + if (!cached) { + // Fallback: plain link + const link = document.createElement('a'); + link.href = `/${id}`; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = `#${id}`; + wrapEl.replaceWith(link); + return; + } + + const { item, meta } = cached; + const commentCount = meta ? (meta.comment_count || 0) : 0; + const uploader = esc(item.username || 'unknown'); + const mime = item.mime || ''; + const thumbSrc = `/t/${id}.webp`; + + // Media type badge + let typeBadge = ''; + if (mime.startsWith('video/')) typeBadge = ''; + else if (mime.startsWith('audio/')) typeBadge = ''; + else if (mime.startsWith('image/')) typeBadge = ''; + + const card = document.createElement('a'); + card.className = 'gchat-post-card'; + card.href = `/${id}`; + card.innerHTML = + `` + + `#${id}` + + (typeBadge ? `${typeBadge}` : '') + + `` + + `` + + `#${id}` + + ` ${uploader}` + + ` ${commentCount}` + + ``; + + wrapEl.replaceWith(card); + } + function appendMsg(msg, scrollForce = false) { const container = document.getElementById('gchat-messages'); if (!container) return; @@ -301,6 +419,8 @@ // Wire up YouTube oEmbed cards node.querySelectorAll('.gchat-yt-card[data-yt-id]').forEach(fetchYtOembed); + // Wire up same-site item embeds + node.querySelectorAll('.gchat-item-embed[data-item-id]').forEach(fetchItemPreview); scrollToBottom(scrollForce); @@ -772,9 +892,9 @@ let _sseReady = false; function setConnecting(connecting) { - if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : ''; - if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : ''; - if (_messages) _messages.style.opacity = connecting ? '0.35' : ''; + if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '1'; + if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : ''; + if (_messages) _messages.style.opacity = connecting ? '0.35' : '1'; const sendBtn = document.getElementById('gchat-send-btn'); if (sendBtn) sendBtn.disabled = connecting; } @@ -783,12 +903,39 @@ const onSseReady = () => { if (_sseReady) return; _sseReady = true; + clearTimeout(_sseUnlockTimer); + clearInterval(_pollFallback); setConnecting(false); }; document.addEventListener('f0ck:sse_ready', onSseReady); // Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag if (document.documentElement.dataset.sseReady === '1') onSseReady(); + // Absolute fallback: if SSE hasn't connected within 10s, unlock anyway and poll + let _pollFallback = null; + const _sseUnlockTimer = setTimeout(() => { + if (_sseReady) return; + console.warn('[Chat] SSE not ready after 10s — unlocking in polling mode'); + setConnecting(false); + // Poll /api/chat every 15s as fallback so messages still appear + _pollFallback = setInterval(async () => { + if (_sseReady) { clearInterval(_pollFallback); return; } + try { + const res = await fetch('/api/chat', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + const data = await res.json(); + if (!data.success) return; + const container = document.getElementById('gchat-messages'); + if (!container) return; + const lastId = parseInt(container.dataset.lastPollId || '0', 10); + const newMsgs = (data.messages || []).filter(m => m.id > lastId); + if (newMsgs.length) { + newMsgs.forEach(m => appendMsg(m, false)); + container.dataset.lastPollId = String(Math.max(...newMsgs.map(m => m.id))); + } + } catch (_) {} + }, 15000); + }, 10000); + const textarea = document.getElementById('gchat-input'); @@ -1193,6 +1340,34 @@ applyTopic(e.detail?.topic || null); }); + // ── Online users bar ───────────────────────────────────────────────── + function renderOnline(users) { + const el = document.getElementById('gchat-online'); + if (!el) return; + if (!users || users.length === 0) { + el.innerHTML = ''; + el.style.display = 'none'; + return; + } + el.style.display = 'block'; + // Show up to 8 avatars, then "+N more" pill + const MAX_SHOWN = 8; + const shown = users.slice(0, MAX_SHOWN); + const extra = users.length - shown.length; + const avatarHTML = shown.map(u => { + const name = esc(u.display_name || u.username || '?'); + const src = avatarSrc(u); + const colorStyle = u.username_color ? `style="border-color:${esc(u.username_color)}"` : ''; + return `${name}`; + }).join(''); + const extraPill = extra > 0 ? `+${extra}` : ''; + const countLabel = `${users.length} online`; + el.innerHTML = `
${countLabel}
${avatarHTML}${extraPill}
`; + } + document.addEventListener('f0ck:global_chat_presence', (e) => { + renderOnline(e.detail?.users || []); + }); + // Event delegation: reply + admin delete buttons inside #gchat-messages const msgArea = document.getElementById('gchat-messages'); const csrf = () => window.f0ckSession?.csrf_token; diff --git a/public/s/js/messages.js b/public/s/js/messages.js index 9bda3a8..d126702 100644 --- a/public/s/js/messages.js +++ b/public/s/js/messages.js @@ -304,6 +304,7 @@ if (window.__dmLoaded) { let threadHasMore = false; const renderedIds = new Set(); let threadMessages = []; // Cache for re-rendering (e.g. emojis) + const dmPostPreviewCache = new Map(); // itemId → { item, meta } | null // Title management — global across all pages let _dmTitleCount = 0; @@ -693,9 +694,99 @@ if (window.__dmLoaded) { const time = timeAgo(m.created_at); div.innerHTML = `
${content}
${escHtml(time)}`; + + // Async: extract post IDs from raw plaintext and inject preview cards into the bubble + if (m.plaintext) resolvePostPreviews(div, m.plaintext); + return div; } + // ── Post link preview cards ─────────────────────────────────────────────── + // Extracts item IDs from the raw plaintext (immune to rendering pipeline + // variations: marked / commentSystem / plain-text fallback), then appends + // a preview card below the bubble content for each unique ID found. + async function resolvePostPreviews(msgDiv, plaintext) { + const bubble = msgDiv.querySelector('.dm-bubble'); + if (!bubble) return; + + // Match bare /12345 and full same-site URLs like https://site.com/12345 + const siteOriginEsc = window.location.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const itemRx = new RegExp( + `(?:${siteOriginEsc})?\\/(\\d+)(?=[\\s,!?\"'\\)\\]<]|$)`, + 'g' + ); + + const seen = new Set(); + let match; + while ((match = itemRx.exec(plaintext)) !== null) { + const id = match[1]; + if (!seen.has(id)) seen.add(id); + } + if (!seen.size) return; + + for (const id of seen) { + // Insert loading placeholder card below the bubble text + const placeholder = document.createElement('span'); + placeholder.className = 'dm-post-card dm-post-card--loading'; + placeholder.innerHTML = + ``+ + `#${id}`; + bubble.appendChild(placeholder); + + // Fetch item info and meta (with cache) + let cached = dmPostPreviewCache.get(id); + if (cached === undefined) { + try { + const [itemRes, metaRes] = await Promise.all([ + fetch(`/api/v2/item/${id}`), + fetch(`/api/v2/scroller/meta?ids=${id}`) + ]); + const itemData = await itemRes.json(); + const metaData = await metaRes.json(); + const item = (itemData.success && itemData.rows) ? itemData.rows : null; + const meta = metaData[id] || null; + cached = item ? { item, meta } : null; + } catch (_) { + cached = null; + } + dmPostPreviewCache.set(id, cached); + } + + if (!cached) { + placeholder.remove(); // no item found — silently drop + continue; + } + + const { item, meta } = cached; + const commentCount = meta ? (meta.comment_count || 0) : 0; + const uploader = escHtml(item.username || 'unknown'); + const mime = item.mime || ''; + const thumbSrc = `/t/${id}.webp`; + + // Media type badge + let typeBadge = ''; + if (mime.startsWith('video/')) typeBadge = ''; + else if (mime.startsWith('audio/')) typeBadge = ''; + else if (mime.startsWith('image/')) typeBadge = ''; + + const card = document.createElement('a'); + card.className = 'dm-post-card'; + card.href = `/${id}`; + card.innerHTML = + ``+ + `#${id}`+ + (typeBadge ? `${typeBadge}` : '') + + ``+ + ``+ + `#${id}`+ + ` ${uploader}`+ + ` ${commentCount}`+ + ``; + + placeholder.replaceWith(card); + } + } + let sendInFlight = false; // debounce guard against double-submit function setupDmEmojiPicker() { diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index 72c4aac..a1332b8 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -56,10 +56,22 @@ const stHideUi = document.getElementById('st-hide-ui'); const stStartUnmuted = document.getElementById('st-start-unmuted'); const stCanvasBg = document.getElementById('st-canvas-bg'); + const stLeftHand = document.getElementById('st-left-hand'); const stAutoNext = document.getElementById('st-auto-next'); const autoNextLoopsInp = document.getElementById('st-auto-next-loops'); const settingsSaveBtn = document.getElementById('settings-save-preset-btn'); const settingsPresetsList = document.getElementById('settings-presets-list'); + const externalUrlInput = document.getElementById('external-url-input'); + const externalUrlBtn = document.getElementById('external-url-btn'); + const chanOpenBtn = document.getElementById('chan-open-btn'); + const chanPanel = document.getElementById('chan-panel'); + const chanBackdrop = document.getElementById('chan-backdrop'); + const chanUrlInput = document.getElementById('chan-url-input'); + const chanUrlLoadBtn = document.getElementById('chan-url-load-btn'); + const chanGrid = document.getElementById('chan-catalog-grid'); + const chanLoading = document.getElementById('chan-catalog-loading'); + const chanGalleryBtn = document.getElementById('chan-gallery-btn'); + const chanGallerySidebar = document.getElementById('chan-gallery-sidebar'); // ── State ──────────────────────────────────────────────────────────────── const defaultMode = window.scrollerMode ?? 0; @@ -83,8 +95,9 @@ // autoNext: false by default let autoNextEnabled = prefs.autoNext === true; let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10)); + let leftHandEnabled = prefs.leftHand === true; - let applied = { mode: defaultMode, mime: '', order: 'random', tags: [] }; + let applied = { mode: defaultMode, mime: '', order: 'random', tags: [], externalUrl: null }; let pending = { ...applied, tags: [] }; // Volume / mute @@ -125,35 +138,6 @@ let emojiResults = []; // current emoji suggestion list [[code, char], ...] let acIndex = -1; // shared keyboard nav index - // Emoji shortcode map (Discord-style :shortcode:) - const EMOJI = { - // Faces - smile:'😊', grin:'😁', happy:'😄', joy:'😂', rofl:'🤣', lol:'😆', wink:'😉', - sweat:'😅', cry:'😢', sob:'😭', angry:'😡', rage:'🤬', fear:'😨', scream:'😱', - wow:'😮', huh:'😕', thinking:'🤔', nerd:'🤓', cool:'😎', tongue:'😛', kissing:'😘', - heart_eyes:'😍', sunglasses:'😎', skull:'💀', dead:'😵', clown:'🤡', ghost:'👻', - alien:'👽', robot:'🤖', devil:'😈', shrug:'🤷', facepalm:'🤦', monocle:'🧐', - blush:'😊', sleepy:'😴', smirk:'😏', unamused:'😒', expressionless:'😑', - // Hands / gestures - thumbsup: '👍', thumbsdown:'👎', clap:'👏', ok:'👌', ok_hand:'👌', wave:'👋', - muscle:'💪', point_up:'☝️', point_right:'👉', pray:'🙏', raised_hands:'🙌', - fist:'✊', peace:'✌️', vulcan:'🖖', - // Hearts / love - heart:'❤️', hearts:'💕', orange_heart:'🧡', yellow_heart:'💛', green_heart:'💚', - blue_heart:'💙', purple_heart:'💜', black_heart:'🖤', broken_heart:'💔', - sparkling_heart:'💖', heartpulse:'💗', revolving_hearts:'💞', - // Symbols / objects - fire:'🔥', star:'⭐', sparkles:'✨', boom:'💥', zzz:'💤', 100:'💯', - money:'💰', gem:'💎', trophy:'🏆', crown:'👑', rocket:'🚀', rainbow:'🌈', - poop:'💩', tada:'🎉', party:'🥳', confetti:'🎊', eyes:'👀', evil_eye:'🧿', - warning:'⚠️', check:'✅', x:'❌', no_entry:'⛔', question:'❓', exclamation:'❗', - // Food / drink - pizza:'🍕', beer:'🍺', coffee:'☕', cake:'🎂', taco:'🌮', sushi:'🍣', - // Animals - cat:'🐱', dog:'🐶', frog:'🐸', panda:'🐼', fox:'🦊', wolf:'🐺', - // Nature - sun:'☀️', moon:'🌙', cloud:'☁️', snow:'❄️', lightning:'⚡', ocean:'🌊', - }; // Session cache helpers ────────────────────────────────────────────── function readCache() { @@ -194,6 +178,17 @@ // entirely so that fetchItems() runs with ?anchor= and the correct // item is guaranteed to be included in the fresh batch. const hid = hashId(); + + // If hash is board/postid (e.g. wsg/6132740), auto-load that 4chan thread + const chanHash = hid.match(/^([a-z0-9]+)\/\d+$/); + if (chanHash) { + const board = chanHash[1]; + // We need to find the thread ID from the post. Use the board catalog or + // just load a search — but simplest: set externalUrl and skip cache restore. + // The thread ID is unknown from just the post ID, so we skip cache and + // let the init block below handle it. + return false; + } if (hid && !cache.items.some(item => String(item.id) === hid)) return false; if (cache.filters) { @@ -216,24 +211,53 @@ } function timeAgo(iso) { const s = Math.floor((Date.now() - new Date(iso)) / 1000); - if (s < 60) return 'just now'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; + const i = window.f0ckI18n || {}; + const fmt = (tpl, n) => (tpl || '{n}').replace('{n}', n); + const ago = (t) => (i.ta_ago || '{t} ago').replace('{t}', t); + if (s < 60) return i.ta_just_now || i.just_now || 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return ago(fmt(m === 1 ? i.ta_minute : i.ta_minutes, m)); + const h = Math.floor(s / 3600); + if (h < 24) return ago(fmt(h === 1 ? i.ta_hour : i.ta_hours, h)); + const d = Math.floor(s / 86400); + if (d < 7) return ago(fmt(d === 1 ? i.ta_day : i.ta_days, d)); + const w = Math.floor(d / 7); + if (d < 30) return ago(fmt(w === 1 ? i.ta_week : i.ta_weeks, w)); + const mo = Math.floor(d / 30); + if (d < 365) return ago(fmt(mo === 1 ? i.ta_month : i.ta_months, mo)); + const y = Math.floor(d / 365); + return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y)); } function hashId() { - // Strip the leading '#' and restrict to numeric IDs only — prevents CSS - // selector injection when the value is interpolated into querySelector. + // Strip the leading '#' and allow numeric IDs or board/postid format const raw = location.hash.replace(/^#/, '').trim(); - return /^\d+$/.test(raw) ? raw : ''; + if (/^\d+$/.test(raw)) return raw; + if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw; + return ''; } + let lastPushedHash = location.hash; function pushHash(id) { if (!id) return; const newHash = '#' + id; - if (location.hash !== newHash) history.replaceState({ scrollerId: id }, '', '/abyss' + newHash); + if (newHash === lastPushedHash) return; + lastPushedHash = newHash; + history.pushState({ scrollerId: id }, '', '/abyss' + newHash); updateCacheActiveId(id); } + // Handle back/forward within abyss — scroll to the target slide + window.addEventListener('popstate', (e) => { + if (!document.body.classList.contains('scroller-active')) return; + const id = e.state?.scrollerId || location.hash.replace('#', ''); + if (!id) return; + lastPushedHash = '#' + id; + const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`); + if (slide) { + slide.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + + // ── Volume / Mute ──────────────────────────────────────────────────────── function syncVolumeUI() { const icon = muteBtn.querySelector('i'); @@ -388,6 +412,37 @@ applyStartUnmuted(prefs.startUnmuted === true); applyCanvasBg(canvasBgEnabled); + // Left-hand mode + function applyLeftHand(val) { + leftHandEnabled = val; + document.body.classList.toggle('left-hand-mode', val); + if (stLeftHand) stLeftHand.classList.toggle('on', val); + // Inject / remove 4chan action buttons in every slide's scroll-actions + document.querySelectorAll('.scroll-actions .chan-action-btn').forEach(el => el.remove()); + if (val) { + document.querySelectorAll('.scroll-actions').forEach(actions => { + // Only add if 4chan buttons are visible (a 4chan feed is active) + if (chanOpenBtn && chanOpenBtn.style.display !== 'none') { + const openBtn = document.createElement('button'); + openBtn.className = 'chan-action-btn'; + openBtn.title = chanOpenBtn.title; + openBtn.innerHTML = `
`; + openBtn.addEventListener('click', () => chanOpenBtn.click()); + actions.appendChild(openBtn); + } + if (chanGalleryBtn && chanGalleryBtn.style.display !== 'none') { + const galBtn = document.createElement('button'); + galBtn.className = 'chan-action-btn'; + galBtn.title = chanGalleryBtn.title; + galBtn.innerHTML = `
`; + galBtn.addEventListener('click', () => chanGalleryBtn.click()); + actions.appendChild(galBtn); + } + }); + } + } + applyLeftHand(leftHandEnabled); + // Toggle clicks if (stHideUi) stHideUi.addEventListener('click', () => { const next = !hideUIEnabled; @@ -408,6 +463,11 @@ prefs.canvasBg = next; savePrefs(prefs); applyCanvasBg(next); }); + if (stLeftHand) stLeftHand.addEventListener('click', () => { + const next = !leftHandEnabled; + prefs.leftHand = next; savePrefs(prefs); + applyLeftHand(next); + }); // Auto-next toggle function applyAutoNext(val) { autoNextEnabled = val; @@ -460,7 +520,7 @@ const list = getPresets(); settingsPresetsList.innerHTML = ''; if (!list.length) { - settingsPresetsList.innerHTML = '
No saved presets yet.
'; + settingsPresetsList.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.no_presets) || 'No saved presets yet.'}
`; return; } const buildMeta = (p) => [ @@ -541,7 +601,7 @@ const tagInp = document.createElement('input'); tagInp.type = 'text'; tagInp.placeholder = 'Add tag\u2026'; tagInp.autocomplete = 'off'; tagInp.spellcheck = false; const addBtnEl = document.createElement('button'); - addBtnEl.textContent = 'Add'; + addBtnEl.textContent = (window.f0ckI18n && window.f0ckI18n.add) || 'Add'; addWrap.appendChild(tagInp); addWrap.appendChild(addBtnEl); const doAdd = () => { const v = tagInp.value.trim().toLowerCase().replace(/\s+/g, '_'); @@ -560,7 +620,7 @@ const overwriteBtn = document.createElement('button'); overwriteBtn.className = 'preset-editor-overwrite'; - overwriteBtn.innerHTML = 'Update & apply preset Save changes and reload feed now'; + overwriteBtn.innerHTML = `${(window.f0ckI18n && window.f0ckI18n.update_preset) || 'Update & apply preset'} ${(window.f0ckI18n && window.f0ckI18n.update_preset_sub) || 'Save changes and reload feed now'}`; overwriteBtn.addEventListener('click', ev => { ev.stopPropagation(); const presets = getPresets(); @@ -641,8 +701,10 @@ if (!slide || slide === currentSlide) return; if (currentSlide) deactivateSlide(currentSlide); currentSlide = slide; + slide._deactivated = false; slide.classList.add('active'); pushHash(slide.dataset.id); + updateGalleryActive(slide.dataset.id); const video = slide.querySelector('video'); const audio = slide.querySelector('audio'); @@ -660,10 +722,16 @@ video.currentTime = 0; } video.muted = isMuted; video.volume = volume; - // Re-register .ready listener on every activation - video.addEventListener('playing', () => video.classList.add('ready'), { once: true }); + video.classList.add('ready'); + // Force load if video hasn't started loading (preload=none for external) + if (video.readyState === 0) { + video.preload = 'auto'; + video.load(); + } else { + video.currentTime = 0; + } video.play().catch(err => { - if (err && err.name === 'NotAllowedError') { video.muted = true; video.play().catch(() => {}); } + if (err && err.name === 'NotAllowedError') { video.muted = true; isMuted = true; syncVolumeUI(); video.play().catch(() => {}); } }); wireProgress(slide, video, fill, thumb); // Start live canvas background loop (only if pref enabled) @@ -706,7 +774,7 @@ } audio.muted = isMuted; audio.volume = volume; audio.play().catch(err => { - if (err && err.name === 'NotAllowedError') { audio.muted = true; audio.play().catch(() => {}); } + if (err && err.name === 'NotAllowedError') { audio.muted = true; isMuted = true; syncVolumeUI(); audio.play().catch(() => {}); } }); if (cover) cover.classList.add('playing'); wireProgress(slide, audio, fill, thumb); @@ -774,6 +842,14 @@ // Failsafe: some Ruffle versions reset volume after WASM init setTimeout(() => { if (player.isConnected) player.volume = isMuted ? 0 : volume; }, 100); ruffleEl._rufflePlayer = player; + // If slide was deactivated while we were loading, destroy immediately + if (slide._deactivated) { + try { player.pause(); player.remove(); } catch {} + ruffleEl._rufflePlayer = null; + const ph2 = ruffleEl.querySelector('.ruffle-placeholder'); + if (ph2) ph2.style.display = 'block'; + return true; + } if (currentSlide === slide) currentMedia = player; return true; } catch (e) { console.warn('[Ruffle] init error', e); return false; } @@ -952,9 +1028,11 @@ if (iframe) iframe.removeAttribute('src'); } // Stop Ruffle player by removing it (destroys WASM instance) + slide._deactivated = true; const ruffleEl = slide.querySelector('.ruffle-container'); if (ruffleEl && ruffleEl._rufflePlayer) { try { + ruffleEl._rufflePlayer.pause(); ruffleEl._rufflePlayer.remove(); ruffleEl._rufflePlayer = null; const ph = ruffleEl.querySelector('.ruffle-placeholder'); @@ -1031,7 +1109,9 @@ async function toggleFav(slide) { if (!window.scrollerLoggedIn) return; - const id = slide.dataset.id; + const id = slide.dataset.localId || slide.dataset.id; + // External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted + if (!/^\d+$/.test(id)) { showShareToast('Can\u2019t fav external items'); return; } const favBtn = slide.querySelector('.js-fav-btn'); // Optimistic: immediately flip the button state before the server responds const wasFaved = favBtn ? favBtn.classList.contains('faved') : false; @@ -1147,6 +1227,7 @@ const slide = document.createElement('div'); slide.className = 'scroll-slide'; slide.dataset.id = item.id; + if (item.local_id) slide.dataset.localId = item.local_id; slide._thumbnail = item.thumbnail; // stored for canvas redraw + applyCanvasBg fallback const bgCanvas = document.createElement('canvas'); @@ -1247,43 +1328,80 @@
${esc(item.display_name || item.username)}
-
${esc(item.timeago)}
+
${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}
${tagsHtml} - #${item.id} + ${item.is_external && item.external_board && item.external_tid + ? `/${item.external_board}/thread/${item.external_tid}` + : (item.local_id + ? `#${item.local_id}` + : `#${item.id}`)} `; slide.appendChild(meta); // Actions (z:10) — no Filter button here, it lives in the topbar const actions = document.createElement('div'); actions.className = 'scroll-actions'; + const _i = window.f0ckI18n || {}; actions.innerHTML = ` ${window.scrollerLoggedIn ? ` - ` : ''} - ${window.scrollerLoggedIn ? ` - ` : ''} - - + ${item.is_external ? ( + item.local_id + ? ` +
+ ${_i.view_label || 'View'} +
` + : `` + ) : ` +
- Open -
`; + ${_i.open_label || 'Open'} + `} + `; slide.appendChild(actions); + // In left-hand mode, inject 4chan action buttons into this slide's actions + if (leftHandEnabled) { + if (chanOpenBtn && chanOpenBtn.style.display !== 'none') { + const ob = document.createElement('button'); + ob.className = 'chan-action-btn'; + ob.title = chanOpenBtn.title; + ob.innerHTML = `
`; + ob.addEventListener('click', () => chanOpenBtn.click()); + actions.appendChild(ob); + } + if (chanGalleryBtn && chanGalleryBtn.style.display !== 'none') { + const gb = document.createElement('button'); + gb.className = 'chan-action-btn'; + gb.title = chanGalleryBtn.title; + gb.innerHTML = `
`; + gb.addEventListener('click', () => chanGalleryBtn.click()); + actions.appendChild(gb); + } + } + // Progress bar (z:10) const pBar = document.createElement('div'); pBar.className = 'scroll-progress-bar'; pBar.innerHTML = '
'; @@ -1301,23 +1419,40 @@ toggleFav(slide); }); } - actions.querySelector('.js-comments-btn').addEventListener('click', () => openComments(item.id)); + actions.querySelector('.js-comments-btn').addEventListener('click', () => { + const slide = actions.closest('.scroll-slide'); + const id = slide?.dataset.localId || item.id; + openComments(id); + }); const tagBtn = actions.querySelector('.js-tag-btn'); - if (tagBtn) tagBtn.addEventListener('click', () => openTagBar(item.id)); + if (tagBtn) tagBtn.addEventListener('click', () => { + const slide = tagBtn.closest('.scroll-slide'); + const id = slide?.dataset.localId || item.id; + if (!/^\d+$/.test(id)) { showShareToast((window.f0ckI18n && window.f0ckI18n.add_to_site_first) || 'Add to site first to tag'); return; } + openTagBar(id); + }); const shareBtn = actions.querySelector('.js-share-btn'); if (shareBtn) shareBtn.addEventListener('click', () => { - const id = shareBtn.dataset.id; - const abyssUrl = `${location.origin}/abyss#${id}`; + const slide = shareBtn.closest('.scroll-slide'); + const localId = slide?.dataset.localId; + const id = localId || shareBtn.dataset.id; + const abyssUrl = localId + ? `${location.origin}/abyss#${localId}` + : `${location.origin}/abyss#${id}`; openSharePanel(abyssUrl); }); + const rehostBtn = actions.querySelector('.rehost-btn'); + if (rehostBtn) rehostBtn.addEventListener('click', () => rehostItem(item, rehostBtn)); // Rating cycle — always wire the click; server enforces mod auth via 403 const ratingBadge = slide.querySelector('.scroll-rating[data-item-id]'); if (ratingBadge) { - if (window.scrollerIsMod) ratingBadge.classList.add('can-cycle'); + if (window.scrollerIsMod || item.local_id) ratingBadge.classList.add('can-cycle'); ratingBadge.addEventListener('click', async e => { e.stopPropagation(); - const id = ratingBadge.dataset.itemId; + const slideEl = ratingBadge.closest('.scroll-slide'); + const id = slideEl?.dataset.localId || ratingBadge.dataset.itemId; + if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; } try { const resp = await fetch(`/api/v2/tags/${id}/cycle-rating`, { method: 'PUT', @@ -1393,20 +1528,152 @@ if (hid && seenIds.size === 0 && !lastCursor) params.set('anchor', hid); try { - const resp = await fetch(`/api/v2/scroller/feed?${params}`); + let endpoint = `/api/v2/scroller/feed?${params}`; + if (applied.externalUrl) { + const match = applied.externalUrl.match(/(?:boards\.)?4chan\.org\/([a-z0-9]+)\/thread\/(\d+)/); + if (match) { + endpoint = `/api/v2/scroller/external/4chan/${match[1]}/${match[2]}`; + } + } + const resp = await fetch(endpoint); const data = await resp.json(); - if (!data.success || !data.items || data.items.length === 0) { + let items = data.items || []; + if (applied.externalUrl && data.posts) { + if (chanGalleryBtn) chanGalleryBtn.style.display = ''; + // Load persisted rehosts from localStorage for this thread + const threadKey = `rehosted_4chan_${data.board}_${data.tid}`; + let cachedRehosts = {}; + try { cachedRehosts = JSON.parse(localStorage.getItem(threadKey) || '{}'); } catch(_) {} + // Merge server-side rehosts with local cache (server is authoritative, cache fills gaps) + const allRehosts = Object.assign({}, cachedRehosts, data.rehosts || {}); + // Persist merged map back + try { localStorage.setItem(threadKey, JSON.stringify(allRehosts)); } catch(_) {} + + items = data.posts + .filter(p => p.tim && p.ext) + .map(p => { + const ext = (p.ext || "").toLowerCase(); + const isVideo = ['.webm', '.mp4'].includes(ext); + const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext); + const isWsg = data.board === 'wsg'; + const isGif = data.board === 'gif'; + + // Respect instance-level allowed mime categories + const mimeCats = window.scrollerMimeCats || ['video', 'image', 'audio']; + if (isVideo && !mimeCats.includes('video')) return null; + if (isImage && !mimeCats.includes('image')) return null; + if (!isVideo && !isImage) return null; + + // Respect user-selected mime filter + if (applied.mime === 'video' && !isVideo) return null; + if (applied.mime === 'image' && !isImage) return null; + + const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`; + const local_id = allRehosts[external_media_url] || null; + + return { + id: `${data.board}/${p.no}`, + external_id: p.no, + external_tid: data.tid, + external_board: data.board, + external_source: '4chan', + is_external: true, + local_id, + external_media_url, + mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'), + dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`, + thumbnail: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}s.jpg`, + username: 'Anonymous', + display_name: 'Anonymous', + avatar: '/a/default.png', + stamp: p.time, + timeago: timeAgo(p.time * 1000), + tags: `4chan, /${data.board}/`, + is_video: isVideo, + is_image: isImage, + is_audio: false, + rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'), + rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged') + }; + }) + .filter(Boolean); + + // Fetch metadata for rehosted items (username, avatar, timestamp) + const rehostItemIds = items.filter(i => i.local_id).map(i => i.local_id); + if (rehostItemIds.length > 0) { + try { + const metaResp = await fetch('/api/v2/scroller/external/rehost-meta', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `ids=${rehostItemIds.join(',')}` + }); + + const metaMap = await metaResp.json(); + + items.forEach(item => { + if (item.local_id && metaMap[item.local_id]) { + const m = metaMap[item.local_id]; + + item.username = m.username; + item.display_name = m.display_name; + item.avatar = m.avatar; + if (m.rating_class) { item.rating_class = m.rating_class; item.rating_label = m.rating_label; } + + } + }); + + } catch (e) { console.warn('[CHAN] rehost-meta fetch failed:', e); } + } + + // Apply order filter client-side (API returns thread order) + if (applied.order === 'random') { + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + } else if (applied.order === 'newest') { + items.sort((a, b) => b.stamp - a.stamp); + } else if (applied.order === 'oldest') { + items.sort((a, b) => a.stamp - b.stamp); + } + + // If loading with a specific hash target, ensure it's first + const hid = hashId(); + if (hid && !currentSlide) { + const idx = items.findIndex(it => it.id === hid); + if (idx > 0) { + const [target] = items.splice(idx, 1); + items.unshift(target); + } + } + + // Store post→thread mapping so #board/postno can resolve back to the thread URL + try { + const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}'); + items.forEach(it => { postMap[it.id] = data.tid; }); + localStorage.setItem('4chan_post_threads', JSON.stringify(postMap)); + } catch(_) {} + } + + if (!data.success || items.length === 0) { consecutiveEmpty++; const noSlides = feed.querySelectorAll('.scroll-slide').length === 0; if (applied.order !== 'random') { hasMore = false; } if (noSlides) { + if (applied.externalUrl) { + alert((window.f0ckI18n && window.f0ckI18n.chan_load_failed) || 'Could not load 4chan thread. It might be archived or have no compatible media.'); + applied.externalUrl = null; + pending.externalUrl = null; + if (galleryOpen) toggleGallery(); + if (externalUrlInput) externalUrlInput.value = ''; + reloadFeed(); + return; + } + // Anchor was requested but blocked (NSFW/NSFL for guest). - // Insert a real scroll-slide with the lock message so the user can - // read it and then SWIPE DOWN manually to reach the normal content. - // No timers, no auto-advance — user is fully in control. const lockSlide = document.createElement('div'); lockSlide.className = 'scroll-slide'; lockSlide.dataset.lock = '1'; @@ -1420,8 +1687,6 @@ '', ].join(''); feed.insertBefore(lockSlide, sentinel); - // Clear hash so next fetch has no anchor, then immediately fetch real items. - // They will be appended before the sentinel so the user can swipe to them. history.replaceState(null, '', '/abyss'); consecutiveEmpty = 0; hasMore = true; @@ -1432,40 +1697,134 @@ hideLoader(); } + if (!data.success) alert(((window.f0ckI18n && window.f0ckI18n.fetch_failed) || 'Fetch failed: {msg}').replace('{msg}', data.msg || 'Unknown error')); return; // exit — no items to process } consecutiveEmpty = 0; let newCount = 0; - data.items.forEach(item => { - if (seenIds.has(item.id)) return; // client-side dedup — skip already-seen items + + items.forEach(item => { + if (seenIds.has(item.id)) return; seenIds.add(item.id); + feed.insertBefore(createSlide(item), sentinel); newCount++; }); + // If every item in the batch was a duplicate, treat it like an empty result if (newCount === 0) { consecutiveEmpty++; } + // External threads load all items at once — no pagination + if (applied.externalUrl) { hasMore = false; } if (data.nextCursor) lastCursor = data.nextCursor; - appendToCache(data.items); + appendToCache(items); if (!currentSlide) { const hid = hashId(); const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null; - // Skip lock slides (data-lock) when looking for the first real item to activate const first = feed.querySelector('.scroll-slide:not([data-lock])'); const toActivate = target || first; if (toActivate) setTimeout(() => { activateSlide(toActivate); hideLoader(); }, 200); - } else { - hideLoader(); } - } catch (e) { - console.error('[scroller] fetch error', e); - consecutiveEmpty++; + } catch (err) { + console.error('[SCROLLER] Fetch error:', err); } finally { isFetching = false; + hideLoader(); } } + async function rehostItem(item, btn) { + if (btn.classList.contains('loading') || btn.classList.contains('success')) return; + if (!window.scrollerLoggedIn) { alert((window.f0ckI18n && window.f0ckI18n.login_required) || 'You must be logged in to add items.'); return; } + + btn.classList.add('loading'); + const icon = btn.querySelector('i'); + const origClass = icon.className; + icon.className = 'fa-solid fa-spinner'; + + let rating = applied.mode === 0 ? 'sfw' : (applied.mode === 1 ? 'nsfw' : (applied.mode === 4 ? 'nsfl' : 'sfw')); + if (item.external_board === 'wsg') rating = 'sfw'; + else if (item.external_board === 'gif') rating = 'nsfw'; + + try { + const resp = await fetch('/api/v2/scroller/rehost', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'x-csrf-token': window.scrollerCsrf || '' + }, + body: new URLSearchParams({ + url: item.external_media_url || item.dest, + rating: rating, + tags: '4chan', + comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}` + }) + }); + const data = await resp.json(); + if (data.success) { + // Persist to localStorage so it survives thread reloads + if (item.external_board && item.external_media_url) { + const threadKey = `rehosted_4chan_${item.external_board}_${applied.externalUrl?.match(/(\d+)\/?$/)?.[1] || ''}` ; + try { + const cached = JSON.parse(localStorage.getItem(threadKey) || '{}'); + cached[item.external_media_url] = data.item_id; + localStorage.setItem(threadKey, JSON.stringify(cached)); + } catch(_) {} + } + + btn.classList.remove('loading'); + btn.classList.add('success'); + icon.className = data.repost ? 'fa-solid fa-link' : 'fa-solid fa-check'; + if (data.repost) showShareToast('Already on site — linked to existing item'); + // Update slide's local ID so fav/comments work immediately + const slide = btn.closest('.scroll-slide'); + if (slide) slide.dataset.localId = data.item_id; + + // Update button to link to the new site-internal post + setTimeout(() => { + btn.outerHTML = ` + +
+ View +
+ `; + }, 800); + + // Update scroll-id-link to show local ID + if (slide) { + const idLink = slide.querySelector('.scroll-id-link'); + if (idLink) { + idLink.href = `/${data.item_id}`; + idLink.textContent = `#${data.item_id}`; + } + // Reflect rehoster's username and local timestamp + if (window.scrollerUsername) { + slide.querySelectorAll('.scroll-user-link').forEach(link => { + link.href = `/user/${window.scrollerUsername}`; + }); + const nameEl = slide.querySelector('.scroll-username'); + if (nameEl) nameEl.textContent = window.scrollerDisplayName || window.scrollerUsername; + const avatarEl = slide.querySelector('.scroll-avatar'); + if (avatarEl) avatarEl.src = window.scrollerUserAvatar || '/a/default.png'; + } + const timeEl = slide.querySelector('.scroll-timeago'); + if (timeEl) timeEl.textContent = (window.f0ckI18n && window.f0ckI18n.just_now) || 'just now'; + // Enable rating cycle now that item has a local_id + const rBadge = slide.querySelector('.scroll-rating[data-item-id]'); + if (rBadge) rBadge.classList.add('can-cycle'); + } + } else { + throw new Error(data.msg); + } + } catch (e) { + btn.classList.remove('loading'); + icon.className = origClass; + alert(((window.f0ckI18n && window.f0ckI18n.rehost_failed) || 'Rehost failed: {msg}').replace('{msg}', e.message)); + } + } + + function reloadFeed() { clearCache(); // Pause previous @@ -1485,9 +1844,12 @@ } catch {} } currentSlide = null; currentMedia = null; + // Close gallery sidebar if open and hide button + if (galleryOpen) toggleGallery(); + if (chanGalleryBtn) chanGalleryBtn.style.display = 'none'; feed.querySelectorAll('.scroll-slide').forEach(s => { slideObserver.unobserve(s); s.remove(); }); emptyEl.classList.remove('show'); - emptyEl.innerHTML = '

Nothing found with current filters

'; + emptyEl.innerHTML = `

${(window.f0ckI18n && window.f0ckI18n.nothing_found) || 'Nothing found with current filters'}

`; hasMore = true; isFetching = false; lastCursor = null; consecutiveEmpty = 0; seenIds = new Set(); feed.scrollTop = 0; @@ -1508,11 +1870,20 @@ } // ── Observers ───────────────────────────────────────────────────────────── + let scrollingToRandom = null; // slide element we're scrolling to, suppresses intermediate activations const slideObserver = new IntersectionObserver((entries) => { entries.forEach(e => { // Ignore intersection changes caused by viewport resize when the // keyboard hides after panel close — would activate the wrong slide if (panelOpen) return; + // During random-scroll, only activate the target slide + if (scrollingToRandom) { + if (e.isIntersecting && e.intersectionRatio >= 0.6 && e.target === scrollingToRandom) { + scrollingToRandom = null; + activateSlide(e.target); + } + return; + } if (e.isIntersecting && e.intersectionRatio >= 0.6) activateSlide(e.target); }); }, { root: feed, threshold: 0.6 }); @@ -1545,8 +1916,65 @@ mutObs.observe(feed, { childList: true }); // ── Comments ────────────────────────────────────────────────────────────── + // ── Reply state ────────────────────────────────────────────────────────── + let replyToCommentId = null; + let replyToUsername = null; + + function setReplyTo(commentId, username) { + replyToCommentId = commentId; + replyToUsername = username; + const _i = window.f0ckI18n || {}; + const indicator = document.getElementById('reply-indicator'); + if (indicator) { + indicator.querySelector('.reply-indicator-text').textContent = + (_i.replying_to || 'Replying to {user}').replace('{user}', '@' + username); + indicator.style.display = 'flex'; + } + if (commentInput) { + commentInput.focus(); + } + } + + function clearReply() { + replyToCommentId = null; + replyToUsername = null; + const indicator = document.getElementById('reply-indicator'); + if (indicator) indicator.style.display = 'none'; + } + + const replyCancelBtn = document.getElementById('reply-cancel-btn'); + if (replyCancelBtn) replyCancelBtn.addEventListener('click', clearReply); + + function renderCommentEl(c, canReply) { + const el = document.createElement('div'); el.className = 'comment-item'; + el.dataset.commentId = c.id || ''; + const av = c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png'); + const nc = c.username_color ? `color:${esc(c.username_color)}` : ''; + const _i = window.f0ckI18n || {}; + const uname = c.display_name || c.username || 'anon'; + el.innerHTML = ` + +
+
${esc(uname)}
+
${renderCommentContent(c.content || '')}
+
+ ${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')} + ${canReply ? `` : ''} +
+
`; + // Wire reply button + const replyBtn = el.querySelector('.comment-reply-btn'); + if (replyBtn) { + replyBtn.addEventListener('click', () => { + setReplyTo(replyBtn.dataset.id, replyBtn.dataset.user); + }); + } + return el; + } + async function openComments(itemId) { commentsItemId = itemId; + clearReply(); commentsOpenLink.href = `/${itemId}`; commentsLoading.style.display = 'block'; commentsEmpty.style.display = 'none'; commentsCount.textContent = ''; commentsList.querySelectorAll('.comment-item').forEach(el => el.remove()); @@ -1568,23 +1996,11 @@ if (btn) btn.textContent = visible.length; } if (!visible.length) { commentsEmpty.style.display = 'block'; return; } - visible.forEach(c => { - const el = document.createElement('div'); el.className = 'comment-item'; - const av = c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png'); - // esc() the color value to prevent attribute-breaking XSS via username_color. - const nc = c.username_color ? `color:${esc(c.username_color)}` : ''; - el.innerHTML = ` - -
-
${esc(c.display_name || c.username || 'anon')}
-
${renderCommentContent(c.content || '')}
-
${c.created_at ? timeAgo(c.created_at) : ''}
-
`; - commentsList.appendChild(el); - }); + const canReply = !!window.scrollerLoggedIn; + visible.forEach(c => commentsList.appendChild(renderCommentEl(c, canReply))); } catch { commentsLoading.style.display = 'none'; commentsEmpty.style.display = 'block'; - commentsEmpty.innerHTML = '
Failed to load'; + commentsEmpty.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.failed_load_comments) || 'Failed to load'}`; } } @@ -1667,7 +2083,7 @@ if (!recents.length) { shareUserResults.classList.remove('show'); return; } const hdr = document.createElement('div'); hdr.className = 'share-recents-label'; - hdr.textContent = 'Recent'; + hdr.textContent = (window.f0ckI18n && window.f0ckI18n.recent) || 'Recent'; shareUserResults.appendChild(hdr); recents.forEach(u => renderShareRow(u, shareUserResults)); shareUserResults.classList.add('show'); @@ -1676,7 +2092,7 @@ function openSharePanel(url) { sharePanelUrl = url || ''; // Reset state - if (shareCopySub) shareCopySub.textContent = 'Copy to clipboard'; + if (shareCopySub) shareCopySub.textContent = (window.f0ckI18n && window.f0ckI18n.copy_clipboard) || 'Copy to clipboard'; if (shareDmSearch) shareDmSearch.classList.remove('show'); if (shareUserInput) shareUserInput.value = ''; if (shareUserResults) { shareUserResults.innerHTML = ''; shareUserResults.classList.remove('show'); } @@ -1700,8 +2116,8 @@ try { await navigator.clipboard.writeText(sharePanelUrl); if (shareCopySub) { - shareCopySub.textContent = 'Copied ✓'; - setTimeout(() => { if (shareCopySub) shareCopySub.textContent = 'Copy to clipboard'; }, 1800); + shareCopySub.textContent = (window.f0ckI18n && window.f0ckI18n.copied) || 'Copied ✓'; + setTimeout(() => { if (shareCopySub) shareCopySub.textContent = (window.f0ckI18n && window.f0ckI18n.copy_clipboard) || 'Copy to clipboard'; }, 1800); } } catch { window.prompt('Copy link:', sharePanelUrl); @@ -1923,7 +2339,7 @@ picker.appendChild(img); }); if (!Object.keys(customEmojis).length) { - picker.innerHTML = '
No custom emojis
'; + picker.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.no_custom_emojis) || 'No custom emojis'}
`; } return picker; } @@ -2083,11 +2499,10 @@ const cursor = commentInput.selectionStart; const textBefore = commentInput.value.slice(0, cursor); - // 1. :emoji: shortcode detection — custom emojis first, then static EMOJI map + // 1. :emoji: shortcode detection — custom site emojis only const emojiMatch = textBefore.match(/:([a-z0-9_]{1,})$/); if (emojiMatch) { const q = emojiMatch[1]; - // Prefer custom (site) emojis if loaded const customHits = Object.keys(customEmojis) .filter(n => n.startsWith(q)).slice(0, 12) .map(n => ['__custom__', n, customEmojis[n]]); @@ -2095,10 +2510,7 @@ renderCustomEmojiAC(customHits); return; } - // Fallback to static emoji map - const hits = Object.entries(EMOJI).filter(([code]) => code.startsWith(q)).slice(0, 12); - if (hits.length) { renderEmojiDropdown(hits); return; } - else { closeAcDropdown(); return; } + closeAcDropdown(); return; } // 2. @mention detection @@ -2157,26 +2569,33 @@ if (!content || !commentsItemId || commentsPosting) return; commentsPosting = true; commentSendBtn.disabled = true; try { + let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`; + if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`; const resp = await fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `item_id=${commentsItemId}&content=${encodeURIComponent(content)}` + body: postBody }); const data = await resp.json(); if (data.success) { if (commentInput) { commentInput.value = ''; commentInput.style.height = 'auto'; } commentSendBtn.disabled = true; commentsEmpty.style.display = 'none'; - const el = document.createElement('div'); el.className = 'comment-item'; const displayName = window.scrollerDisplayName || window.scrollerUsername || 'you'; - el.innerHTML = ` - -
-
${esc(displayName)}
-
${renderCommentContent(content)}
-
just now
-
`; + const el = renderCommentEl({ + id: data.comment?.id, + avatar_file: null, + avatar: null, + username: window.scrollerUsername, + display_name: displayName, + content: content, + created_at: null + }, !!window.scrollerLoggedIn); + // Set avatar from global + const avImg = el.querySelector('.comment-avatar'); + if (avImg) avImg.src = window.scrollerUserAvatar || '/a/default.png'; commentsList.appendChild(el); commentsList.scrollTop = commentsList.scrollHeight; + clearReply(); // Update panel header count const cur = parseInt(commentsCount.textContent.replace(/\D/g, '') || '0'); commentsCount.textContent = `(${cur + 1})`; @@ -2230,8 +2649,8 @@ async function submitTag() { if (!addTagInput || tagPosting) return; // Always tag the currently active slide, regardless of which slide opened the bar - const targetId = currentSlide?.dataset?.id || tagBarItemId; - if (!targetId) return; + const targetId = currentSlide?.dataset?.localId || currentSlide?.dataset?.id || tagBarItemId; + if (!targetId || !/^\d+$/.test(targetId)) { showShareToast('Add to site first to tag'); return; } const tag = addTagInput.value.trim(); if (!tag) return; tagPosting = true; @@ -2248,7 +2667,7 @@ addTagInput.value = ''; addTagInput.focus(); // stay focused for next tag // Apply full fresh tag list from response - const targetSlide = feed.querySelector(`.scroll-slide[data-id="${targetId}"]`); + const targetSlide = currentSlide || feed.querySelector(`.scroll-slide[data-id="${targetId}"]`); if (targetSlide) applyFreshTags(targetSlide, (data.tags || []).filter(t => t.id > 2).map(t => t.tag).join(', ')); // ✓ flash on send button if (addTagSendBtn) { @@ -2305,6 +2724,7 @@ document.querySelectorAll('#mode-pills .filter-pill').forEach(p => p.classList.toggle('active', +p.dataset.mode === pending.mode)); document.querySelectorAll('#mime-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.mime === pending.mime)); document.querySelectorAll('#order-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.order === pending.order)); + if (externalUrlInput) externalUrlInput.value = pending.externalUrl || ''; renderActiveTags(); } @@ -2321,10 +2741,66 @@ makePillListener('order-pills', 'order', v => v); filterApplyBtn.addEventListener('click', () => { applied = { ...pending, tags: [...pending.tags] }; closePanel(filterPanel, filterBackdrop); reloadFeed(); }); + + if (externalUrlBtn) { + externalUrlBtn.addEventListener('click', () => { + const val = externalUrlInput.value.trim(); + if (!val) { + applied.externalUrl = null; + } else { + if (!val.includes('boards.4chan.org')) { + alert((window.f0ckI18n && window.f0ckI18n.invalid_chan_url) || 'Please enter a valid 4chan thread URL'); + return; + } + applied.externalUrl = val; + applied.order = 'oldest'; + } + closePanel(filterPanel, filterBackdrop); + reloadFeed(); + }); + } + + // ── Auto-load 4chan thread from hash (e.g. #wsg/6132740) ──────────────── + let chanHashPending = null; // async promise if we need server lookup + { + const hid = hashId(); + const chanMatch = hid.match(/^([a-z0-9]+)\/(\d+)$/); + if (chanMatch && !applied.externalUrl) { + const board = chanMatch[1]; + const postno = chanMatch[2]; + // Look up thread ID from the post→thread mapping + let tid = null; + try { + const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}'); + tid = postMap[hid]; // hid is "board/postno" + } catch(_) {} + if (tid) { + applied.externalUrl = `https://boards.4chan.org/${board}/thread/${tid}`; + applied.order = 'oldest'; + unlock4chan(); + } else { + // No local mapping — ask the server to find the thread + chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`) + .then(r => r.json()) + .then(data => { + console.log('[CHAN] Find result:', data); + if (data.success && data.tid) { + applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`; + applied.order = 'oldest'; + unlock4chan(); + console.log('[CHAN] Set externalUrl:', applied.externalUrl); + } + }) + .catch(err => { console.error('[CHAN] Find error:', err); }); + } + } + } + filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); }); function updateFilterSummary() { - const isDef = applied.mode === defaultMode && applied.mime === '' && applied.order === 'random' && applied.tags.length === 0; + const is4chan = !!applied.externalUrl; + const isDef = applied.mode === defaultMode && applied.mime === '' && applied.order === 'random' && applied.tags.length === 0 && !is4chan; filterOpenBtn.classList.toggle('has-filter', !isDef); filterSummary.classList.toggle('show', !isDef); if (!isDef) { // Check if current filters exactly match a saved preset @@ -2335,10 +2811,11 @@ p.order === applied.order && tagsKey(p.tags) === tagsKey(applied.tags) ); - if (matchedPreset) { + if (matchedPreset && !is4chan) { filterSummary.textContent = matchedPreset.name; } else { const parts = []; + if (is4chan) parts.push('4chan'); if (applied.mode !== defaultMode) parts.push(modeLabels[applied.mode]); if (applied.mime) parts.push(applied.mime); if (applied.order !== 'random') parts.push(applied.order); @@ -2416,7 +2893,7 @@ // 'c' toggles comments — only when NOT typing (so it never fires inside the comment input) if ((e.key === 'c' || e.key === 'C') && !isTyping && !tagBar?.classList.contains('open')) { if (commentsPanel.classList.contains('open')) closeAllPanels(); - else if (currentSlide) openComments(currentSlide.dataset.id); + else if (currentSlide) openComments(currentSlide.dataset.localId || currentSlide.dataset.id); return; } @@ -2441,16 +2918,45 @@ else if (e.key === 'm') { isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); } else if (e.key === ' ') { e.preventDefault(); if (currentMedia) currentMedia.paused ? currentMedia.play().catch(() => {}) : currentMedia.pause(); } else if (e.key === 'f' || e.key === 'F') { pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); } + else if (e.key === 'g' || e.key === 'G') { if (applied.externalUrl && chanGalleryBtn) toggleGallery(); } else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); e.stopImmediatePropagation(); // prevent f0ckm.js global 'r' random shortcut from firing - // Jump to a random already-loaded slide - const slides = Array.from(feed.querySelectorAll('.scroll-slide')); - if (slides.length > 1) { - let target; - do { target = slides[Math.floor(Math.random() * slides.length)]; } while (target === currentSlide); - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } + // Fetch a truly random item from the server with current filters + (async () => { + try { + const params = new URLSearchParams({ limit: 1, mode: applied.mode, orderby: 'random' }); + if (applied.mime) params.set('mime', applied.mime); + if (applied.tags.length > 0) params.set('tag', applied.tags.join(',')); + if (seenIds.size > 0) { + const recent = [...seenIds].slice(-200); + params.set('exclude', recent.join(',')); + } + const resp = await fetch(`/api/v2/scroller/feed?${params}`); + const data = await resp.json(); + if (data.items && data.items.length > 0) { + const item = data.items[0]; + seenIds.add(item.id); + const slide = createSlide(item); + // Insert right after current slide — smooth scroll only travels 1 slide + if (currentSlide && currentSlide.nextSibling) { + feed.insertBefore(slide, currentSlide.nextSibling); + } else { + feed.appendChild(slide); + } + if (currentSlide) deactivateSlide(currentSlide); + scrollingToRandom = slide; + slide.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Safety: activate after 1.5s if observer didn't fire + setTimeout(() => { + if (scrollingToRandom === slide) { + scrollingToRandom = null; + activateSlide(slide); + } + }, 1500); + } + } catch (err) { console.warn('[scroller] random fetch failed', err); } + })(); } else if (e.key === 'p' || e.key === 'P') { e.preventDefault(); @@ -2482,6 +2988,210 @@ else if (e.key === 'Escape') { window.location.href = '/'; } }, true); // capture phase — fires before f0ckm.js bubble listeners + // ── 4chan thread loading: auto-show for mods, secret '4444' for everyone else + function unlock4chan() { + const section = document.getElementById('external-source-section'); + if (section) section.style.display = ''; + if (chanOpenBtn) chanOpenBtn.style.display = ''; + } + if (window.scrollerIsMod) { + unlock4chan(); + } else { + let fourCount = 0; + let fourTimer = null; + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key === '4') { + fourCount++; + clearTimeout(fourTimer); + fourTimer = setTimeout(() => { fourCount = 0; }, 1000); + if (fourCount >= 4) { + fourCount = 0; + unlock4chan(); + showShareToast('4chan mode unlocked 🔓'); + } + } else { + fourCount = 0; + } + }); + } + + // ── 4chan Panel ────────────────────────────────────────────────────────── + let chanCurrentBoard = 'wsg'; + let chanCatalogCache = {}; + + if (chanOpenBtn && chanPanel && chanBackdrop) { + chanOpenBtn.addEventListener('click', () => { + openPanel(chanPanel, chanBackdrop); + if (!chanCatalogCache[chanCurrentBoard]) loadCatalog(chanCurrentBoard); + }); + chanBackdrop.addEventListener('click', () => closePanel(chanPanel, chanBackdrop)); + + // Board pills + document.querySelectorAll('#chan-board-pills .filter-pill').forEach(p => { + p.addEventListener('click', () => { + document.querySelectorAll('#chan-board-pills .filter-pill').forEach(b => b.classList.remove('active')); + p.classList.add('active'); + chanCurrentBoard = p.dataset.board; + if (chanCatalogCache[chanCurrentBoard]) { + renderCatalog(chanCurrentBoard, chanCatalogCache[chanCurrentBoard]); + } else { + loadCatalog(chanCurrentBoard); + } + }); + }); + + // Custom board input + const chanCustomBoard = document.getElementById('chan-custom-board'); + const chanCustomBoardBtn = document.getElementById('chan-custom-board-btn'); + function loadCustomBoard() { + const code = chanCustomBoard?.value.trim().toLowerCase().replace(/\//g, ''); + if (!code || !/^[a-z0-9]{1,6}$/.test(code)) { showShareToast('Invalid board code'); return; } + document.querySelectorAll('#chan-board-pills .filter-pill').forEach(b => b.classList.remove('active')); + chanCustomBoardBtn.classList.add('active'); + chanCurrentBoard = code; + if (chanCatalogCache[code]) { + renderCatalog(code, chanCatalogCache[code]); + } else { + loadCatalog(code); + } + } + if (chanCustomBoardBtn) chanCustomBoardBtn.addEventListener('click', loadCustomBoard); + if (chanCustomBoard) chanCustomBoard.addEventListener('keydown', e => { if (e.key === 'Enter') loadCustomBoard(); }); + + // URL load button + if (chanUrlLoadBtn && chanUrlInput) { + chanUrlLoadBtn.addEventListener('click', () => { + const val = chanUrlInput.value.trim(); + if (!val || !val.includes('boards.4chan.org')) { + showShareToast('Invalid 4chan URL'); + return; + } + applied.externalUrl = val; + closePanel(chanPanel, chanBackdrop); + reloadFeed(); + }); + chanUrlInput.addEventListener('keydown', e => { if (e.key === 'Enter') chanUrlLoadBtn.click(); }); + } + } + + async function loadCatalog(board) { + if (chanLoading) chanLoading.style.display = ''; + if (chanGrid) chanGrid.innerHTML = ''; + try { + const resp = await fetch(`/api/v2/scroller/external/4chan/${board}/catalog`); + const data = await resp.json(); + if (data.success && data.threads) { + chanCatalogCache[board] = data.threads; + renderCatalog(board, data.threads); + } + } catch (err) { + console.error('[CHAN] Catalog error:', err); + if (chanGrid) chanGrid.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.chan_catalog_failed) || 'Failed to load catalog'}
`; + } finally { + if (chanLoading) chanLoading.style.display = 'none'; + } + } + + function renderCatalog(board, threads) { + if (!chanGrid) return; + chanGrid.innerHTML = ''; + const searchInput = document.getElementById('chan-catalog-search'); + if (searchInput) searchInput.value = ''; + + threads.forEach(t => { + const card = document.createElement('div'); + card.className = 'chan-thread-card' + (t.sticky ? ' sticky' : ''); + card.dataset.search = `${(t.sub || '')} ${(t.com || '')}`.toLowerCase(); + const thumbUrl = t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : ''; + card.innerHTML = ` + ${thumbUrl ? `` : '
'} +
+
${esc(t.sub || t.com || 'No subject')}
+ ${t.com && t.sub ? `
${esc(t.com)}
` : ''} +
+ ${t.replies} + ${t.images} + ${t.sticky ? 'Pinned' : ''} +
+
`; + card.addEventListener('click', () => { + applied.externalUrl = `https://boards.4chan.org/${board}/thread/${t.no}`; + applied.order = 'oldest'; + closePanel(chanPanel, chanBackdrop); + reloadFeed(); + }); + chanGrid.appendChild(card); + }); + } + + // Catalog search filter + const chanCatalogSearch = document.getElementById('chan-catalog-search'); + if (chanCatalogSearch) { + chanCatalogSearch.addEventListener('input', () => { + const q = chanCatalogSearch.value.toLowerCase().trim(); + if (!chanGrid) return; + chanGrid.querySelectorAll('.chan-thread-card').forEach(card => { + card.style.display = !q || card.dataset.search.includes(q) ? '' : 'none'; + }); + }); + } + + // ── Thread Gallery Sidebar ────────────────────────────────────────────── + let galleryOpen = false; + + function toggleGallery() { + galleryOpen = !galleryOpen; + if (chanGallerySidebar) chanGallerySidebar.classList.toggle('open', galleryOpen); + if (chanGalleryBtn) chanGalleryBtn.classList.toggle('active', galleryOpen); + document.body.classList.toggle('gallery-open', galleryOpen); + if (galleryOpen) populateGallery(); + } + + function populateGallery() { + if (!chanGallerySidebar) return; + chanGallerySidebar.innerHTML = ''; + const slides = feed.querySelectorAll('.scroll-slide'); + slides.forEach((slide, i) => { + const thumbSrc = slide._thumbnail || ''; + if (!thumbSrc) return; + const wrap = document.createElement('div'); + wrap.className = 'gallery-thumb-wrap'; + const img = document.createElement('img'); + img.className = 'gallery-thumb'; + img.src = thumbSrc; + img.loading = 'lazy'; + img.alt = ''; + if (slide === currentSlide) img.classList.add('active'); + img.dataset.slideId = slide.dataset.id || ''; + img.addEventListener('click', () => { + slide.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + const idx = document.createElement('span'); + idx.className = 'gallery-thumb-idx'; + idx.textContent = i + 1; + wrap.appendChild(img); + wrap.appendChild(idx); + chanGallerySidebar.appendChild(wrap); + }); + // Sync sidebar scroll to active thumbnail + const activeTh = chanGallerySidebar.querySelector('.gallery-thumb.active'); + if (activeTh) activeTh.scrollIntoView({ block: 'center' }); + } + + function updateGalleryActive(slideId) { + if (!chanGallerySidebar || !galleryOpen) return; + chanGallerySidebar.querySelectorAll('.gallery-thumb').forEach(th => { + const isActive = th.dataset.slideId === slideId; + th.classList.toggle('active', isActive); + if (isActive) th.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + } + + if (chanGalleryBtn) { + chanGalleryBtn.addEventListener('click', toggleGallery); + } + // ── Touch: let native CSS scroll-snap handle everything on mobile ───────── // (Removing custom touchstart/touchend handlers that caused jank by fighting // the browser's native momentum scrolling and snap behaviour.) @@ -2547,6 +3257,198 @@ // Also honor live hook from f0ckm.js if navigated via PJAX window._scrollerNotifHook = updateScrollerNotifBadge; + // ── Scroller Notification Dropdown ────────────────────────────────── + const sNotifBtn = document.getElementById('scroller-notif-btn'); + const sNotifDropdown = document.getElementById('scroller-notif-dropdown'); + const sNotifList = document.getElementById('scroller-notif-list'); + const sMarkAll = document.getElementById('scroller-mark-all-read'); + + // Tab type arrays + const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment']; + const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report']; + let sActiveTab = 'user'; + let sCachedNotifs = []; + + if (sNotifBtn && sNotifDropdown) { + let sNotifOpen = false; + + function positionDropdown() { + const rect = sNotifBtn.getBoundingClientRect(); + const dw = sNotifDropdown.offsetWidth || 340; + let left = rect.left + rect.width / 2 - dw / 2; + // Clamp to viewport + if (left + dw > window.innerWidth - 8) left = window.innerWidth - dw - 8; + if (left < 8) left = 8; + sNotifDropdown.style.top = (rect.bottom + 6) + 'px'; + sNotifDropdown.style.left = left + 'px'; + sNotifDropdown.style.right = 'auto'; + } + + function toggleScrollerNotifDropdown() { + sNotifOpen = !sNotifOpen; + if (sNotifOpen) { + sNotifDropdown.style.display = ''; + positionDropdown(); + sNotifDropdown.classList.add('visible'); + fetchScrollerNotifs(); + } else { + sNotifDropdown.classList.remove('visible'); + sNotifDropdown.style.display = 'none'; + } + } + + // Reposition on resize / layout shift + const ro = new ResizeObserver(() => { if (sNotifOpen) positionDropdown(); }); + ro.observe(document.getElementById('topbar') || document.body); + window.addEventListener('resize', () => { if (sNotifOpen) positionDropdown(); }); + + function isUserType(type) { return SCROLLER_USER_TYPES.includes(type); } + function isSystemType(type) { return SCROLLER_SYSTEM_TYPES.includes(type); } + + function filterByTab(notifs, tab) { + if (tab === 'user') return notifs.filter(n => isUserType(n.type)); + if (tab === 'system') return notifs.filter(n => isSystemType(n.type)); + return notifs; + } + + function updateScrollerTabBadges(notifs) { + const userUnread = notifs.filter(n => isUserType(n.type) && !n.is_read).length; + const sysUnread = notifs.filter(n => isSystemType(n.type) && !n.is_read).length; + const userBadge = document.getElementById('scroller-notif-tab-badge-user'); + const sysBadge = document.getElementById('scroller-notif-tab-badge-system'); + if (userBadge) { + userBadge.textContent = userUnread; + userBadge.style.display = userUnread > 0 ? '' : 'none'; + } + if (sysBadge) { + sysBadge.textContent = sysUnread; + sysBadge.style.display = sysUnread > 0 ? '' : 'none'; + } + } + + async function fetchScrollerNotifs() { + try { + const res = await fetch('/api/notifications'); + const data = await res.json(); + if (data.success) { + sCachedNotifs = data.notifications || []; + updateScrollerTabBadges(sCachedNotifs); + renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); + } + } catch (e) { console.warn('[SCROLLER] Notif fetch failed:', e); } + } + + function renderScrollerNotifs(notifs) { + if (!sNotifList) return; + if (!notifs.length) { + const emptyMsg = (window.f0ckI18n && window.f0ckI18n.no_notifications) || 'No new notifications'; + sNotifList.innerHTML = `
${emptyMsg}
`; + return; + } + const i18n = window.f0ckI18n || {}; + sNotifList.innerHTML = notifs.map(n => { + let link = `/${n.item_id}`, msg = '', user = n.from_display_name || n.from_user || 'System'; + if (n.type === 'approve') { + msg = i18n.notif_upload_approved || 'Your Upload has been approved'; + user = i18n.notif_system || 'System'; + } else if (n.type === 'upload_success') { + msg = i18n.notif_upload_success || 'Background upload finished'; + user = i18n.notif_system || 'System'; + } else if (n.type === 'upload_error') { + msg = i18n.notif_upload_error || 'Background upload failed'; + user = i18n.notif_system || 'System'; + link = n.item_id ? `/${n.item_id}` : '#'; + } else if (n.type === 'deny' || n.type === 'item_deleted') { + msg = n.type === 'item_deleted' + ? (i18n.notif_upload_deleted || `Upload #${n.item_id} deleted`) + : (i18n.notif_upload_denied || `Upload #${n.item_id} denied`); + user = i18n.notif_moderation || 'Moderation'; + } else if (n.type === 'admin_pending') { + link = '/mod/approve'; user = i18n.notif_admin || 'Admin'; + msg = i18n.notif_upload_pending || 'New upload needs approval'; + } else if (n.type === 'report') { + link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator'; + msg = i18n.notif_new_report || 'New user report'; + } else { + link = `/${n.item_id}#c${n.reference_id}`; + if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you'; + else if (n.type === 'subscription') msg = i18n.notif_subscribed || 'commented in a thread you follow'; + else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you'; + else msg = i18n.notif_commented || 'commented'; + } + const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`; + const thumb = n.item_id ? `
` : ''; + return ` + ${thumb} +
+
${user}
+
${msg}
+ ${new Date(n.created_at).toLocaleString()} +
+
`; + }).join(''); + } + + // Tab switching + sNotifDropdown.querySelectorAll('.notif-tab').forEach(tab => { + tab.addEventListener('click', () => { + sActiveTab = tab.dataset.tab; + sNotifDropdown.querySelectorAll('.notif-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === sActiveTab)); + if (sNotifList) sNotifList.dataset.activeTab = sActiveTab; + renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); + }); + }); + + sNotifBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleScrollerNotifDropdown(); + }); + + // Close on outside click + document.addEventListener('click', (e) => { + if (sNotifOpen && !sNotifDropdown.contains(e.target) && !sNotifBtn.contains(e.target)) { + sNotifOpen = false; + sNotifDropdown.classList.remove('visible'); + sNotifDropdown.style.display = 'none'; + } + }); + + // Mark all read + if (sMarkAll) { + sMarkAll.addEventListener('click', async () => { + try { + await fetch('/api/notifications/read', { method: 'POST' }); + updateScrollerNotifBadge(0); + sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true })); + updateScrollerTabBadges(sCachedNotifs); + renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); + } catch (e) {} + }); + } + + // Single notif click → mark read + sNotifDropdown.addEventListener('click', (e) => { + const item = e.target.closest('.notif-item'); + if (!item) return; + const nid = item.dataset.id; + if (nid && item.classList.contains('unread')) { + fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {}); + item.classList.remove('unread'); + // Update cache + const cached = sCachedNotifs.find(n => String(n.id) === String(nid)); + if (cached) cached.is_read = true; + updateScrollerTabBadges(sCachedNotifs); + const badge = document.getElementById('scroller-notif-badge'); + if (badge) { + let c = parseInt(badge.textContent) || 0; + if (c > 0) { badge.textContent = --c; if (c === 0) badge.style.display = 'none'; } + } + } + }); + } + + // Cleanup on PJAX navigation away document.addEventListener('pjax:send', () => { clearInterval(notifPollTimer); @@ -2563,6 +3465,12 @@ updateFilterSummary(); const restored = tryRestoreFromCache(); - if (!restored) fetchItems(); + if (!restored) { + if (chanHashPending) { + chanHashPending.then(() => fetchItems()); + } else { + fetchItems(); + } + } })(); diff --git a/public/s/js/theme.js b/public/s/js/theme.js index b5a3fb1..881cf41 100644 --- a/public/s/js/theme.js +++ b/public/s/js/theme.js @@ -1,20 +1,20 @@ -const Cookie = { - get: name => { - const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1]; - if (c) return decodeURIComponent(c); - }, - set: (name, value, opts = {}) => { - if (opts.days) { - opts['max-age'] = opts.days * 60 * 60 * 24; - delete opts.days; - } - opts.SameSite = 'Strict'; - opts = Object.entries(opts).reduce((accumulatedStr, [k, v]) => `${accumulatedStr}; ${k}=${v}`, ''); - document.cookie = name + '=' + encodeURIComponent(value) + opts; - } -}; - (() => { + const Cookie = { + get: name => { + const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1]; + if (c) return decodeURIComponent(c); + }, + set: (name, value, opts = {}) => { + if (opts.days) { + opts['max-age'] = opts.days * 60 * 60 * 24; + delete opts.days; + } + opts.SameSite = 'Strict'; + opts = Object.entries(opts).reduce((accumulatedStr, [k, v]) => `${accumulatedStr}; ${k}=${v}`, ''); + document.cookie = name + '=' + encodeURIComponent(value) + opts; + } + }; + const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d']; const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled'; @@ -43,8 +43,8 @@ const Cookie = { e.preventDefault(); cycleTheme(); const newTheme = document.documentElement.getAttribute('theme') || defaultTheme; - // Use scroller toast if available, otherwise site-wide flashMessage - if (typeof window._scrollerThemeToast === 'function') { + // Use scroller toast only when scroller is actually active + if (document.body.classList.contains('scroller-active') && typeof window._scrollerThemeToast === 'function') { window._scrollerThemeToast(newTheme); } else if (typeof window.flashMessage === 'function') { window.flashMessage(`Theme: ${newTheme}`, 2000); diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index b1ee573..5c2f282 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -13,10 +13,12 @@ "mod": "mod", "settings": "Einstellungen", "logout": "Abmelden", - "notifications": "Nuttis", + "notifications": "Benachrichtigungen", "mark_all_read": "Alle als gelesen markieren", - "no_notifications": "Keine neuen Nuttis", - "view_all_notifications": "Alle Nuttis anzeigen", + "no_notifications": "Keine neuen Benachrichtigungen", + "view_all_notifications": "Alle anzeigen", + "notif_tab_user": "Benutzer", + "notif_tab_system": "System", "manage_subscriptions": "Abonnements verwalten", "favorites": "Favoriten", "direct_messages": "Direktnachrichten", @@ -265,7 +267,7 @@ }, "comments": { "write_comment": "Kommentar schreiben...", - "post": "Abschnalzen", + "post": "Senden", "cancel": "Abbrechen" }, "upload_btn": { @@ -414,12 +416,13 @@ "loading": "Gespräche werden geladen…", "decrypting": "Nachrichten werden entschlüsselt…", "input_placeholder": "Nachricht schreiben…", - "send": "Abschnalzen" + "send": "Senden" }, "profile": { "message_btn": "Nachricht", "legacy_record": "Legacy-Eintrag – Erster Upload:", "joined": "Beigetreten:", + "age_days": "{n} Tage", "stat_comments": "Kommentare:", "stat_tags": "Tags:", "stat_halls": "Hallen:", @@ -486,6 +489,8 @@ "months": "{n} Monaten", "day": "{n} Tag", "days": "{n} Tagen", + "week": "{n} Woche", + "weeks": "{n} Wochen", "hour": "{n} Stunde", "hours": "{n} Stunden", "minute": "{n} Minute", @@ -552,5 +557,92 @@ "slow_down": "Langsamer!", "error_send": "Fehler beim Senden", "network_error": "Netzwerkfehler" + }, + "scroller": { + "just_now": "gerade eben", + "add": "Hinzufügen", + "update_preset": "Voreinstellung aktualisieren", + "update_preset_sub": "Änderungen speichern und Feed neu laden", + "no_presets": "Noch keine Voreinstellungen gespeichert.", + "copy_clipboard": "In Zwischenablage kopieren", + "copied": "Kopiert ✓", + "recent": "Kürzlich", + "nothing_found": "Nichts gefunden mit aktuellen Filtern", + "adjust_filters": "Filter anpassen", + "failed_load_comments": "Laden fehlgeschlagen", + "no_custom_emojis": "Keine eigenen Emojis", + "login_required": "Du musst eingeloggt sein, um Inhalte hinzuzufügen.", + "rehost_failed": "Rehost fehlgeschlagen: {msg}", + "chan_load_failed": "4chan-Thread konnte nicht geladen werden. Er ist möglicherweise archiviert oder enthält keine kompatiblen Medien.", + "fetch_failed": "Abruf fehlgeschlagen: {msg}", + "invalid_chan_url": "Bitte gib eine gültige 4chan-Thread-URL ein", + "chan_catalog_failed": "Katalog konnte nicht geladen werden", + "anonymous": "Anonym", + "back": "Zurück", + "settings": "Einstellungen", + "filters": "Filter", + "volume": "Lautstärke", + "reset_all": "Alles zurücksetzen", + "rating": "Bewertung", + "all": "Alle", + "untagged": "Ohne Tags", + "media_type": "Medientyp", + "video": "Video", + "image": "Bild", + "audio": "Audio", + "order": "Reihenfolge", + "random": "Zufall", + "newest": "Neueste", + "oldest": "Älteste", + "tags": "Tags", + "search_tags": "Tags suchen…", + "saved_presets": "Gespeicherte Voreinstellungen", + "save_preset": "Aktuelle Filter als Voreinstellung speichern", + "apply_reload": "Anwenden & Neu laden", + "chan_threads": "4chan-Fäden", + "thread_gallery": "Faden-Galerie", + "load_by_url": "Per URL laden", + "load": "Laden", + "browse_boards": "Bretter durchsuchen", + "go": "Los", + "search_threads": "Fäden suchen…", + "loading_catalog": "Katalog wird geladen…", + "appearance": "Darstellung", + "hide_ui": "UI verbergen", + "hide_ui_desc": "Blendet die Leiste und Aktionsknöpfe für volle Immersion aus", + "start_sound": "Mit Ton starten", + "start_sound_desc": "Automatisch Stummschaltung aufheben beim Öffnen", + "animated_bg": "Animierter Hintergrund", + "animated_bg_desc": "Live-Videoframes hinter dem Player; deaktivieren für statisches Vorschaubild", + "playback": "Wiedergabe", + "auto_next": "Auto-weiter", + "auto_next_desc": "Automatisch zum nächsten Inhalt wechseln, wenn das Medium endet", + "loops_before_next": "Schleifen vor Weiter", + "loops_before_next_desc": "Wie oft abspielen, bevor weitergeschaltet wird (Videos & Audio)", + "comments": "Kommentare", + "open": "Öffnen", + "loading": "Laden…", + "no_comments": "Noch keine Kommentare", + "write_comment": "Kommentar schreiben...", + "add_tag_placeholder": "Tag zu diesem Inhalt hinzufügen…", + "add_tag": "Tag hinzufügen", + "close": "Schließen", + "share": "Teilen", + "copy_link": "Link kopieren", + "send_dm": "Per DM senden", + "share_inbox": "An den Posteingang eines Benutzers teilen", + "search_user": "Benutzer suchen…", + "login_to_comment": "Einloggen zum Kommentieren", + "doomscroll": "Dunkelscrollen", + "favourite": "Favorit", + "view": "Ansehen", + "open_post": "Beitrag öffnen", + "already_added": "Bereits hinzugefügt", + "add_to_site": "Zur Seite hinzufügen", + "add_to_site_first": "Erst zur Seite hinzufügen, um Tags zu vergeben", + "left_hand": "Linkshändermodus", + "left_hand_desc": "Du weißt bescheid.", + "replying_to": "Antwort an {user}", + "reply": "Antworten" } } diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index 7ba724d..b7b430e 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -17,6 +17,8 @@ "mark_all_read": "Mark all read", "no_notifications": "No new notifications", "view_all_notifications": "View all notifications", + "notif_tab_user": "User", + "notif_tab_system": "System", "manage_subscriptions": "manage subscriptions", "favorites": "Favorites", "direct_messages": "Direct Messages", @@ -424,6 +426,7 @@ "message_btn": "✉ Message", "legacy_record": "Legacy Record – First Upload:", "joined": "Joined:", + "age_days": "{n} days", "stat_comments": "Comments:", "stat_tags": "Tags:", "stat_halls": "Halls:", @@ -490,6 +493,8 @@ "months": "{n} months", "day": "{n} day", "days": "{n} days", + "week": "{n} week", + "weeks": "{n} weeks", "hour": "{n} hour", "hours": "{n} hours", "minute": "{n} minute", @@ -554,5 +559,92 @@ "slow_down": "Slow down!", "error_send": "Error sending", "network_error": "Network error" + }, + "scroller": { + "just_now": "just now", + "add": "Add", + "update_preset": "Update & apply preset", + "update_preset_sub": "Save changes and reload feed now", + "no_presets": "No saved presets yet.", + "copy_clipboard": "Copy to clipboard", + "copied": "Copied ✓", + "recent": "Recent", + "nothing_found": "Nothing found with current filters", + "adjust_filters": "Adjust filters", + "failed_load_comments": "Failed to load", + "no_custom_emojis": "No custom emojis", + "login_required": "You must be logged in to add items.", + "rehost_failed": "Rehost failed: {msg}", + "chan_load_failed": "Could not load 4chan thread. It might be archived or have no compatible media.", + "fetch_failed": "Fetch failed: {msg}", + "invalid_chan_url": "Please enter a valid 4chan thread URL", + "chan_catalog_failed": "Failed to load catalog", + "anonymous": "Anonymous", + "back": "Back", + "settings": "Settings", + "filters": "Filters", + "volume": "Volume", + "reset_all": "Reset all", + "rating": "Rating", + "all": "All", + "untagged": "Untagged", + "media_type": "Media type", + "video": "Video", + "image": "Image", + "audio": "Audio", + "order": "Order", + "random": "Random", + "newest": "Newest", + "oldest": "Oldest", + "tags": "Tags", + "search_tags": "Search tags…", + "saved_presets": "Saved presets", + "save_preset": "Save current filters as preset", + "apply_reload": "Apply & Reload", + "chan_threads": "4chan Threads", + "thread_gallery": "Thread Gallery", + "load_by_url": "Load by URL", + "load": "Load", + "browse_boards": "Browse Boards", + "go": "Go", + "search_threads": "Search threads…", + "loading_catalog": "Loading catalog…", + "appearance": "Appearance", + "hide_ui": "Hide UI", + "hide_ui_desc": "Hides the top bar and action buttons for full immersion", + "start_sound": "Start with sound", + "start_sound_desc": "Automatically unmute when you open the scroller", + "animated_bg": "Animated background", + "animated_bg_desc": "Live video frames behind the player; disable for static thumbnail", + "playback": "Playback", + "auto_next": "Auto-next", + "auto_next_desc": "Automatically advance to the next item when media ends", + "loops_before_next": "Loops before next", + "loops_before_next_desc": "How many times to play before advancing (videos & audio)", + "comments": "Comments", + "open": "Open", + "loading": "Loading…", + "no_comments": "No comments yet", + "write_comment": "Write a comment...", + "add_tag_placeholder": "Add a tag to this item…", + "add_tag": "Add tag", + "close": "Close", + "share": "Share", + "copy_link": "Copy link", + "send_dm": "Send via DM", + "share_inbox": "Share to a user's inbox", + "search_user": "Search for a user…", + "login_to_comment": "Log in to comment", + "doomscroll": "doomscroll", + "favourite": "Favourite", + "view": "View", + "open_post": "Open post", + "already_added": "Already added", + "add_to_site": "Add to site", + "add_to_site_first": "Add to site first to tag", + "left_hand": "Left hand mode", + "left_hand_desc": "You know why.", + "replying_to": "Replying to {user}", + "reply": "Reply" } } diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index 28f3a48..eed3719 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -17,6 +17,8 @@ "mark_all_read": "Alles als gelezen markeren", "no_notifications": "Geen nieuwe meldingen", "view_all_notifications": "Alle meldingen bekijken", + "notif_tab_user": "Gebruiker", + "notif_tab_system": "Systeem", "manage_subscriptions": "abonnementen beheren", "favorites": "Favorieten", "direct_messages": "Directe Berichten", @@ -420,6 +422,7 @@ "message_btn": "✉ bericht", "legacy_record": "Legacy Record – Eerste Upload:", "joined": "Lid geworden:", + "age_days": "{n} dagen", "stat_comments": "Opmerkingen:", "stat_tags": "Tags:", "stat_halls": "Hallen:", @@ -486,6 +489,8 @@ "months": "{n} maanden", "day": "{n} dag", "days": "{n} dagen", + "week": "{n} week", + "weeks": "{n} weken", "hour": "{n} uur", "hours": "{n} uur", "minute": "{n} minuut", @@ -550,5 +555,92 @@ "slow_down": "Langzamer!", "error_send": "Versturen mislukt", "network_error": "Netwerkfout" + }, + "scroller": { + "just_now": "zojuist", + "add": "Toevoegen", + "update_preset": "Voorinstelling bijwerken", + "update_preset_sub": "Wijzigingen opslaan en feed herladen", + "no_presets": "Nog geen opgeslagen voorinstellingen.", + "copy_clipboard": "Kopiëren naar klembord", + "copied": "Gekopieerd ✓", + "recent": "Recent", + "nothing_found": "Niets gevonden met huidige filters", + "adjust_filters": "Filters aanpassen", + "failed_load_comments": "Laden mislukt", + "no_custom_emojis": "Geen aangepaste emoji's", + "login_required": "Je moet ingelogd zijn om items toe te voegen.", + "rehost_failed": "Rehost mislukt: {msg}", + "chan_load_failed": "4chan-thread kon niet worden geladen. Het is mogelijk gearchiveerd of bevat geen compatibele media.", + "fetch_failed": "Ophalen mislukt: {msg}", + "invalid_chan_url": "Voer een geldige 4chan-thread-URL in", + "chan_catalog_failed": "Catalogus kon niet worden geladen", + "anonymous": "Anoniem", + "back": "Terug", + "settings": "Instellingen", + "filters": "Filters", + "volume": "Volume", + "reset_all": "Alles resetten", + "rating": "Beoordeling", + "all": "Alles", + "untagged": "Zonder tags", + "media_type": "Mediatype", + "video": "Video", + "image": "Afbeelding", + "audio": "Audio", + "order": "Volgorde", + "random": "Willekeurig", + "newest": "Nieuwste", + "oldest": "Oudste", + "tags": "Tags", + "search_tags": "Tags zoeken…", + "saved_presets": "Opgeslagen voorinstellingen", + "save_preset": "Huidige filters opslaan als voorinstelling", + "apply_reload": "Toepassen & Herladen", + "chan_threads": "4chan-threads", + "thread_gallery": "Thread-galerij", + "load_by_url": "Laden via URL", + "load": "Laden", + "browse_boards": "Borden doorbladeren", + "go": "Ga", + "search_threads": "Threads zoeken…", + "loading_catalog": "Catalogus laden…", + "appearance": "Uiterlijk", + "hide_ui": "UI verbergen", + "hide_ui_desc": "Verbergt de bovenste balk en actieknoppen voor volledige onderdompeling", + "start_sound": "Met geluid starten", + "start_sound_desc": "Automatisch demping opheffen bij openen", + "animated_bg": "Geanimeerde achtergrond", + "animated_bg_desc": "Live videoframes achter de speler; uitschakelen voor statische thumbnail", + "playback": "Afspelen", + "auto_next": "Auto-volgende", + "auto_next_desc": "Automatisch naar het volgende item gaan wanneer media eindigt", + "loops_before_next": "Lussen voor volgende", + "loops_before_next_desc": "Hoe vaak afspelen voordat wordt doorgegaan (video's & audio)", + "comments": "Opmerkingen", + "open": "Openen", + "loading": "Laden…", + "no_comments": "Nog geen opmerkingen", + "write_comment": "Schrijf een opmerking...", + "add_tag_placeholder": "Tag toevoegen aan dit item…", + "add_tag": "Tag toevoegen", + "close": "Sluiten", + "share": "Delen", + "copy_link": "Link kopiëren", + "send_dm": "Via DM versturen", + "share_inbox": "Delen naar de inbox van een gebruiker", + "search_user": "Zoek een gebruiker…", + "login_to_comment": "Inloggen om te reageren", + "doomscroll": "doomscroll", + "favourite": "Favoriet", + "view": "Bekijken", + "open_post": "Bericht openen", + "already_added": "Al toegevoegd", + "add_to_site": "Toevoegen aan site", + "add_to_site_first": "Eerst toevoegen aan site om tags te plaatsen", + "left_hand": "Linkshandige modus", + "left_hand_desc": "Je weet wel waarom.", + "replying_to": "Antwoord aan {user}", + "reply": "Antwoorden" } } diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index d93197d..5dbd7d3 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -17,6 +17,8 @@ "mark_all_read": "Alle als gelesen markieren", "no_notifications": "Keine neuen Hinweise", "view_all_notifications": "Alle Hinweise betrachten", + "notif_tab_user": "Benutzer", + "notif_tab_system": "System", "manage_subscriptions": "Abonnements verwalten", "favorites": "Favoriten", "direct_messages": "Direktnachrichten", @@ -387,7 +389,7 @@ }, "ranking": { "title": "Rangliste", - "top_contributors": "Top-Beitragende", + "top_contributors": "Top Etikettierer", "col_rank": "Rang", "col_avatar": "Profilbild", "col_username": "Benutzername", @@ -424,6 +426,7 @@ "message_btn": "Nachricht", "legacy_record": "Veralteter Datensatz – Erste Aufladierung:", "joined": "Beigetreten:", + "age_days": "{n} Tage", "stat_comments": "Kommentare:", "stat_tags": "Etiketten:", "stat_halls": "Hallen:", @@ -485,11 +488,13 @@ "timeago": { "just_now": "gerade eben", "year": "{n} Jahr", - "years": "{n} Jahre", + "years": "{n} Jahren", "month": "{n} Monat", - "months": "{n} Monate", + "months": "{n} Monaten", "day": "{n} Tag", - "days": "{n} Tage", + "days": "{n} Tagen", + "week": "{n} Woche", + "weeks": "{n} Wochen", "hour": "{n} Stunde", "hours": "{n} Stunden", "minute": "{n} Minute", @@ -556,5 +561,92 @@ "slow_down": "Gemach, gemach!", "error_send": "Sendung fehlgeschlagen", "network_error": "Netzwerkfehler" + }, + "scroller": { + "just_now": "gerade eben", + "add": "Hinzufügen", + "update_preset": "Voreinstellung aktualisieren", + "update_preset_sub": "Änderungen speichern und Futterstrom neu laden", + "no_presets": "Noch keine Voreinstellungen gespeichert.", + "copy_clipboard": "In die Zwischenablage kopieren", + "copied": "Kopiert ✓", + "recent": "Kürzlich", + "nothing_found": "Nichts gefunden mit aktuellen Filtern", + "adjust_filters": "Filter anpassen", + "failed_load_comments": "Ladung fehlgeschlagen", + "no_custom_emojis": "Keine benutzerdefinierten Emojis", + "login_required": "Sie müssen angemeldet sein, um Elemente hinzuzufügen.", + "rehost_failed": "Umladierung fehlgeschlagen: {msg}", + "chan_load_failed": "Der Vierkanal-Faden konnte nicht geladen werden. Er ist möglicherweise archiviert oder enthält keine kompatiblen Medien.", + "fetch_failed": "Abruf fehlgeschlagen: {msg}", + "invalid_chan_url": "Bitte geben Sie eine gültige Vierkanal-Faden-Elfe ein", + "chan_catalog_failed": "Katalog konnte nicht geladen werden", + "anonymous": "Anonym", + "back": "Zurück", + "settings": "Einstellungen", + "filters": "Filter", + "volume": "Lautstärke", + "reset_all": "Alles zurücksetzen", + "rating": "Bewertung", + "all": "Alle", + "untagged": "Ohne Etiketten", + "media_type": "Medientyp", + "video": "Video", + "image": "Lichtbild", + "audio": "Tondatei", + "order": "Reihenfolge", + "random": "Zufall", + "newest": "Neueste", + "oldest": "Älteste", + "tags": "Etiketten", + "search_tags": "Etiketten suchen…", + "saved_presets": "Gespeicherte Voreinstellungen", + "save_preset": "Aktuelle Filter als Voreinstellung speichern", + "apply_reload": "Anwenden & Neu laden", + "chan_threads": "Vierkanal-Fäden", + "thread_gallery": "Faden-Galerie", + "load_by_url": "Per Elfe laden", + "load": "Laden", + "browse_boards": "Bretter durchstöbern", + "go": "Los", + "search_threads": "Fäden durchsuchen…", + "loading_catalog": "Katalog wird geladen…", + "appearance": "Darstellung", + "hide_ui": "Oberfläche verbergen", + "hide_ui_desc": "Verbirgt die Leiste und Aktionsknöpfe für volle Versenkung", + "start_sound": "Mit Ton beginnen", + "start_sound_desc": "Automatisch Stummschaltung aufheben beim Öffnen des Scrollers", + "animated_bg": "Belebter Hintergrund", + "animated_bg_desc": "Lebendige Videoframes hinter dem Abspieler; deaktivieren für ruhendes Vorschaubild", + "playback": "Wiedergabe", + "auto_next": "Selbsttätiges Weiter", + "auto_next_desc": "Selbsttätig zum nächsten Inhalt wechseln, wenn das Medium endet", + "loops_before_next": "Schleifen vor Weiter", + "loops_before_next_desc": "Wie oft abspielen, bevor weitergeschaltet wird (Videos & Tondateien)", + "comments": "Kommentare", + "open": "Öffnen", + "loading": "Ladung wird aufbereitet…", + "no_comments": "Noch keine Kommentare vorhanden", + "write_comment": "Schreiben Sie doch einen Kommentar...", + "add_tag_placeholder": "Etikett zu diesem Inhalt hinzufügen…", + "add_tag": "Etikett hinzufügen", + "close": "Schließen", + "share": "Teilen", + "copy_link": "Verknüpfung kopieren", + "send_dm": "Per Direktnachricht senden", + "share_inbox": "An den Posteingang eines Benutzers teilen", + "search_user": "Benutzer suchen…", + "login_to_comment": "Anmelden zum Kommentieren", + "doomscroll": "Verderbensrolle", + "favourite": "Favorit", + "view": "Ansehen", + "open_post": "Pfosten öffnen", + "already_added": "Bereits hinzugefügt", + "add_to_site": "Zur Weltnetzpräsenz hinzufügen", + "add_to_site_first": "Erst zur Weltnetzpräsenz hinzufügen, um Etiketten zu vergeben", + "left_hand": "Linkshändermodus", + "left_hand_desc": "Sie wissen schon wieso.", + "replying_to": "Antwort an {user}", + "reply": "Antworten" } } diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index f4dc088..97b1d3b 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -857,6 +857,60 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1); } }); + router.post(/^\/api\/v2\/admin\/users\/reassign-uploads\/?$/, lib.auth, async (req, res) => { + try { + const { source_user_id, source_username, target_username } = req.post; + if (!source_user_id && !source_username) throw new Error('Missing source_user_id or source_username'); + if (!target_username || !target_username.trim()) throw new Error('Missing target_username'); + + // Resolve source user (registered or ghost) + let sourceLogin, sourceUser; + if (source_user_id) { + const source = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+source_user_id} LIMIT 1`; + if (!source.length) throw new Error('Source user not found'); + if (source[0].login === 'deleted_user') throw new Error('Cannot reassign uploads from the protected deleted_user account.'); + sourceLogin = source[0].login; + sourceUser = source[0].user; + } else { + // Ghost/legacy user — just use the username directly + sourceLogin = source_username.trim(); + sourceUser = source_username.trim(); + } + + // Resolve target user + const target = await db`SELECT id, login, "user" FROM "user" WHERE login ILIKE ${target_username.trim()} LIMIT 1`; + if (!target.length) throw new Error('Target user "' + target_username.trim() + '" not found'); + + const targetLogin = target[0].login; + const targetId = target[0].id; + + if (source_user_id && +source_user_id === targetId) throw new Error('Source and target user are the same.'); + + // Reassign all items + const result = await db` + UPDATE items + SET username = ${targetLogin} + WHERE username ILIKE ${sourceLogin} OR username ILIKE ${sourceUser} + `; + + // Log in audit + await audit.log(req.session.id, 'admin_reassign_uploads', 'user', source_user_id ? +source_user_id : null, { + source_login: sourceLogin, + target_login: targetLogin, + target_id: targetId, + count: result.count + }); + + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: true, + count: result.count, + msg: `Successfully reassigned ${result.count} uploads from ${sourceLogin} to ${targetLogin}.` + })); + } catch (err) { + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); + } + }); + router.post(/^\/api\/v2\/admin\/users\/bulk-delete-items\/?$/, lib.auth, async (req, res) => { try { const { user_id, username } = req.post; diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 95bde0a..dee59cf 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -294,6 +294,43 @@ export default router => { // Background processing block (async () => { + const sanitizeError = (err) => { + if (!err) return `Failed to process ${url}`; + + // Priority 1: meaningful error from stderr (yt-dlp/curl/etc) + if (err.stderr) { + const stderr = String(err.stderr).trim(); + + // yt-dlp specific patterns + const errorMatch = stderr.match(/ERROR:\s*(.+)$/m); + if (errorMatch) return errorMatch[1].trim(); + + // curl specific patterns + if (stderr.startsWith('curl: ')) return stderr; + + // Fallback to last meaningful line of stderr + const lines = stderr.split('\n').map(l => l.trim()).filter(l => l && !l.includes('WARNING:')); + if (lines.length > 0) return lines[lines.length - 1]; + } + + const msg = String(err.message || ''); + + // Priority 2: Extract HTTP codes + const httpCode = msg.match(/HTTP Error (\d+)/i)?.[1] + || msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] + || null; + if (httpCode) return `Download/Process failed (HTTP ${httpCode})`; + + // Priority 3: Sanitize raw queue.spawn errors + if (msg.startsWith('Command \'')) { + const match = msg.match(/failed with code (\d+)/); + const code = match ? match[1] : '1'; + return `Process failed (code ${code})`; + } + + return msg || `Failed to process ${url}`; + }; + try { const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : []; const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web']; @@ -303,16 +340,7 @@ export default router => { const uuid = await queue.genuuid(); const isInstagram = /instagram\.com/i.test(url); - const dlError = (err) => { - if (!err) return `Failed to download from ${url}`; - const errStr = String(err.stderr || err.message || ''); - const httpCode = errStr.match(/HTTP Error (\d+)/i)?.[1] - || errStr.match(/\b(4\d{2}|5\d{2})\b/)?.[1] - || null; - if (httpCode) return `Failed to download from ${url} (HTTP ${httpCode})`; - if (err.code != null) return `Failed to download from ${url} (code ${err.code})`; - return `Failed to download from ${url}`; - }; + const dlError = (err) => sanitizeError(err); let source; console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`); @@ -330,7 +358,7 @@ export default router => { ])).stdout.trim(); } catch (err) { console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`); - if (isInstagram) throw err; + if (isInstagram) throw new Error(sanitizeError(err)); try { source = (await queue.spawn('yt-dlp', [ @@ -365,7 +393,11 @@ export default router => { const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; curlArgs.push('--socks5-hostname', proxyHost); } - await queue.spawn('curl', curlArgs); + try { + await queue.spawn('curl', curlArgs); + } catch (err) { + throw new Error(sanitizeError(err)); + } const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim(); const extension = cfg.mimes[fallbackMime]; @@ -549,7 +581,7 @@ export default router => { } catch (err) { console.error('[UPLOAD-URL-ASYNC] Final Error:', err); // Error notification - await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: err.message })})`; + await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: sanitizeError(err) })})`; } })(); } diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs new file mode 100644 index 0000000..0de360a --- /dev/null +++ b/src/inc/routes/external.mjs @@ -0,0 +1,467 @@ +import cfg from "../config.mjs"; +import db from "../sql.mjs"; +import lib from "../lib.mjs"; +import queue from "../queue.mjs"; +import { promises as fs } from "fs"; +import path from "path"; +import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs"; + +/** + * external.mjs — External source handlers (4chan threads, etc.) + */ +export default (router) => { + + /** + * Helper to fetch data (JSON or Buffer) using curl if a proxy is configured. + * This ensures we respect the SOCKS5 proxy for all external 4chan requests. + */ + async function fetchWithProxy(url, asBuffer = false) { + const curlArgs = [ + '-s', '-f', '-L', + '-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + '--max-time', '30', + url + ]; + if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { + const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; + curlArgs.push('--socks5-hostname', proxyHost); + } + + const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' }); + if (asBuffer) return stdout; + const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim(); + if (!text.startsWith('{') && !text.startsWith('[')) { + console.error('[EXTERNAL] Non-JSON response from', url, '— first 200 chars:', text.slice(0, 200)); + throw new Error('Expected JSON but got non-JSON response'); + } + return JSON.parse(text); + } + + // GET /api/v2/scroller/external/4chan/:board/:tid + // Proxies 4chan thread JSON + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/(?\d+)\/?$/, async (req, res) => { + const { board, tid } = req.params || {}; + + if (!board || !tid) { + console.error('[EXTERNAL] Missing board or tid:', req.params); + return res.reply({ code: 400, body: JSON.stringify({ success: false, error: 'invalid_parameters' }) }); + } + + try { + const url = `https://a.4cdn.org/${board}/thread/${tid}.json`; + console.log(`[EXTERNAL] Fetching 4chan thread: ${url}`); + + const data = await fetchWithProxy(url); + const posts = data.posts || []; + + // Check which media URLs are already rehosted on this platform + const rehosts = {}; + const mediaPosts = posts.filter(p => p.tim && p.ext); + const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`); + if (cdn4Urls.length > 0) { + try { + const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`; + rows.forEach(r => { rehosts[r.src] = r.id; }); + } catch (e) { + console.error('[EXTERNAL] DB src check error:', e.message); + } + } + + return res.reply({ + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + body: JSON.stringify({ success: true, posts, board, tid, rehosts }) + }); + + } catch (err) { + console.error('[EXTERNAL] 4chan fetch error:', err.message); + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'fetch_failed' }) + }); + } + }); + + // POST /api/v2/scroller/external/rehost-meta + // Given item IDs, return their metadata (username, avatar, timestamp) + router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, async (req, res) => { + const ids = (req.post?.ids || '').split(',').map(Number).filter(n => n > 0); + if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: '{}' }); + + try { + const ratingTagIds = [1, 2, cfg.nsfl_tag_id || 3]; + const rows = await db` + SELECT i.id, i.username, i.stamp, + COALESCE(uo.display_name, i.username) as display_name, + uo.avatar_file, uo.avatar, + (SELECT ta.tag_id FROM tags_assign ta + WHERE ta.item_id = i.id AND ta.tag_id = ANY(${ratingTagIds}::int[]) + ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id + FROM items i + LEFT JOIN "user" u ON u."user" = i.username + LEFT JOIN user_options uo ON uo.user_id = u.id + WHERE i.id = ANY(${ids}::int[])`; + const meta = {}; + rows.forEach(r => { + let rating_label = '?', rating_class = 'untagged'; + if (r.rating_tag_id == 1) { rating_label = 'SFW'; rating_class = 'sfw'; } + else if (r.rating_tag_id == 2) { rating_label = 'NSFW'; rating_class = 'nsfw'; } + else if (r.rating_tag_id == (cfg.nsfl_tag_id || 3)) { rating_label = 'NSFL'; rating_class = 'nsfl'; } + meta[r.id] = { + username: r.username, + display_name: r.display_name, + avatar: r.avatar_file ? `/a/${r.avatar_file}` : (r.avatar ? `/t/${r.avatar}.webp` : '/a/default.png'), + stamp: r.stamp, + rating_class, + rating_label + }; + }); + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(meta) + }); + } catch (e) { + console.error('[EXTERNAL] rehost-meta error:', e.message); + return res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: '{}' }); + } + }); + + // GET /api/v2/scroller/external/4chan/:board/catalog + // Proxies 4chan board catalog JSON + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/catalog\/?$/, async (req, res) => { + const { board } = req.params || {}; + if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) }); + + try { + const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`); + const threads = []; + for (const page of pages) { + for (const t of (page.threads || [])) { + threads.push({ + no: t.no, + sub: t.sub || '', + com: (t.com || '').replace(/<[^>]+>/g, '').slice(0, 120), + replies: t.replies || 0, + images: t.images || 0, + tim: t.tim, + ext: t.ext, + sticky: t.sticky || 0 + }); + } + } + return res.reply({ + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' }, + body: JSON.stringify({ success: true, board, threads }) + }); + } catch (err) { + console.error('[EXTERNAL] Catalog fetch error:', err.message); + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'catalog_fetch_failed' }) + }); + } + }); + + // GET /api/v2/scroller/external/4chan/:board/find/:postno + // Resolves a post number to its parent thread ID + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/find\/(?\d+)\/?$/, async (req, res) => { + const { board, postno } = req.params || {}; + if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) }); + + try { + // 1) Try as thread OP — if postno IS the thread, this returns 200 + try { + const thread = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${postno}.json`); + if (thread && thread.posts) { + return res.reply({ + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' }, + body: JSON.stringify({ success: true, tid: Number(postno), board }) + }); + } + } catch (_) { /* 404 — post is not an OP, continue searching */ } + + // 2) Search catalog's last_replies for the post + const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`); + for (const page of pages) { + for (const t of (page.threads || [])) { + // Check OP + if (t.no === Number(postno)) { + return res.reply({ + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' }, + body: JSON.stringify({ success: true, tid: t.no, board }) + }); + } + // Check last_replies + if (t.last_replies) { + for (const r of t.last_replies) { + if (r.no === Number(postno)) { + return res.reply({ + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' }, + body: JSON.stringify({ success: true, tid: t.no, board }) + }); + } + } + } + } + } + + // Not found + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'post_not_found' }) + }); + } catch (err) { + console.error('[EXTERNAL] Find post error:', err.message); + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'find_failed' }) + }); + } + }); + + // GET /api/v2/scroller/external/4chan/:board/media/:file + // Proxies 4chan media — streams directly to client for fast playback start + router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/media\/(?[^/]+)$/, async (req, res) => { + const { board, file } = req.params || {}; + const url = `https://i.4cdn.org/${board}/${file}`; + + const ext = file.split('.').pop(); + const mimes = { + 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', + 'gif': 'image/gif', 'webp': 'image/webp', + 'webm': 'video/webm', 'mp4': 'video/mp4' + }; + const contentType = mimes[ext] || 'application/octet-stream'; + + const curlArgs = [ + '-s', '-f', '-L', + '-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + '--max-time', '60', + url + ]; + if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { + const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; + curlArgs.push('--socks5-hostname', proxyHost); + } + + const { spawn } = await import('child_process'); + const curl = spawn('curl', curlArgs); + + res.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400', + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Transfer-Encoding': 'chunked' + }); + + curl.stdout.pipe(res); + + curl.stderr.on('data', () => {}); // suppress stderr + curl.on('error', () => { try { res.end(); } catch(_) {} }); + curl.on('close', (code) => { + if (code !== 0) try { res.end(); } catch(_) {} + }); + + // If the client disconnects, kill curl + req.on('close', () => { try { curl.kill(); } catch(_) {} }); + }); + + // POST /api/v2/scroller/rehost + // Downloads an external item and adds it to the platform + router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => { + const { url, rating: initialRating, tags: tagsRaw, comment, is_oc } = req.post || {}; + + if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) }); + + const board = url.match(/boards\.4cdn\.org\/([a-z0-9]+)\//)?.[1] + || url.match(/i\.4cdn\.org\/([a-z0-9]+)\//)?.[1] + || url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1] + || null; + + let rating = initialRating; + if (board === 'gif') rating = 'nsfw'; + else if (board === 'wsg') rating = 'sfw'; + + if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) { + return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) }); + } + + const session = req.session; + + try { + const uuid = await queue.genuuid(); + const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`); + + // Download via curl (lightweight) + const curlArgs = [ + '-s', '-f', '-L', url, '-o', tmpPath, + '--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`, + '--connect-timeout', '30', + '--max-time', '300', + '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ]; + if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') { + const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks; + curlArgs.push('--socks5-hostname', proxyHost); + } + + await queue.spawn('curl', curlArgs); + + // Detect MIME + const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim(); + const ext = cfg.mimes[mime]; + if (!ext) { + throw new Error(`Unsupported file type: ${mime}`); + } + + const finalTmp = path.join(cfg.paths.tmp, `${uuid}.${ext}`); + await fs.rename(tmpPath, finalTmp); + + const checksum = (await queue.spawn('sha256sum', [finalTmp])).stdout.trim().split(' ')[0]; + + // Repost check + if (!getBypassDuplicateCheck()) { + const repost = await queue.checkrepostsum(checksum); + if (repost) { + await fs.unlink(finalTmp).catch(() => {}); + return res.reply({ + code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' }) + }); + } + } + + const phash = await queue.generatePHash(finalTmp).catch(() => null); + + // PHash duplicate check + if (phash && !getBypassDuplicateCheck()) { + const phashMatch = await queue.checkrepostphash(phash); + if (phashMatch) { + await fs.unlink(finalTmp).catch(() => {}); + return res.reply({ + code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' }) + }); + } + } + + const filename = `${uuid}.${ext}`; + const isApprovalRequired = getManualApproval(); + const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b; + + await fs.copyFile(finalTmp, path.join(destDir, filename)); + await fs.unlink(finalTmp).catch(() => {}); + + const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum; + + const [{ id: itemid }] = await db` + insert into items ${db({ + src: url, + dest: filename, + mime: mime, + size: (await fs.stat(path.join(destDir, filename))).size, + checksum: insertChecksum, + phash: phash, + username: session.user, + userchannel: 'web', + usernetwork: 'web', + stamp: ~~(Date.now() / 1000), + active: !isApprovalRequired, + is_oc: !!is_oc + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')} + RETURNING id + `; + + // Process thumbnail + try { + await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired); + if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired); + } catch (err) { + console.error('[REHOST] Thumbnail error:', err); + } + + // Tags + const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)); + await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`; + + const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; + // Board tag in chan-style format e.g. /gif/, /wsg/ + if (board) tags.push(`/${board}/`); + // Auto-tag rating based on board + if (board === 'wsg') tags.push('sfw'); + else if (board === 'gif') tags.push('nsfw'); + for (const tagName of tags) { + let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`; + if (tagRow.length === 0) { + await db`insert into tags ${db({ tag: tagName }, 'tag')} on conflict do nothing`; + tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`; + } + if (tagRow.length) { + await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: session.id })} on conflict do nothing`; + } + } + + + + + + await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`; + + // Broadcast new_item event for live grid updates (only if auto-approved) + if (!isApprovalRequired) { + try { + await db`SELECT pg_notify('new_item', ${JSON.stringify({ + id: itemid, + dest: filename, + mime: mime, + username: session.user, + display_name: session.display_name || null, + tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)), + is_oc: false + })})`; + } catch (err) { + console.error('[REHOST] new_item notify failed:', err); + } + } + + // Push to Matrix channel (only if auto-approved) + if (!isApprovalRequired) { + try { + const self = router.self; + const matrixCfg = cfg.clients?.find(c => c.type === 'matrix'); + if (matrixCfg?.notification_channel_id && self?.bot?.clients) { + const clients = await Promise.all(self.bot.clients); + const matrixWrapper = clients.find(c => c.type === 'matrix'); + if (matrixWrapper?.client) { + const message = `${session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`; + await matrixWrapper.client.send(matrixCfg.notification_channel_id, message); + console.log(`[REHOST] Matrix notification sent for item ${itemid}`); + } + } + } catch (err) { + console.error('[REHOST] Matrix notification error:', err); + } + } + + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, item_id: itemid }) + }); + + } catch (err) { + console.error('[REHOST] Error:', err); + return res.reply({ + code: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: err.message }) + }); + } + }); + + return router; +}; diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 65c3062..9abb457 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -139,6 +139,7 @@ export default (router, tpl) => { timeago: lib.timeAgo(userData.created_at), timefull: userData.created_at }; + userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000); if (userData.banned) { if (!userData.ban_expires) { diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs index 3e17eed..ce5e1c2 100644 --- a/src/inc/routes/notifications.mjs +++ b/src/inc/routes/notifications.mjs @@ -6,6 +6,27 @@ import { setMotd } from "../motd.mjs"; export const clients = new Set(); const activeTabs = new Map(); // sessionId -> tabId +// Broadcast the deduplicated online-user list to all connected clients +function broadcastChatPresence() { + const seen = new Set(); + const users = []; + for (const client of clients) { + if (client.userId && !seen.has(client.userId)) { + seen.add(client.userId); + users.push({ + username: client.username, + display_name: client.display_name, + avatar_file: client.avatar_file, + avatar: client.avatar, + username_color: client.username_color + }); + } + } + for (const client of clients) { + client.send({ type: 'global_chat_presence', data: { users } }); + } +} + function pruneInactiveClients(sessionId, currentTabId) { for (const client of clients) { if (client.sessionId === sessionId && client.tabId !== currentTabId) { @@ -286,26 +307,50 @@ db.listen('global_chat_topic', (payload) => { export default (router, tpl) => { - async function getNotificationHistory(userId, page = 1, limit = 50) { + const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment']; + const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report']; + + async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) { const offset = (page - 1) * limit; - const notifications = await db` - SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data, - COALESCE(u.user, 'System') as from_user, - COALESCE(uo.display_name, '') as from_display_name, - COALESCE(u.id, 0) as from_user_id, - uo.username_color, - i.dest, i.mime - FROM notifications n - LEFT JOIN comments c ON n.reference_id = c.id - LEFT JOIN "user" u ON c.user_id = u.id - LEFT JOIN user_options uo ON u.id = uo.user_id - LEFT JOIN items i ON n.item_id = i.id - WHERE n.user_id = ${userId} - AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false)) - ORDER BY n.created_at DESC - LIMIT ${limit + 1} - OFFSET ${offset} - `; + const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null); + const notifications = typeFilter + ? await db` + SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data, + COALESCE(u.user, 'System') as from_user, + COALESCE(uo.display_name, '') as from_display_name, + COALESCE(u.id, 0) as from_user_id, + uo.username_color, + i.dest, i.mime + FROM notifications n + LEFT JOIN comments c ON n.reference_id = c.id + LEFT JOIN "user" u ON c.user_id = u.id + LEFT JOIN user_options uo ON u.id = uo.user_id + LEFT JOIN items i ON n.item_id = i.id + WHERE n.user_id = ${userId} + AND n.type = ANY(${typeFilter}) + AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false)) + ORDER BY n.created_at DESC + LIMIT ${limit + 1} + OFFSET ${offset} + ` + : await db` + SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data, + COALESCE(u.user, 'System') as from_user, + COALESCE(uo.display_name, '') as from_display_name, + COALESCE(u.id, 0) as from_user_id, + uo.username_color, + i.dest, i.mime + FROM notifications n + LEFT JOIN comments c ON n.reference_id = c.id + LEFT JOIN "user" u ON c.user_id = u.id + LEFT JOIN user_options uo ON u.id = uo.user_id + LEFT JOIN items i ON n.item_id = i.id + WHERE n.user_id = ${userId} + AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false)) + ORDER BY n.created_at DESC + LIMIT ${limit + 1} + OFFSET ${offset} + `; const hasMore = notifications.length > limit; if (hasMore) notifications.pop(); @@ -348,7 +393,7 @@ export default (router, tpl) => { WHERE n.user_id = ${req.session.id} AND n.is_read = false AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false)) ORDER BY n.created_at DESC - LIMIT 20 + LIMIT 1000 `; const processed = notifications.map(n => { @@ -437,17 +482,14 @@ export default (router, tpl) => { // For guests, we use tabId to avoid IP-based pruning collisions (CGNAT). const sessionId = sessionCookie || `guest-${tabId}`; - // Pruning/Active logic only for logged-in users + // sessionId used for presence deduplication only — all tabs from same session connect freely + // Soft cap: max 10 SSE connections per session (prevents runaway tab abuse) + const MAX_TABS_PER_SESSION = 10; if (!isGuest) { - const currentActive = activeTabs.get(sessionId); - if (currentActive && currentActive !== tabId) { - // Check if the current active tab is actually still connected - const activeClient = Array.from(clients).find(c => c.sessionId === sessionId && c.tabId === currentActive); - if (activeClient) { - // console.log(`[SSE] Denying connection for inactive tab ${tabId} (Active: ${currentActive})`); - res.writeHead(204); // No Content - return res.end(); - } + const sessionClients = Array.from(clients).filter(c => c.sessionId === sessionId); + if (sessionClients.length >= MAX_TABS_PER_SESSION) { + // Close the oldest connection (FIFO) to free the slot + sessionClients[0].close(); } } @@ -463,6 +505,11 @@ export default (router, tpl) => { const client = { userId: (req.session && typeof req.session === 'object') ? req.session.id : null, + username: req.session?.user || null, + display_name: req.session?.display_name || null, + avatar_file: req.session?.avatar_file || null, + avatar: req.session?.avatar || null, + username_color: req.session?.username_color || null, sessionId, tabId, send: (data) => { @@ -500,13 +547,11 @@ export default (router, tpl) => { } - // Set as active tab and prune others (only for logged-in users) - if (!isGuest) { - activeTabs.set(sessionId, tabId); - pruneInactiveClients(sessionId, tabId); - } + // Track active tab (no pruning — all tabs are allowed to coexist) + if (!isGuest) activeTabs.set(sessionId, tabId); clients.add(client); + broadcastChatPresence(); // notify everyone of new user // Keep-alive ping const pingInterval = setInterval(() => { @@ -520,6 +565,7 @@ export default (router, tpl) => { res.on('close', () => { clearInterval(pingInterval); clients.delete(client); + broadcastChatPresence(); // notify everyone user left if (activeTabs.get(sessionId) === tabId) { // activeTabs.delete(sessionId); // Keep it set so we know who was last active } @@ -531,11 +577,9 @@ export default (router, tpl) => { const tabId = req.url.qs?.tabId; const sessionId = req.cookies?.session; - // Only track active tabs for logged-in users + // Track which tab is focused (informational only, no pruning) if (tabId && sessionId) { - console.log(`[SSE] Tab ${tabId} became active for session ${sessionId}`); activeTabs.set(sessionId, tabId); - pruneInactiveClients(sessionId, tabId); return res.reply({ body: JSON.stringify({ success: true }) }); } @@ -546,7 +590,8 @@ export default (router, tpl) => { // Notification History Page router.get('/notifications', async (req, res) => { if (!req.session) return res.redirect('/login'); - const data = await getNotificationHistory(req.session.id, 1); + const tab = req.url.qs?.tab || 'user'; + const data = await getNotificationHistory(req.session.id, 1, 50, tab); data.session = req.session; data.hidePagination = true; data.pagination = { @@ -564,7 +609,8 @@ export default (router, tpl) => { success: false }, 401); const page = parseInt(req.url.qs.page) || 1; - const data = await getNotificationHistory(req.session.id, page); + const tab = req.url.qs.tab || null; + const data = await getNotificationHistory(req.session.id, page, 50, tab); const html = tpl.render('snippets/notifications-list', data, req); diff --git a/src/index.mjs b/src/index.mjs index 84b091b..1d15f3b 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -414,7 +414,7 @@ process.on('uncaughtException', err => { // because the session middleware will have completed by the time router callbacks execute. app.use(async (req, res) => { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return; - if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps'].includes(req.url.pathname)) return; + if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta'].includes(req.url.pathname)) return; // Hall manager routes are handled by bypass middleware with their own session auth if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return; // User hall image upload is handled by bypass middleware below @@ -737,6 +737,7 @@ process.on('uncaughtException', err => { custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []), allowed_comment_images: cfg.websrv.allowed_comment_images || [], allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []), + paths_images: cfg.websrv.paths?.images || '/b', get fonts() { try { diff --git a/views/admin/users_list.html b/views/admin/users_list.html index 617b60f..7bb5908 100644 --- a/views/admin/users_list.html +++ b/views/admin/users_list.html @@ -71,11 +71,13 @@ + @if(u.failed_attempts > 0) @endif @else + @endif @if(u.id && u.login !== 'deleted_user') diff --git a/views/notifications.html b/views/notifications.html index 55f2ad8..144e215 100644 --- a/views/notifications.html +++ b/views/notifications.html @@ -6,7 +6,11 @@

{{ t('notifications.page_title') }}

-
+
+ + +
+
@include(snippets/notifications-list)
@if(pagination.next) @@ -15,8 +19,38 @@
@endif
-
doomscroll
+
{{ t('scroller.doomscroll') }}
- +
- - + + + + @if(typeof session !== 'undefined' && session) - - - - +
+ + +
@endif - +
@@ -743,13 +1042,17 @@ + + + +
-

Nothing found with current filters

- +

{{ t('scroller.nothing_found') }}

+
@@ -758,12 +1061,12 @@ + + +
+ @@ -827,46 +1167,53 @@