Update base

This commit is contained in:
2026-04-27 01:52:45 +02:00
parent b646107eb7
commit cdaf469a6d
31 changed files with 3766 additions and 418 deletions

0
deleted/.gitkeep Normal file
View File

0
pending/.gitkeep Normal file
View File

0
pending/ca/.gitkeep Normal file
View File

0
pending/t/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

View File

@@ -966,23 +966,114 @@ html[theme="4d"] {
} }
.notif-header { .notif-header {
padding: 10px; padding: 8px 10px 6px;
border-bottom: 1px solid var(--nav-border-color); border-bottom: 1px solid var(--nav-border-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 8px;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--white); color: var(--white);
background: var(--nav-bg); background: var(--nav-bg);
} }
.notif-header button { .notif-header button#mark-all-read {
background: none; background: none;
border: none; border: none;
color: var(--accent); color: var(--accent);
cursor: pointer; cursor: pointer;
font-size: 0.8rem; font-size: 0.7rem;
padding: 0; 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 { .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 { .embed-responsive-image {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -10110,6 +10264,223 @@ body.layout-modern .tag-controls {
max-width: 200px; 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 */ /* Köpfe — random corner image */
#koepfe-img { #koepfe-img {
position: fixed; position: fixed;
@@ -10909,7 +11280,7 @@ textarea#profile_description {
} }
.meta-suggestion:hover { .meta-suggestion:hover {
background: var(--accent); background: var(--bg);
border-color: var(--accent); border-color: var(--accent);
color: #000 !important; color: #000 !important;
transform: translateY(-1px); transform: translateY(-1px);
@@ -11101,6 +11472,23 @@ body.scroller-active #gchat-widget {
opacity: 1; opacity: 1;
transform: scale(1.08); 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 { body.sidebar-right-hidden #gchat-reopen-bubble {
right: 18px; right: 18px;
} }
@@ -11226,6 +11614,68 @@ body.scroller-active #gchat-reopen-bubble {
/* Messages area */ /* Messages area */
/* Pinned topic bar */ /* 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 { #gchat-topic {
padding: 5px 10px; padding: 5px 10px;
font-size: 0.78em; font-size: 0.78em;
@@ -11691,7 +12141,7 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-embed-video video { .gchat-embed-video video {
max-width: 100%; max-width: 100%;
max-height: 180px; max-height: 350px;
border-radius: 6px; border-radius: 6px;
display: block; display: block;
} }
@@ -11831,3 +12281,245 @@ body.scroller-active #gchat-reopen-bubble {
cursor: default; 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;
}
}

View File

@@ -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 <strong>target username</strong> to transfer all uploads from <strong style="color:var(--accent)">' + escHTML(name) + '</strong> 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' }
);
};
})(); })();

View File

@@ -1183,9 +1183,15 @@ window.cancelAnimFrame = (function () {
}); });
// --- Gesture Support (Mobile & Desktop) --- // --- Gesture Support (Mobile & Desktop) ---
// Inject HUD and Overlay // Inject HUD, Overlay, and Danmaku toggle
const existingHUD = container.querySelector('.v0ck_hud'); const existingHUD = container.querySelector('.v0ck_hud');
if (!existingHUD) { 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', ` container.insertAdjacentHTML('beforeend', `
<div class="v0ck_hud v0ck_hidden" style="z-index: 10000;"> <div class="v0ck_hud v0ck_hidden" style="z-index: 10000;">
<svg viewBox="0 0 24 24"><use class="v0ck_hud_icon" href="/s/img/v0ck.svg#volume_full"></use></svg> <svg viewBox="0 0 24 24"><use class="v0ck_hud_icon" href="/s/img/v0ck.svg#volume_full"></use></svg>
@@ -1194,7 +1200,51 @@ window.cancelAnimFrame = (function () {
</div> </div>
</div> </div>
<div class="ruffle-gesture-overlay"></div> <div class="ruffle-gesture-overlay"></div>
<button class="ruffle-danmaku-toggle${dmOn ? ' active' : ''}" title="Toggle Danmaku">
<i class="fa-solid fa-bars-staggered"></i>
</button>
`); `);
// 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'); const hud = container.querySelector('.v0ck_hud');
@@ -1379,7 +1429,7 @@ window.cancelAnimFrame = (function () {
// that were applied so the main-site layout is fully restored. // that were applied so the main-site layout is fully restored.
if (document.body.classList.contains('scroller-active')) { if (document.body.classList.contains('scroller-active')) {
document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove()); 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 // Restore body
['overflow', 'background', 'height'].forEach(p => document.body.style.removeProperty(p)); ['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) // Restore #main (element persists across PJAX, its inline styles must be cleared)
const _m = document.getElementById('main'); const _m = document.getElementById('main');
if (_m) ['height', 'overflow'].forEach(p => _m.style.removeProperty(p)); 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 // Immediately close image modal on any navigation
@@ -2224,7 +2278,7 @@ window.cancelAnimFrame = (function () {
if (visualizerRafId) window.cancelAnimFrame(visualizerRafId); 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 => { media.forEach(m => {
try { try {
@@ -2301,6 +2355,26 @@ window.cancelAnimFrame = (function () {
if (isNavigating) return; if (isNavigating) return;
isNavigating = true; 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 // Dispatch pjax:start so navigation-aware listeners (e.g. metadata modal, image modal) can react
if (!options.keepMedia) { if (!options.keepMedia) {
window.dispatchEvent(new Event('pjax:start')); window.dispatchEvent(new Event('pjax:start'));
@@ -3059,6 +3133,25 @@ window.cancelAnimFrame = (function () {
const url = window.location.href; const url = window.location.href;
const p = window.location.pathname; 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 // Item detection logic MUST match loadPageAjax/loadItemAjax analysis
// Priorities: Item first, then Special/Grid // Priorities: Item first, then Special/Grid
const parts = p.split('/').filter(Boolean); const parts = p.split('/').filter(Boolean);
@@ -3458,6 +3551,11 @@ window.cancelAnimFrame = (function () {
params.append('strict', '1'); 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()}` : const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` : ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
ctx.subs ? `/ajax/subscriptions?${params.toString()}` : ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
@@ -3551,6 +3649,11 @@ window.cancelAnimFrame = (function () {
params.append('strict', '1'); 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()}` : const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` : ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
ctx.subs ? `/ajax/subscriptions?${params.toString()}` : ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
@@ -4971,6 +5074,10 @@ if (sbtForm) {
// Notification System // Notification System
class NotificationSystem { 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() { constructor() {
this.bell = document.getElementById('nav-notif-btn'); this.bell = document.getElementById('nav-notif-btn');
this.dropdown = document.getElementById('notif-dropdown'); this.dropdown = document.getElementById('notif-dropdown');
@@ -4981,6 +5088,9 @@ class NotificationSystem {
this.retryCount = 0; this.retryCount = 0;
this.maxRetries = 20; // Increased retries this.maxRetries = 20; // Increased retries
this.pendingNotifIds = new Set(); // item IDs notified before thumbnail was in the grid 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 // Generate/retrieve unique tab ID
this.tabId = sessionStorage.getItem('f0ck_tab_id'); this.tabId = sessionStorage.getItem('f0ck_tab_id');
@@ -5086,6 +5196,12 @@ class NotificationSystem {
} }
console.log("[NotificationSystem] Tab visible, signaling active..."); 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.pollDebounced) this.pollDebounced();
if (this.checkForNewItems) this.checkForNewItems(); if (this.checkForNewItems) this.checkForNewItems();
// Catch-up on emojis if they were updated while this tab was pruned/backgrounded // 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 })); document.dispatchEvent(new CustomEvent('f0ck:global_chat_background', { detail: data.data }));
} else if (data.type === 'global_chat_topic') { } else if (data.type === 'global_chat_topic') {
document.dispatchEvent(new CustomEvent('f0ck:global_chat_topic', { detail: data.data })); 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) { } catch (err) {
console.error('SSE data parse error', err); console.error('SSE data parse error', err);
@@ -5385,20 +5503,23 @@ class NotificationSystem {
this.es = null; 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) { if (document.hidden) {
console.log("[NotificationSystem] Tab hidden, suspended SSE retries."); console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
return; 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) { if (this.retryCount < this.maxRetries) {
const delay = Math.pow(2, this.retryCount) * 1000; console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
console.log(`[NotificationSystem] Retrying SSE connection in ${delay}ms... (Attempt ${this.retryCount + 1}/${this.maxRetries})`);
setTimeout(() => this.initSSE(), delay); setTimeout(() => this.initSSE(), delay);
this.retryCount++; this.retryCount++;
} else { } 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()); 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) // Single Notification Click Handler (Delegated)
// Handles both Dropdown and History Page // Handles both Dropdown and History Page
const handleNotificationClick = (e) => { const handleNotificationClick = (e) => {
@@ -5706,30 +5846,45 @@ class NotificationSystem {
updateUI(notifications) { updateUI(notifications) {
if (!this.countBadge || !this.list) return; 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) { const userUnread = this._cachedUser.filter(n => !n.is_read).length;
this.countBadge.textContent = unreadCount; 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'; this.countBadge.style.display = 'block';
} else { } else {
this.countBadge.style.display = 'none'; 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 // Forward count to Abyss scroller notification badge if active
if (typeof window._scrollerNotifHook === 'function') { if (typeof window._scrollerNotifHook === 'function') {
window._scrollerNotifHook(unreadCount); window._scrollerNotifHook(totalUnread);
} }
// Sync .has-notif highlights on main grid thumbnails for all unread notifications. // 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 currentPath = window.location.pathname;
const unreadItemIds = new Set(); const unreadItemIds = new Set();
notifications.forEach(n => { notifications.forEach(n => {
if (!n.is_read && n.item_id) { if (!n.is_read && n.item_id) {
unreadItemIds.add(String(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; 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 => { document.querySelectorAll(`a.thumb[href$="/${n.item_id}"], a.lazy-thumb[href$="/${n.item_id}"]`).forEach(el => {
el.classList.add('has-notif'); 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. // 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 => { document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => {
const match = el.getAttribute('href')?.match(/\/(\d+)$/); const match = el.getAttribute('href')?.match(/\/(\d+)$/);
if (match && !unreadItemIds.has(match[1])) { if (match && !unreadItemIds.has(match[1])) {
@@ -5747,26 +5900,23 @@ class NotificationSystem {
} }
}); });
if (notifications.length === 0) { // Render the active tab
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`; this._renderActiveTab();
return;
}
this.list.innerHTML = Sanitizer.clean(notifications.map(n => this.renderItem(n)).join(''));
// Live update for History Page // Live update for History Page
const historyContainer = document.querySelector('.notifications-list-full'); const historyContainer = document.querySelector('.notifications-list-full');
if (historyContainer) { 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}"]`); const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
if (!existing) { if (!existing) {
console.log("[NotificationSystem] Adding new item to history:", n.id); console.log("[NotificationSystem] Adding new item to history:", n.id);
const html = this.renderHistoryItem(n); const html = this.renderHistoryItem(n);
// Create temp container to turn string into node
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(html); temp.innerHTML = Sanitizer.clean(html);
const node = temp.firstElementChild; 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); historyContainer.prepend(node);
} else { } else {
console.log("[NotificationSystem] Item already exists:", n.id); 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 = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
return;
}
this.list.innerHTML = Sanitizer.clean(items.map(n => this.renderItem(n)).join(''));
}
renderHistoryItem(n) { renderHistoryItem(n) {
let link = `/${n.item_id}`; let link = `/${n.item_id}`;
let msg = ''; let msg = '';
@@ -5992,6 +6152,14 @@ class NotificationSystem {
markAllReadUI() { markAllReadUI() {
this.countBadge.style.display = 'none'; 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) { if (this.list) {
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`; this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
} }

View File

@@ -25,12 +25,25 @@
function updateBadge() { function updateBadge() {
const badge = document.getElementById('gchat-badge'); const badge = document.getElementById('gchat-badge');
if (!badge) return; const bubble = document.getElementById('gchat-reopen-bubble');
if (unreadCount > 0) { if (unreadCount > 0) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount; const label = unreadCount > 99 ? '99+' : String(unreadCount);
badge.style.display = 'inline-flex'; 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 { } 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 @@
</div> </div>
</div> </div>
<div id="gchat-topic" style="display:none"></div> <div id="gchat-topic" style="display:none"></div>
<div id="gchat-online"></div>
<div id="gchat-messages"></div> <div id="gchat-messages"></div>
<div id="gchat-input-area"> <div id="gchat-input-area">
<div id="gchat-toolbar"> <div id="gchat-toolbar">
@@ -211,10 +225,50 @@
`</a>`; `</a>`;
}); });
// 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) =>
`<span class="gchat-item-embed gchat-post-card gchat-post-card--loading" data-item-id="${itemId}">` +
`<span class="gchat-post-card__thumb-wrap"><span class="gchat-post-card__thumb-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></span></span>` +
`<span class="gchat-post-card__info"><span class="gchat-post-card__id">#${itemId}</span></span>` +
`</span>`
);
// 6e. Remaining https URLs → embed media if from allowed host, else plain link
html = html.replace(/(^|[\s>])(https?:\/\/[^\s<"]+)/g, (match, pre, url) => { html = html.replace(/(^|[\s>])(https?:\/\/[^\s<"]+)/g, (match, pre, url) => {
// Don't double-wrap already-embedded URLs // Skip URLs already embedded by earlier steps
if (match.includes('<img') || match.includes('<video') || match.includes('<audio') || match.includes('<iframe') || match.includes('gchat-yt-card')) return match; if (match.includes('<img') || match.includes('<video') || match.includes('<audio') ||
match.includes('<iframe') || match.includes('gchat-yt-card') || match.includes('gchat-item-embed'))
return match;
// Use URL API for reliable host + extension detection
try {
const urlObj = new URL(url);
const host = urlObj.host;
const path = urlObj.pathname;
// Derive CDN host from window.f0ckMediaBase (may be on a different subdomain in prod)
let mediaHost = '';
try { mediaHost = new URL(window.f0ckMediaBase || '').host; } catch (_) {}
const isSameSite = host === window.location.host;
const isMediaHost = !!mediaHost && host === mediaHost;
const isAllowedHoster = !isSameSite && !isMediaHost && (window.f0ckAllowedImages || []).some(h =>
host === h || host.endsWith('.' + h)
);
if (isSameSite || isMediaHost || isAllowedHoster) {
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
}
} catch (_) {}
return `${pre}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}<i class="fa-solid fa-arrow-up-right-from-square" style="font-size:0.7em;margin-left:3px;opacity:0.6"></i></a>`; return `${pre}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}<i class="fa-solid fa-arrow-up-right-from-square" style="font-size:0.7em;margin-left:3px;opacity:0.6"></i></a>`;
}); });
@@ -277,6 +331,70 @@
if (authorEl) authorEl.textContent = meta.author_name || ''; 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 = '<i class="fa-solid fa-film"></i>';
else if (mime.startsWith('audio/')) typeBadge = '<i class="fa-solid fa-music"></i>';
else if (mime.startsWith('image/')) typeBadge = '<i class="fa-solid fa-image"></i>';
const card = document.createElement('a');
card.className = 'gchat-post-card';
card.href = `/${id}`;
card.innerHTML =
`<span class="gchat-post-card__thumb-wrap">` +
`<img class="gchat-post-card__thumb" src="${esc(thumbSrc)}" alt="#${id}" loading="lazy" onerror="this.style.display='none'">` +
(typeBadge ? `<span class="gchat-post-card__type-badge">${typeBadge}</span>` : '') +
`</span>` +
`<span class="gchat-post-card__info">` +
`<span class="gchat-post-card__id">#${id}</span>` +
`<span class="gchat-post-card__uploader"><i class="fa-solid fa-user"></i> ${uploader}</span>` +
`<span class="gchat-post-card__comments"><i class="fa-solid fa-comment"></i> ${commentCount}</span>` +
`</span>`;
wrapEl.replaceWith(card);
}
function appendMsg(msg, scrollForce = false) { function appendMsg(msg, scrollForce = false) {
const container = document.getElementById('gchat-messages'); const container = document.getElementById('gchat-messages');
if (!container) return; if (!container) return;
@@ -301,6 +419,8 @@
// Wire up YouTube oEmbed cards // Wire up YouTube oEmbed cards
node.querySelectorAll('.gchat-yt-card[data-yt-id]').forEach(fetchYtOembed); 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); scrollToBottom(scrollForce);
@@ -772,9 +892,9 @@
let _sseReady = false; let _sseReady = false;
function setConnecting(connecting) { function setConnecting(connecting) {
if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : ''; if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '1';
if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : ''; if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : '';
if (_messages) _messages.style.opacity = connecting ? '0.35' : ''; if (_messages) _messages.style.opacity = connecting ? '0.35' : '1';
const sendBtn = document.getElementById('gchat-send-btn'); const sendBtn = document.getElementById('gchat-send-btn');
if (sendBtn) sendBtn.disabled = connecting; if (sendBtn) sendBtn.disabled = connecting;
} }
@@ -783,12 +903,39 @@
const onSseReady = () => { const onSseReady = () => {
if (_sseReady) return; if (_sseReady) return;
_sseReady = true; _sseReady = true;
clearTimeout(_sseUnlockTimer);
clearInterval(_pollFallback);
setConnecting(false); setConnecting(false);
}; };
document.addEventListener('f0ck:sse_ready', onSseReady); document.addEventListener('f0ck:sse_ready', onSseReady);
// Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag // Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag
if (document.documentElement.dataset.sseReady === '1') onSseReady(); 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'); const textarea = document.getElementById('gchat-input');
@@ -1193,6 +1340,34 @@
applyTopic(e.detail?.topic || null); 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 `<img src="${src}" class="gchat-online-avatar" title="${name}" alt="${name}" loading="lazy" ${colorStyle}>`;
}).join('');
const extraPill = extra > 0 ? `<span class="gchat-online-extra">+${extra}</span>` : '';
const countLabel = `<span class="gchat-online-count">${users.length} online</span>`;
el.innerHTML = `<div class="gchat-online-inner">${countLabel}<div class="gchat-online-avatars">${avatarHTML}${extraPill}</div></div>`;
}
document.addEventListener('f0ck:global_chat_presence', (e) => {
renderOnline(e.detail?.users || []);
});
// Event delegation: reply + admin delete buttons inside #gchat-messages // Event delegation: reply + admin delete buttons inside #gchat-messages
const msgArea = document.getElementById('gchat-messages'); const msgArea = document.getElementById('gchat-messages');
const csrf = () => window.f0ckSession?.csrf_token; const csrf = () => window.f0ckSession?.csrf_token;

View File

@@ -304,6 +304,7 @@ if (window.__dmLoaded) {
let threadHasMore = false; let threadHasMore = false;
const renderedIds = new Set(); const renderedIds = new Set();
let threadMessages = []; // Cache for re-rendering (e.g. emojis) let threadMessages = []; // Cache for re-rendering (e.g. emojis)
const dmPostPreviewCache = new Map(); // itemId → { item, meta } | null
// Title management — global across all pages // Title management — global across all pages
let _dmTitleCount = 0; let _dmTitleCount = 0;
@@ -693,9 +694,99 @@ if (window.__dmLoaded) {
const time = timeAgo(m.created_at); const time = timeAgo(m.created_at);
div.innerHTML = `<div class="dm-bubble comment-content">${content}</div><span class="dm-msg-time" data-ts="${escHtml(m.created_at)}">${escHtml(time)}</span>`; div.innerHTML = `<div class="dm-bubble comment-content">${content}</div><span class="dm-msg-time" data-ts="${escHtml(m.created_at)}">${escHtml(time)}</span>`;
// Async: extract post IDs from raw plaintext and inject preview cards into the bubble
if (m.plaintext) resolvePostPreviews(div, m.plaintext);
return div; 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 =
`<span class="dm-post-card__thumb-wrap"><span class="dm-post-card__thumb-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></span></span>`+
`<span class="dm-post-card__info"><span class="dm-post-card__id">#${id}</span></span>`;
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 = '<i class="fa-solid fa-film"></i>';
else if (mime.startsWith('audio/')) typeBadge = '<i class="fa-solid fa-music"></i>';
else if (mime.startsWith('image/')) typeBadge = '<i class="fa-solid fa-image"></i>';
const card = document.createElement('a');
card.className = 'dm-post-card';
card.href = `/${id}`;
card.innerHTML =
`<span class="dm-post-card__thumb-wrap">`+
`<img class="dm-post-card__thumb" src="${escHtml(thumbSrc)}" alt="#${id}" loading="lazy" onerror="this.style.display='none'">`+
(typeBadge ? `<span class="dm-post-card__type-badge">${typeBadge}</span>` : '') +
`</span>`+
`<span class="dm-post-card__info">`+
`<span class="dm-post-card__id">#${id}</span>`+
`<span class="dm-post-card__uploader"><i class="fa-solid fa-user"></i> ${uploader}</span>`+
`<span class="dm-post-card__comments"><i class="fa-solid fa-comment"></i> ${commentCount}</span>`+
`</span>`;
placeholder.replaceWith(card);
}
}
let sendInFlight = false; // debounce guard against double-submit let sendInFlight = false; // debounce guard against double-submit
function setupDmEmojiPicker() { function setupDmEmojiPicker() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
(() => {
const Cookie = { const Cookie = {
get: name => { get: name => {
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1]; const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
@@ -14,7 +15,6 @@ const Cookie = {
} }
}; };
(() => {
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d']; 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'; const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
@@ -43,8 +43,8 @@ const Cookie = {
e.preventDefault(); e.preventDefault();
cycleTheme(); cycleTheme();
const newTheme = document.documentElement.getAttribute('theme') || defaultTheme; const newTheme = document.documentElement.getAttribute('theme') || defaultTheme;
// Use scroller toast if available, otherwise site-wide flashMessage // Use scroller toast only when scroller is actually active
if (typeof window._scrollerThemeToast === 'function') { if (document.body.classList.contains('scroller-active') && typeof window._scrollerThemeToast === 'function') {
window._scrollerThemeToast(newTheme); window._scrollerThemeToast(newTheme);
} else if (typeof window.flashMessage === 'function') { } else if (typeof window.flashMessage === 'function') {
window.flashMessage(`Theme: ${newTheme}`, 2000); window.flashMessage(`Theme: ${newTheme}`, 2000);

View File

@@ -13,10 +13,12 @@
"mod": "mod", "mod": "mod",
"settings": "Einstellungen", "settings": "Einstellungen",
"logout": "Abmelden", "logout": "Abmelden",
"notifications": "Nuttis", "notifications": "Benachrichtigungen",
"mark_all_read": "Alle als gelesen markieren", "mark_all_read": "Alle als gelesen markieren",
"no_notifications": "Keine neuen Nuttis", "no_notifications": "Keine neuen Benachrichtigungen",
"view_all_notifications": "Alle Nuttis anzeigen", "view_all_notifications": "Alle anzeigen",
"notif_tab_user": "Benutzer",
"notif_tab_system": "System",
"manage_subscriptions": "Abonnements verwalten", "manage_subscriptions": "Abonnements verwalten",
"favorites": "Favoriten", "favorites": "Favoriten",
"direct_messages": "Direktnachrichten", "direct_messages": "Direktnachrichten",
@@ -265,7 +267,7 @@
}, },
"comments": { "comments": {
"write_comment": "Kommentar schreiben...", "write_comment": "Kommentar schreiben...",
"post": "Abschnalzen", "post": "Senden",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"upload_btn": { "upload_btn": {
@@ -414,12 +416,13 @@
"loading": "Gespräche werden geladen…", "loading": "Gespräche werden geladen…",
"decrypting": "Nachrichten werden entschlüsselt…", "decrypting": "Nachrichten werden entschlüsselt…",
"input_placeholder": "Nachricht schreiben…", "input_placeholder": "Nachricht schreiben…",
"send": "Abschnalzen" "send": "Senden"
}, },
"profile": { "profile": {
"message_btn": "Nachricht", "message_btn": "Nachricht",
"legacy_record": "Legacy-Eintrag Erster Upload:", "legacy_record": "Legacy-Eintrag Erster Upload:",
"joined": "Beigetreten:", "joined": "Beigetreten:",
"age_days": "{n} Tage",
"stat_comments": "Kommentare:", "stat_comments": "Kommentare:",
"stat_tags": "Tags:", "stat_tags": "Tags:",
"stat_halls": "Hallen:", "stat_halls": "Hallen:",
@@ -486,6 +489,8 @@
"months": "{n} Monaten", "months": "{n} Monaten",
"day": "{n} Tag", "day": "{n} Tag",
"days": "{n} Tagen", "days": "{n} Tagen",
"week": "{n} Woche",
"weeks": "{n} Wochen",
"hour": "{n} Stunde", "hour": "{n} Stunde",
"hours": "{n} Stunden", "hours": "{n} Stunden",
"minute": "{n} Minute", "minute": "{n} Minute",
@@ -552,5 +557,92 @@
"slow_down": "Langsamer!", "slow_down": "Langsamer!",
"error_send": "Fehler beim Senden", "error_send": "Fehler beim Senden",
"network_error": "Netzwerkfehler" "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"
} }
} }

View File

@@ -17,6 +17,8 @@
"mark_all_read": "Mark all read", "mark_all_read": "Mark all read",
"no_notifications": "No new notifications", "no_notifications": "No new notifications",
"view_all_notifications": "View all notifications", "view_all_notifications": "View all notifications",
"notif_tab_user": "User",
"notif_tab_system": "System",
"manage_subscriptions": "manage subscriptions", "manage_subscriptions": "manage subscriptions",
"favorites": "Favorites", "favorites": "Favorites",
"direct_messages": "Direct Messages", "direct_messages": "Direct Messages",
@@ -424,6 +426,7 @@
"message_btn": "✉ Message", "message_btn": "✉ Message",
"legacy_record": "Legacy Record First Upload:", "legacy_record": "Legacy Record First Upload:",
"joined": "Joined:", "joined": "Joined:",
"age_days": "{n} days",
"stat_comments": "Comments:", "stat_comments": "Comments:",
"stat_tags": "Tags:", "stat_tags": "Tags:",
"stat_halls": "Halls:", "stat_halls": "Halls:",
@@ -490,6 +493,8 @@
"months": "{n} months", "months": "{n} months",
"day": "{n} day", "day": "{n} day",
"days": "{n} days", "days": "{n} days",
"week": "{n} week",
"weeks": "{n} weeks",
"hour": "{n} hour", "hour": "{n} hour",
"hours": "{n} hours", "hours": "{n} hours",
"minute": "{n} minute", "minute": "{n} minute",
@@ -554,5 +559,92 @@
"slow_down": "Slow down!", "slow_down": "Slow down!",
"error_send": "Error sending", "error_send": "Error sending",
"network_error": "Network error" "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"
} }
} }

View File

@@ -17,6 +17,8 @@
"mark_all_read": "Alles als gelezen markeren", "mark_all_read": "Alles als gelezen markeren",
"no_notifications": "Geen nieuwe meldingen", "no_notifications": "Geen nieuwe meldingen",
"view_all_notifications": "Alle meldingen bekijken", "view_all_notifications": "Alle meldingen bekijken",
"notif_tab_user": "Gebruiker",
"notif_tab_system": "Systeem",
"manage_subscriptions": "abonnementen beheren", "manage_subscriptions": "abonnementen beheren",
"favorites": "Favorieten", "favorites": "Favorieten",
"direct_messages": "Directe Berichten", "direct_messages": "Directe Berichten",
@@ -420,6 +422,7 @@
"message_btn": "✉ bericht", "message_btn": "✉ bericht",
"legacy_record": "Legacy Record Eerste Upload:", "legacy_record": "Legacy Record Eerste Upload:",
"joined": "Lid geworden:", "joined": "Lid geworden:",
"age_days": "{n} dagen",
"stat_comments": "Opmerkingen:", "stat_comments": "Opmerkingen:",
"stat_tags": "Tags:", "stat_tags": "Tags:",
"stat_halls": "Hallen:", "stat_halls": "Hallen:",
@@ -486,6 +489,8 @@
"months": "{n} maanden", "months": "{n} maanden",
"day": "{n} dag", "day": "{n} dag",
"days": "{n} dagen", "days": "{n} dagen",
"week": "{n} week",
"weeks": "{n} weken",
"hour": "{n} uur", "hour": "{n} uur",
"hours": "{n} uur", "hours": "{n} uur",
"minute": "{n} minuut", "minute": "{n} minuut",
@@ -550,5 +555,92 @@
"slow_down": "Langzamer!", "slow_down": "Langzamer!",
"error_send": "Versturen mislukt", "error_send": "Versturen mislukt",
"network_error": "Netwerkfout" "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"
} }
} }

View File

@@ -17,6 +17,8 @@
"mark_all_read": "Alle als gelesen markieren", "mark_all_read": "Alle als gelesen markieren",
"no_notifications": "Keine neuen Hinweise", "no_notifications": "Keine neuen Hinweise",
"view_all_notifications": "Alle Hinweise betrachten", "view_all_notifications": "Alle Hinweise betrachten",
"notif_tab_user": "Benutzer",
"notif_tab_system": "System",
"manage_subscriptions": "Abonnements verwalten", "manage_subscriptions": "Abonnements verwalten",
"favorites": "Favoriten", "favorites": "Favoriten",
"direct_messages": "Direktnachrichten", "direct_messages": "Direktnachrichten",
@@ -387,7 +389,7 @@
}, },
"ranking": { "ranking": {
"title": "Rangliste", "title": "Rangliste",
"top_contributors": "Top-Beitragende", "top_contributors": "Top Etikettierer",
"col_rank": "Rang", "col_rank": "Rang",
"col_avatar": "Profilbild", "col_avatar": "Profilbild",
"col_username": "Benutzername", "col_username": "Benutzername",
@@ -424,6 +426,7 @@
"message_btn": "Nachricht", "message_btn": "Nachricht",
"legacy_record": "Veralteter Datensatz Erste Aufladierung:", "legacy_record": "Veralteter Datensatz Erste Aufladierung:",
"joined": "Beigetreten:", "joined": "Beigetreten:",
"age_days": "{n} Tage",
"stat_comments": "Kommentare:", "stat_comments": "Kommentare:",
"stat_tags": "Etiketten:", "stat_tags": "Etiketten:",
"stat_halls": "Hallen:", "stat_halls": "Hallen:",
@@ -485,11 +488,13 @@
"timeago": { "timeago": {
"just_now": "gerade eben", "just_now": "gerade eben",
"year": "{n} Jahr", "year": "{n} Jahr",
"years": "{n} Jahre", "years": "{n} Jahren",
"month": "{n} Monat", "month": "{n} Monat",
"months": "{n} Monate", "months": "{n} Monaten",
"day": "{n} Tag", "day": "{n} Tag",
"days": "{n} Tage", "days": "{n} Tagen",
"week": "{n} Woche",
"weeks": "{n} Wochen",
"hour": "{n} Stunde", "hour": "{n} Stunde",
"hours": "{n} Stunden", "hours": "{n} Stunden",
"minute": "{n} Minute", "minute": "{n} Minute",
@@ -556,5 +561,92 @@
"slow_down": "Gemach, gemach!", "slow_down": "Gemach, gemach!",
"error_send": "Sendung fehlgeschlagen", "error_send": "Sendung fehlgeschlagen",
"network_error": "Netzwerkfehler" "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"
} }
} }

View File

@@ -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) => { router.post(/^\/api\/v2\/admin\/users\/bulk-delete-items\/?$/, lib.auth, async (req, res) => {
try { try {
const { user_id, username } = req.post; const { user_id, username } = req.post;

View File

@@ -294,6 +294,43 @@ export default router => {
// Background processing block // Background processing block
(async () => { (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 { try {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : []; 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']; 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 uuid = await queue.genuuid();
const isInstagram = /instagram\.com/i.test(url); const isInstagram = /instagram\.com/i.test(url);
const dlError = (err) => { const dlError = (err) => sanitizeError(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}`;
};
let source; let source;
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`); console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
@@ -330,7 +358,7 @@ export default router => {
])).stdout.trim(); ])).stdout.trim();
} catch (err) { } catch (err) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`); console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
if (isInstagram) throw err; if (isInstagram) throw new Error(sanitizeError(err));
try { try {
source = (await queue.spawn('yt-dlp', [ 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; const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost); curlArgs.push('--socks5-hostname', proxyHost);
} }
try {
await queue.spawn('curl', curlArgs); 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 fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
const extension = cfg.mimes[fallbackMime]; const extension = cfg.mimes[fallbackMime];
@@ -549,7 +581,7 @@ export default router => {
} catch (err) { } catch (err) {
console.error('[UPLOAD-URL-ASYNC] Final Error:', err); console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
// Error notification // 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) })})`;
} }
})(); })();
} }

467
src/inc/routes/external.mjs Normal file
View File

@@ -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\/(?<board>[a-z0-9]+)\/(?<tid>\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\/(?<board>[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\/(?<board>[a-z0-9]+)\/find\/(?<postno>\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\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, 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;
};

View File

@@ -139,6 +139,7 @@ export default (router, tpl) => {
timeago: lib.timeAgo(userData.created_at), timeago: lib.timeAgo(userData.created_at),
timefull: 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.banned) {
if (!userData.ban_expires) { if (!userData.ban_expires) {

View File

@@ -6,6 +6,27 @@ import { setMotd } from "../motd.mjs";
export const clients = new Set(); export const clients = new Set();
const activeTabs = new Map(); // sessionId -> tabId 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) { function pruneInactiveClients(sessionId, currentTabId) {
for (const client of clients) { for (const client of clients) {
if (client.sessionId === sessionId && client.tabId !== currentTabId) { if (client.sessionId === sessionId && client.tabId !== currentTabId) {
@@ -286,9 +307,33 @@ db.listen('global_chat_topic', (payload) => {
export default (router, tpl) => { 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 offset = (page - 1) * limit;
const notifications = await db` 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, 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(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name, COALESCE(uo.display_name, '') as from_display_name,
@@ -348,7 +393,7 @@ export default (router, tpl) => {
WHERE n.user_id = ${req.session.id} AND n.is_read = false 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)) 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 ORDER BY n.created_at DESC
LIMIT 20 LIMIT 1000
`; `;
const processed = notifications.map(n => { 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). // For guests, we use tabId to avoid IP-based pruning collisions (CGNAT).
const sessionId = sessionCookie || `guest-${tabId}`; 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) { if (!isGuest) {
const currentActive = activeTabs.get(sessionId); const sessionClients = Array.from(clients).filter(c => c.sessionId === sessionId);
if (currentActive && currentActive !== tabId) { if (sessionClients.length >= MAX_TABS_PER_SESSION) {
// Check if the current active tab is actually still connected // Close the oldest connection (FIFO) to free the slot
const activeClient = Array.from(clients).find(c => c.sessionId === sessionId && c.tabId === currentActive); sessionClients[0].close();
if (activeClient) {
// console.log(`[SSE] Denying connection for inactive tab ${tabId} (Active: ${currentActive})`);
res.writeHead(204); // No Content
return res.end();
}
} }
} }
@@ -463,6 +505,11 @@ export default (router, tpl) => {
const client = { const client = {
userId: (req.session && typeof req.session === 'object') ? req.session.id : null, 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, sessionId,
tabId, tabId,
send: (data) => { send: (data) => {
@@ -500,13 +547,11 @@ export default (router, tpl) => {
} }
// Set as active tab and prune others (only for logged-in users) // Track active tab (no pruning — all tabs are allowed to coexist)
if (!isGuest) { if (!isGuest) activeTabs.set(sessionId, tabId);
activeTabs.set(sessionId, tabId);
pruneInactiveClients(sessionId, tabId);
}
clients.add(client); clients.add(client);
broadcastChatPresence(); // notify everyone of new user
// Keep-alive ping // Keep-alive ping
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
@@ -520,6 +565,7 @@ export default (router, tpl) => {
res.on('close', () => { res.on('close', () => {
clearInterval(pingInterval); clearInterval(pingInterval);
clients.delete(client); clients.delete(client);
broadcastChatPresence(); // notify everyone user left
if (activeTabs.get(sessionId) === tabId) { if (activeTabs.get(sessionId) === tabId) {
// activeTabs.delete(sessionId); // Keep it set so we know who was last active // 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 tabId = req.url.qs?.tabId;
const sessionId = req.cookies?.session; 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) { if (tabId && sessionId) {
console.log(`[SSE] Tab ${tabId} became active for session ${sessionId}`);
activeTabs.set(sessionId, tabId); activeTabs.set(sessionId, tabId);
pruneInactiveClients(sessionId, tabId);
return res.reply({ body: JSON.stringify({ success: true }) }); return res.reply({ body: JSON.stringify({ success: true }) });
} }
@@ -546,7 +590,8 @@ export default (router, tpl) => {
// Notification History Page // Notification History Page
router.get('/notifications', async (req, res) => { router.get('/notifications', async (req, res) => {
if (!req.session) return res.redirect('/login'); 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.session = req.session;
data.hidePagination = true; data.hidePagination = true;
data.pagination = { data.pagination = {
@@ -564,7 +609,8 @@ export default (router, tpl) => {
success: false success: false
}, 401); }, 401);
const page = parseInt(req.url.qs.page) || 1; 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); const html = tpl.render('snippets/notifications-list', data, req);

View File

@@ -414,7 +414,7 @@ process.on('uncaughtException', err => {
// because the session middleware will have completed by the time router callbacks execute. // because the session middleware will have completed by the time router callbacks execute.
app.use(async (req, res) => { app.use(async (req, res) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return; 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 // Hall manager routes are handled by bypass middleware with their own session auth
if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return; if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
// User hall image upload is handled by bypass middleware below // 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 || []), custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
allowed_comment_images: cfg.websrv.allowed_comment_images || [], allowed_comment_images: cfg.websrv.allowed_comment_images || [],
allowed_comment_images_json: JSON.stringify(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() { get fonts() {
try { try {

View File

@@ -71,11 +71,13 @@
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminReassignUploads(this)" class="btn-modern" title="Reassign uploads to another user" style="background: rgba(0, 200, 180, 0.1); color: #00c8b4; border: 1px solid rgba(0, 200, 180, 0.2);"><i class="fa fa-right-left"></i> Reassign</button>
@if(u.failed_attempts > 0) @if(u.failed_attempts > 0)
<button data-username="{{ u.login }}" onclick="adminResetLoginAttempts(this)" class="btn-modern btn-pw" title="Reset Login Attempts" style="background: rgba(255, 204, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 204, 0, 0.2);"><i class="fa fa-unlock"></i> Reset IP</button> <button data-username="{{ u.login }}" onclick="adminResetLoginAttempts(this)" class="btn-modern btn-pw" title="Reset Login Attempts" style="background: rgba(255, 204, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 204, 0, 0.2);"><i class="fa fa-unlock"></i> Reset IP</button>
@endif @endif
@else @else
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Legacy Files</button> <button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Legacy Files</button>
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="adminReassignUploads(this)" class="btn-modern" title="Reassign uploads to another user" style="background: rgba(0, 200, 180, 0.1); color: #00c8b4; border: 1px solid rgba(0, 200, 180, 0.2);"><i class="fa fa-right-left"></i> Reassign</button>
@endif @endif
@if(u.id && u.login !== 'deleted_user') @if(u.id && u.login !== 'deleted_user')

View File

@@ -6,7 +6,11 @@
<h2>{{ t('notifications.page_title') }}</h2> <h2>{{ t('notifications.page_title') }}</h2>
<button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button> <button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button>
</div> </div>
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}"> <div class="notif-page-tabs">
<button class="notif-page-tab active" data-tab="user">{{ t('nav.notif_tab_user') }}</button>
<button class="notif-page-tab" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
</div>
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}" data-tab="user">
@include(snippets/notifications-list) @include(snippets/notifications-list)
</div> </div>
@if(pagination.next) @if(pagination.next)
@@ -15,8 +19,38 @@
</div> </div>
@endif @endif
<script> <script>
// Initialize mark all read for the page // Tab switching for notification history page
(function () { (function () {
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const tabs = document.querySelectorAll('.notif-page-tab');
const container = document.getElementById('notifications-container');
tabs.forEach(tab => {
tab.addEventListener('click', async () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
container.dataset.tab = tabName;
container.dataset.page = '1';
// Load first page for this tab
try {
const res = await fetch(`/ajax/notifications?page=1&tab=${tabName}`);
const data = await res.json();
if (data.success) {
container.innerHTML = data.html || `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
const footbar = document.getElementById('footbar');
if (footbar) {
footbar.style.display = data.hasMore ? '' : 'none';
}
}
} catch (e) {
console.error('Failed to load notifications tab', e);
}
});
});
// Mark all read
const btn = document.getElementById('mark-all-read-page'); const btn = document.getElementById('mark-all-read-page');
if (btn) { if (btn) {
btn.onclick = async () => { btn.onclick = async () => {

View File

@@ -70,6 +70,10 @@
} }
.topbar-icon-btn:hover { background: rgba(255,255,255,.18); transform: scale(1.07); } .topbar-icon-btn:hover { background: rgba(255,255,255,.18); transform: scale(1.07); }
.topbar-icon-btn.has-filter { border-color: var(--accent); color: var(--accent); } .topbar-icon-btn.has-filter { border-color: var(--accent); color: var(--accent); }
@media (max-width: 600px) {
.topbar-left, .topbar-right { gap: 4px; }
.topbar-icon-btn { width: 32px; height: 32px; font-size: .78rem; }
}
#filter-active-summary { #filter-active-summary {
display: none; display: none;
background: rgba(0,0,0,.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); background: rgba(0,0,0,.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@@ -236,6 +240,65 @@
font-size: .6rem; font-weight: 700; line-height: 16px; font-size: .6rem; font-weight: 700; line-height: 16px;
text-align: center; pointer-events: none; text-align: center; pointer-events: none;
} }
/* Notification dropdown inside scroller */
#scroller-notif-dropdown {
width: 340px; max-width: calc(100vw - 16px);
background: #1a1a1a; border: 1px solid rgba(255,255,255,.1);
box-shadow: 0 8px 32px rgba(0,0,0,.6); border-radius: 8px;
overflow: hidden; opacity: 0; transform: translateY(-6px);
transition: opacity .2s ease, transform .2s ease;
}
#scroller-notif-dropdown.visible { opacity: 1; transform: translateY(0); display: block !important; }
.notif-header {
padding: 8px 10px 6px; border-bottom: 1px solid rgba(255,255,255,.08);
display: flex; justify-content: space-between; align-items: center; gap: 8px;
font-size: .82rem; font-weight: 700; color: rgba(255,255,255,.75); background: #111;
}
.notif-header button#scroller-mark-all-read {
background: none; border: none; color: var(--accent, #e91e8c); cursor: pointer;
font-size: .8rem; padding: 2px 4px; flex-shrink: 0;
}
.notif-header button#scroller-mark-all-read:hover { color: #fff; }
.notif-tabs { display: flex; gap: 2px; flex: 1; min-width: 0; }
.notif-tab {
background: none; border: none; color: #888; font-size: .72rem; font-weight: 600;
padding: 4px 10px; cursor: pointer; border-radius: 4px; transition: all .15s ease;
text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; position: relative;
}
.notif-tab:hover { color: #ccc; background: rgba(255,255,255,.05); }
.notif-tab.active { color: var(--accent, #e91e8c); background: rgba(233,30,140,.1); }
.notif-tab-badge {
display: inline-block; background: #e91e8c; 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;
}
.notif-list { max-height: 360px; overflow-y: auto; }
.notif-item {
padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.05);
background: #1a1a1a; cursor: pointer; transition: background .15s;
font-size: .82rem; color: #ddd; display: block; text-decoration: none;
}
.notif-item:hover { background: #262626; color: #fff; text-decoration: none; }
.notif-item.unread { border-left: 3px solid var(--accent, #e91e8c); background: rgba(255,255,255,.03); }
.notif-item.notif-with-thumb { display: flex; align-items: flex-start; gap: 10px; }
.notif-thumb {
flex-shrink: 0; width: 56px; height: 56px; border-radius: 4px;
overflow: hidden; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
}
.notif-thumb img { width: 100%; height: 100%; object-fit: cover; }
.notif-content { flex: 1; min-width: 0; }
.notif-user { font-size: .8rem; margin-bottom: 2px; }
.notif-msg { color: #aaa; font-size: .78rem; line-height: 1.3; }
.notif-time { font-size: .7rem; color: #666; margin-top: 3px; display: block; }
.notif-empty { text-align: center; padding: 20px; color: #666; font-size: .82rem; }
.notif-footer {
padding: 8px 14px; border-top: 1px solid rgba(255,255,255,.08);
text-align: center; background: #111;
}
.notif-footer a, .view-all-notifs {
color: var(--accent, #e91e8c); font-size: .78rem; text-decoration: none;
}
.notif-footer a:hover { text-decoration: underline; }
.scroll-id-link { .scroll-id-link {
display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--accent); font-weight: 700; display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--accent); font-weight: 700;
text-decoration: none; margin-top: 2px; pointer-events: all; position: relative; z-index: 11; width: fit-content; text-decoration: none; margin-top: 2px; pointer-events: all; position: relative; z-index: 11; width: fit-content;
@@ -277,6 +340,36 @@
.scroll-btn.faved .scroll-btn-icon i { color: #ff4081; } .scroll-btn.faved .scroll-btn-icon i { color: #ff4081; }
.scroll-btn.faved .scroll-btn-count { color: #ff4081; } .scroll-btn.faved .scroll-btn-count { color: #ff4081; }
/* Add to site button (External items) */
.scroll-btn.rehost-btn .scroll-btn-icon {
background: rgba(255, 255, 255, .15);
border-color: var(--accent);
color: var(--accent);
font-size: 1.4rem;
}
.scroll-btn.rehost-btn:hover .scroll-btn-icon {
background: var(--accent);
color: #000;
}
.scroll-btn.rehost-btn.loading .scroll-btn-icon i {
animation: spin .8s linear infinite;
}
.scroll-btn.rehost-btn.success .scroll-btn-icon {
background: #4caf50;
border-color: #4caf50;
color: #fff;
}
/* Progress indicators for rehosting */
.rehost-progress-toast {
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.85); backdrop-filter: blur(10px);
padding: 10px 20px; border-radius: 50px; border: 1px solid var(--accent);
z-index: 1000; font-size: 0.85rem; font-weight: 700; color: #fff;
display: none; align-items: center; gap: 10px;
}
.rehost-progress-toast.show { display: flex; }
/* ── PROGRESS BAR ─────────────────────────────── */ /* ── PROGRESS BAR ─────────────────────────────── */
.scroll-progress-bar { .scroll-progress-bar {
position: absolute; bottom: 0; left: 0; right: 0; height: 4px; position: absolute; bottom: 0; left: 0; right: 0; height: 4px;
@@ -498,7 +591,26 @@
.scroller-spoiler.revealed, .scroller-spoiler:hover { color: inherit; background: rgba(255,255,255,.1); } .scroller-spoiler.revealed, .scroller-spoiler:hover { color: inherit; background: rgba(255,255,255,.1); }
.scroller-blur { filter: blur(5px); cursor: pointer; transition: filter .2s; display: inline-block; } .scroller-blur { filter: blur(5px); cursor: pointer; transition: filter .2s; display: inline-block; }
.scroller-blur.revealed, .scroller-blur:hover { filter: none; } .scroller-blur.revealed, .scroller-blur:hover { filter: none; }
.comment-time { font-size: .67rem; color: rgba(255,255,255,.38); margin-top: 4px; } .comment-meta { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.comment-time { font-size: .67rem; color: rgba(255,255,255,.38); }
.comment-reply-btn {
background: none; border: none; color: rgba(255,255,255,.45); font-size: .67rem;
font-weight: 700; cursor: pointer; padding: 0; text-transform: none;
}
.comment-reply-btn:hover { color: var(--accent, #fff); }
#reply-indicator {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; background: rgba(255,255,255,.05);
border-top: 2px solid var(--accent, #4fc3f7);
font-size: .78rem; color: rgba(255,255,255,.65);
}
.reply-indicator-icon { font-size: .72rem; color: var(--accent, #4fc3f7); }
.reply-indicator-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#reply-cancel-btn {
background: none; border: none; color: rgba(255,255,255,.4); cursor: pointer;
padding: 2px 4px; font-size: .8rem;
}
#reply-cancel-btn:hover { color: #fff; }
#comments-loading, #comments-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.35); font-size: .85rem; } #comments-loading, #comments-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.35); font-size: .85rem; }
#comments-loading .loader-spinner { margin: 0 auto 10px; } #comments-loading .loader-spinner { margin: 0 auto 10px; }
@@ -687,6 +799,101 @@
} }
.scroll-tag-sugg-item:hover, .scroll-tag-sugg-item.selected { background: rgba(255,255,255,.08); } .scroll-tag-sugg-item:hover, .scroll-tag-sugg-item.selected { background: rgba(255,255,255,.08); }
.scroll-tag-sugg-count { font-size: .72rem; color: rgba(255,255,255,.35); margin-left: auto; } .scroll-tag-sugg-count { font-size: .72rem; color: rgba(255,255,255,.35); margin-left: auto; }
/* ── 4CHAN CATALOG GRID ──────────────────────── */
#chan-panel { z-index: 802; }
.chan-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.chan-thread-card {
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
border-radius: 12px; overflow: hidden; cursor: pointer; transition: background .14s, border-color .14s, transform .12s;
}
.chan-thread-card:hover { background: rgba(255,255,255,.1); border-color: var(--accent); transform: translateY(-2px); }
.chan-thread-card.sticky { border-color: rgba(255,200,0,.3); }
.chan-thread-thumb {
width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block;
background: rgba(255,255,255,.03);
}
.chan-thread-info { padding: 8px 10px; }
.chan-thread-sub {
font-size: .76rem; font-weight: 700; color: #fff; line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
min-height: 1.3em;
}
.chan-thread-com {
font-size: .68rem; color: rgba(255,255,255,.5); margin-top: 3px; line-height: 1.25;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.chan-thread-stats {
display: flex; gap: 10px; margin-top: 6px; font-size: .65rem; color: rgba(255,255,255,.4); font-weight: 600;
}
.chan-thread-stats i { margin-right: 3px; font-size: .58rem; }
#chan-open-btn i { color: #789922; }
#chan-open-btn:hover i { color: var(--accent); }
#chan-gallery-btn.active i { color: var(--accent); }
/* ── Thread Gallery Sidebar ──────── */
#chan-gallery-sidebar {
position: fixed; top: 55px; right: 0; bottom: 5px; width: 180px;
background: rgba(10,10,14,.92); backdrop-filter: blur(20px);
border-left: 1px solid rgba(255,255,255,.08);
overflow-y: auto; overflow-x: hidden;
z-index: 90; display: none;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.15) transparent;
}
#chan-gallery-sidebar.open { display: block; }
#chan-gallery-sidebar::-webkit-scrollbar { width: 4px; }
#chan-gallery-sidebar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
.gallery-thumb {
width: 100%; aspect-ratio: 1; object-fit: cover; display: block;
cursor: pointer; opacity: .6; transition: opacity .15s, outline-color .15s;
border-bottom: 1px solid rgba(255,255,255,.06);
outline: 2px solid transparent; outline-offset: -2px;
}
.gallery-thumb:hover { opacity: 1; }
.gallery-thumb.active { opacity: 1; outline-color: var(--accent); }
.gallery-thumb-wrap {
position: relative;
}
.gallery-thumb-idx {
position: absolute; top: 3px; left: 5px;
font-size: .6rem; font-weight: 700; color: rgba(255,255,255,.7);
text-shadow: 0 1px 3px rgba(0,0,0,.8);
pointer-events: none;
}
/* When gallery is open, narrow the feed */
body.gallery-open #scroller-feed { margin-right: 180px; }
body.gallery-open .scroll-actions { right: 192px; }
@media (max-width: 600px) {
#chan-gallery-sidebar { width: 100px; top: 50px; }
body.gallery-open #scroller-feed { margin-right: 100px; }
body.gallery-open .scroll-actions { right: 112px; }
}
/* ── LEFT HAND MODE ──────────────────────── */
body.left-hand-mode .scroll-actions { right: auto; left: 10px; }
body.left-hand-mode .scroll-meta { right: 0; left: 72px; padding-left: 0; padding-right: 16px; text-align: right; }
body.left-hand-mode .scroll-meta-inner { align-items: flex-end; }
body.left-hand-mode .scroll-meta-top { flex-direction: row-reverse; }
body.left-hand-mode .scroll-tags { justify-content: flex-end; }
body.left-hand-mode .scroll-badges { justify-content: flex-end; }
body.left-hand-mode.gallery-open .scroll-actions { left: 10px; right: auto; }
body.left-hand-mode .topbar-right #chan-open-btn,
body.left-hand-mode .topbar-right #chan-gallery-btn { display: none !important; }
/* 4chan buttons styled as side-action buttons when inside scroll-actions */
.scroll-actions .chan-action-btn {
display: flex; flex-direction: column; align-items: center; gap: 3px;
cursor: pointer; color: #fff; background: none; border: none; padding: 0;
transition: transform .12s;
}
.scroll-actions .chan-action-btn:hover { transform: scale(1.14); }
.scroll-actions .chan-action-btn .scroll-btn-icon {
width: 46px; height: 46px; border-radius: 50%;
background: rgba(255,255,255,.1); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,.14); display: flex; align-items: center; justify-content: center;
font-size: 1.05rem; transition: background .17s;
}
.scroll-actions .chan-action-btn:hover .scroll-btn-icon { background: rgba(255,255,255,.22); }
.scroll-actions .chan-action-btn .scroll-btn-label { font-size: .6rem; font-weight: 600; letter-spacing: .03em; color: rgba(255,255,255,.75); }
</style> </style>
</head> </head>
<body class="scroller-active"> <body class="scroller-active">
@@ -703,36 +910,128 @@
window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif; window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif;
window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckAllowedImages = {{ allowed_comment_images_json }};
window.scrollerPublic = {{ private_society ? 'false' : 'true' }}; window.scrollerPublic = {{ private_society ? 'false' : 'true' }};
window.scrollerMimeCats = {{ JSON.stringify(scroller_mime_cats) }};
@if(typeof session !== 'undefined' && session) @if(typeof session !== 'undefined' && session)
window.scrollerUsername = "{{ session.user || '' }}"; window.scrollerUsername = "{{ session.user || '' }}";
window.scrollerDisplayName = "{{ session.display_name || session.user || '' }}"; window.scrollerDisplayName = "{{ session.display_name || session.user || '' }}";
window.scrollerUserAvatar = "{{ session.avatar_file ? '/a/' + session.avatar_file : (session.avatar ? '/t/' + session.avatar + '.webp' : '/a/default.png') }}"; window.scrollerUserAvatar = "{{ session.avatar_file ? '/a/' + session.avatar_file : (session.avatar ? '/t/' + session.avatar + '.webp' : '/a/default.png') }}";
window.f0ckI18n = {
// notifications
notif_upload_approved: "{{ t('notifications.upload_approved_short') }}",
notif_upload_pending: "{{ t('notifications.upload_pending_short') }}",
notif_new_report: "{{ t('notifications.new_report_short') }}",
notif_upload_denied: "{{ t('notifications.upload_denied_short') }}",
notif_upload_deleted: "{{ t('notifications.upload_deleted_short') }}",
notif_upload_success: "{{ t('notifications.upload_success') }}",
notif_upload_error: "{{ t('notifications.upload_error') }}",
notif_replied: "{{ t('notifications.replied_short') }}",
notif_subscribed: "{{ t('notifications.subscribed_short') }}",
notif_mentioned: "{{ t('notifications.mentioned_short') }}",
notif_commented: "{{ t('notifications.commented') }}",
notif_system: "{{ t('notifications.system') }}",
notif_admin: "{{ t('notifications.admin') }}",
notif_moderation: "{{ t('notifications.moderation') }}",
notif_tab_user: "{{ t('nav.notif_tab_user') }}",
notif_tab_system: "{{ t('nav.notif_tab_system') }}",
no_notifications: "{{ t('nav.no_notifications') }}",
// scroller
just_now: "{{ t('scroller.just_now') }}",
add: "{{ t('scroller.add') }}",
update_preset: "{{ t('scroller.update_preset') }}",
update_preset_sub: "{{ t('scroller.update_preset_sub') }}",
no_presets: "{{ t('scroller.no_presets') }}",
copy_clipboard: "{{ t('scroller.copy_clipboard') }}",
copied: "{{ t('scroller.copied') }}",
recent: "{{ t('scroller.recent') }}",
nothing_found: "{{ t('scroller.nothing_found') }}",
adjust_filters: "{{ t('scroller.adjust_filters') }}",
failed_load_comments: "{{ t('scroller.failed_load_comments') }}",
no_custom_emojis: "{{ t('scroller.no_custom_emojis') }}",
login_required: "{{ t('scroller.login_required') }}",
rehost_failed: "{{ t('scroller.rehost_failed') }}",
chan_load_failed: "{{ t('scroller.chan_load_failed') }}",
fetch_failed: "{{ t('scroller.fetch_failed') }}",
invalid_chan_url: "{{ t('scroller.invalid_chan_url') }}",
chan_catalog_failed: "{{ t('scroller.chan_catalog_failed') }}",
anonymous: "{{ t('scroller.anonymous') }}",
// timeago
ta_just_now: "{{ t('timeago.just_now') }}",
ta_second: "{{ t('timeago.second') }}",
ta_seconds: "{{ t('timeago.seconds') }}",
ta_minute: "{{ t('timeago.minute') }}",
ta_minutes: "{{ t('timeago.minutes') }}",
ta_hour: "{{ t('timeago.hour') }}",
ta_hours: "{{ t('timeago.hours') }}",
ta_day: "{{ t('timeago.day') }}",
ta_days: "{{ t('timeago.days') }}",
ta_week: "{{ t('timeago.week') }}",
ta_weeks: "{{ t('timeago.weeks') }}",
ta_month: "{{ t('timeago.month') }}",
ta_months: "{{ t('timeago.months') }}",
ta_year: "{{ t('timeago.year') }}",
ta_years: "{{ t('timeago.years') }}",
ta_ago: "{{ t('timeago.ago') }}",
// actions
favourite: "{{ t('scroller.favourite') }}",
comments_label: "{{ t('scroller.comments') }}",
add_tag: "{{ t('scroller.add_tag') }}",
share_label: "{{ t('scroller.share') }}",
open_label: "{{ t('scroller.open') }}",
add_label: "{{ t('scroller.add') }}",
view_label: "{{ t('scroller.view') }}",
open_post: "{{ t('scroller.open_post') }}",
already_added: "{{ t('scroller.already_added') }}",
add_to_site: "{{ t('scroller.add_to_site') }}",
add_to_site_first: "{{ t('scroller.add_to_site_first') }}",
// reply
replying_to: "{{ t('scroller.replying_to') }}",
reply: "{{ t('scroller.reply') }}"
};
@endif @endif
</script> </script>
<!-- Loader --> <!-- Loader -->
<div id="scroller-loader"> <div id="scroller-loader">
<div class="loader-logo">{{ domain }}</div> <div class="loader-logo">{{ domain }}</div>
<div class="loader-sub">doomscroll</div> <div class="loader-sub">{{ t('scroller.doomscroll') }}</div>
<div class="loader-spinner"></div> <div class="loader-spinner"></div>
</div> </div>
<!-- Top bar --> <!-- Top bar -->
<div id="scroller-topbar"> <div id="scroller-topbar">
<div class="topbar-left"> <div class="topbar-left">
<a id="scroller-back" class="topbar-icon-btn" href="/" title="Back"><i class="fa-solid fa-arrow-left"></i></a> <a id="scroller-back" class="topbar-icon-btn" href="/" title="{{ t('scroller.back') }}"><i class="fa-solid fa-arrow-left"></i></a>
<div id="filter-active-summary"></div> <div id="filter-active-summary"></div>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<button id="settings-open-btn" class="topbar-icon-btn" title="Settings"><i class="fa-solid fa-gear"></i></button> <button id="chan-open-btn" class="topbar-icon-btn" title="{{ t('scroller.chan_threads') }}" style="display:none"><i class="fa-solid fa-clover"></i></button>
<button id="filter-open-btn" class="topbar-icon-btn" title="Filters (F)"><i class="fa-solid fa-sliders"></i></button> <button id="chan-gallery-btn" class="topbar-icon-btn" title="{{ t('scroller.thread_gallery') }} (G)" style="display:none"><i class="fa-solid fa-grip"></i></button>
<button id="settings-open-btn" class="topbar-icon-btn" title="{{ t('scroller.settings') }}"><i class="fa-solid fa-gear"></i></button>
<button id="filter-open-btn" class="topbar-icon-btn" title="{{ t('scroller.filters') }} (F)"><i class="fa-solid fa-sliders"></i></button>
@if(typeof session !== 'undefined' && session) @if(typeof session !== 'undefined' && session)
<a id="scroller-notif-btn" class="topbar-icon-btn" href="/notifications" title="Notifications"> <div id="scroller-notif-wrap" style="position:relative;">
<button id="scroller-notif-btn" class="topbar-icon-btn" title="Notifications">
<i class="fa-solid fa-bell"></i> <i class="fa-solid fa-bell"></i>
<span id="scroller-notif-badge"></span> <span id="scroller-notif-badge"></span>
</a> </button>
<div id="scroller-notif-dropdown" class="notif-dropdown" style="position:fixed; z-index:99999; display:none;">
<div class="notif-header">
<div class="notif-tabs">
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-user" style="display:none">0</span></button>
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
</div>
<button id="scroller-mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div>
<div class="notif-list" id="scroller-notif-list" data-active-tab="user">
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
</div>
<div class="notif-footer">
<a href="/notifications" target="_blank" class="view-all-notifs">{{ t('nav.view_all_notifications') }}</a>
</div>
</div>
</div>
@endif @endif
<button id="scroller-mute-btn" class="topbar-icon-btn" title="Volume (M)"><i class="fa-solid fa-volume-xmark"></i></button> <button id="scroller-mute-btn" class="topbar-icon-btn" title="{{ t('scroller.volume') }} (M)"><i class="fa-solid fa-volume-xmark"></i></button>
</div> </div>
</div> </div>
@@ -743,13 +1042,17 @@
<span class="volume-label"><i class="fa-solid fa-volume-low"></i></span> <span class="volume-label"><i class="fa-solid fa-volume-low"></i></span>
</div> </div>
<!-- Thread Gallery Sidebar -->
<div id="chan-gallery-sidebar"></div>
<!-- Feed --> <!-- Feed -->
<div id="scroller-feed" role="feed" aria-label="Doomscroll feed"> <div id="scroller-feed" role="feed" aria-label="Doomscroll feed">
<div id="scroller-sentinel"></div> <div id="scroller-sentinel"></div>
<div id="scroller-empty"> <div id="scroller-empty">
<i class="fa-solid fa-binoculars"></i> <i class="fa-solid fa-binoculars"></i>
<p>Nothing found with current filters</p> <p>{{ t('scroller.nothing_found') }}</p>
<button onclick="document.getElementById('filter-open-btn').click()" style="margin-top:8px;padding:8px 20px;border-radius:50px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;cursor:pointer;font-size:.8rem;">Adjust filters</button> <button onclick="document.getElementById('filter-open-btn').click()" style="margin-top:8px;padding:8px 20px;border-radius:50px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;cursor:pointer;font-size:.8rem;">{{ t('scroller.adjust_filters') }}</button>
</div> </div>
</div> </div>
@@ -758,12 +1061,12 @@
<div id="filter-panel" class="scroller-panel" role="dialog" aria-label="Feed filters"> <div id="filter-panel" class="scroller-panel" role="dialog" aria-label="Feed filters">
<div class="panel-handle"><div class="panel-handle-bar"></div></div> <div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Filters</span> <span class="panel-title">{{ t('scroller.filters') }}</span>
<button class="filter-reset-btn" id="filter-reset-btn">Reset all</button> <button class="filter-reset-btn" id="filter-reset-btn">{{ t('scroller.reset_all') }}</button>
</div> </div>
<div class="filter-scroll-area"> <div class="filter-scroll-area">
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-label">Rating</div> <div class="filter-section-label">{{ t('scroller.rating') }}</div>
<div class="pill-group" id="mode-pills"> <div class="pill-group" id="mode-pills">
<button class="filter-pill active" data-mode="0"><i class="fa-solid fa-shield-halved"></i>SFW</button> <button class="filter-pill active" data-mode="0"><i class="fa-solid fa-shield-halved"></i>SFW</button>
@if(session) @if(session)
@@ -773,40 +1076,41 @@
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button> <button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
@endif @endif
@if(session) @if(session)
<button class="filter-pill" data-mode="3">All</button> <button class="filter-pill" data-mode="3">{{ t('scroller.all') }}</button>
@endif @endif
@if(session && (session.admin || session.is_moderator)) @if(session && (session.admin || session.is_moderator))
<button class="filter-pill" data-mode="2">Untagged</button> <button class="filter-pill" data-mode="2">{{ t('scroller.untagged') }}</button>
@endif @endif
</div> </div>
</div> </div>
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-label">Media type</div> <div class="filter-section-label">{{ t('scroller.media_type') }}</div>
<div class="pill-group" id="mime-pills"> <div class="pill-group" id="mime-pills">
<button class="filter-pill active" data-mime=""><i class="fa-solid fa-layer-group"></i>All</button> <button class="filter-pill active" data-mime=""><i class="fa-solid fa-layer-group"></i>{{ t('scroller.all') }}</button>
@if(scroller_mime_cats.includes('video')) @if(scroller_mime_cats.includes('video'))
<button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>Video</button> <button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>{{ t('scroller.video') }}</button>
@endif @endif
@if(scroller_mime_cats.includes('image')) @if(scroller_mime_cats.includes('image'))
<button class="filter-pill" data-mime="image"><i class="fa-solid fa-image"></i>Image</button> <button class="filter-pill" data-mime="image"><i class="fa-solid fa-image"></i>{{ t('scroller.image') }}</button>
@endif @endif
@if(scroller_mime_cats.includes('audio')) @if(scroller_mime_cats.includes('audio'))
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>Audio</button> <button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>{{ t('scroller.audio') }}</button>
@endif @endif
</div> </div>
</div> </div>
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-label">Order</div> <div class="filter-section-label">{{ t('scroller.order') }}</div>
<div class="pill-group" id="order-pills"> <div class="pill-group" id="order-pills">
<button class="filter-pill active" data-order="random"><i class="fa-solid fa-shuffle"></i>Random</button> <button class="filter-pill active" data-order="random"><i class="fa-solid fa-shuffle"></i>{{ t('scroller.random') }}</button>
<button class="filter-pill" data-order="newest"><i class="fa-solid fa-clock-rotate-left"></i>Newest</button> <button class="filter-pill" data-order="newest"><i class="fa-solid fa-clock-rotate-left"></i>{{ t('scroller.newest') }}</button>
<button class="filter-pill" data-order="oldest"><i class="fa-solid fa-hourglass-start"></i>Oldest</button> <button class="filter-pill" data-order="oldest"><i class="fa-solid fa-hourglass-start"></i>{{ t('scroller.oldest') }}</button>
</div> </div>
</div> </div>
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-label">Tags</div> <div class="filter-section-label">{{ t('scroller.tags') }}</div>
<div class="tag-search-wrap"> <div class="tag-search-wrap">
<input type="text" id="filter-tag-input" placeholder="Search tags" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> <input type="text" id="filter-tag-input" placeholder="{{ t('scroller.search_tags') }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
<span class="tag-search-icon"><i class="fa-solid fa-tag"></i></span> <span class="tag-search-icon"><i class="fa-solid fa-tag"></i></span>
<button id="filter-tag-clear"><i class="fa-solid fa-xmark"></i></button> <button id="filter-tag-clear"><i class="fa-solid fa-xmark"></i></button>
</div> </div>
@@ -814,12 +1118,48 @@
<div id="filter-active-tags"></div> <div id="filter-active-tags"></div>
</div> </div>
<div class="filter-section" style="margin-top:8px"> <div class="filter-section" style="margin-top:8px">
<div class="filter-section-label">Saved presets</div> <div class="filter-section-label">{{ t('scroller.saved_presets') }}</div>
<button class="settings-save-preset-btn" id="settings-save-preset-btn"><i class="fa-solid fa-floppy-disk"></i> Save current filters as preset</button> <button class="settings-save-preset-btn" id="settings-save-preset-btn"><i class="fa-solid fa-floppy-disk"></i> {{ t('scroller.save_preset') }}</button>
<div id="settings-presets-list"></div> <div id="settings-presets-list"></div>
</div> </div>
</div> </div>
<button class="filter-apply-btn" id="filter-apply-btn"><i class="fa-solid fa-check" style="margin-right:8px"></i>Apply &amp; Reload</button> <button class="filter-apply-btn" id="filter-apply-btn"><i class="fa-solid fa-check" style="margin-right:8px"></i>{{ t('scroller.apply_reload') }}</button>
</div>
<!-- 4CHAN PANEL -->
<div id="chan-backdrop" class="scroller-backdrop"></div>
<div id="chan-panel" class="scroller-panel" role="dialog" aria-label="4chan threads">
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header">
<span class="panel-title"><i class="fa-solid fa-clover" style="margin-right:6px;color:var(--accent)"></i>{{ t('scroller.chan_threads') }}</span>
</div>
<div class="filter-scroll-area">
<div class="filter-section">
<div class="filter-section-label">{{ t('scroller.load_by_url') }}</div>
<div style="display:flex;gap:8px">
<input type="text" id="chan-url-input" style="flex:1;padding:10px 14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:10px;color:#fff;font-size:.86rem;outline:none;" placeholder="https://boards.4chan.org/wsg/thread/..." autocomplete="off">
<button id="chan-url-load-btn" style="padding:10px 18px;background:var(--accent);color:#000;border:none;border-radius:10px;font-weight:700;font-size:.84rem;cursor:pointer;white-space:nowrap;">{{ t('scroller.load') }}</button>
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">{{ t('scroller.browse_boards') }}</div>
<div class="pill-group" id="chan-board-pills">
<button class="filter-pill" data-board="gif"><i class="fa-solid fa-fire"></i>/gif/</button>
<button class="filter-pill active" data-board="wsg"><i class="fa-solid fa-shield-halved"></i>/wsg/</button>
<div style="display:flex;align-items:center;gap:4px;margin-left:4px;">
<span style="color:rgba(255,255,255,.4);font-size:.8rem">/</span>
<input type="text" id="chan-custom-board" style="width:48px;padding:6px 8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:8px;color:#fff;font-size:.82rem;outline:none;text-align:center;" placeholder="v" maxlength="6" autocomplete="off">
<span style="color:rgba(255,255,255,.4);font-size:.8rem">/</span>
<button id="chan-custom-board-btn" class="filter-pill" style="padding:6px 10px;font-size:.78rem;">{{ t('scroller.go') }}</button>
</div>
</div>
</div>
<div class="filter-section" style="padding-top:8px">
<input type="text" id="chan-catalog-search" style="width:100%;padding:10px 14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:10px;color:#fff;font-size:.86rem;outline:none;margin-bottom:12px;box-sizing:border-box;" placeholder="{{ t('scroller.search_threads') }}" autocomplete="off">
<div id="chan-catalog-grid" class="chan-grid"></div>
<div id="chan-catalog-loading" style="text-align:center;padding:30px;color:rgba(255,255,255,.35);display:none"><div class="loader-spinner" style="margin:0 auto 10px"></div>{{ t('scroller.loading_catalog') }}</div>
</div>
</div>
</div> </div>
<!-- SETTINGS PANEL --> <!-- SETTINGS PANEL -->
@@ -827,46 +1167,53 @@
<div id="settings-panel" class="scroller-panel" role="dialog" aria-label="Scroller settings"> <div id="settings-panel" class="scroller-panel" role="dialog" aria-label="Scroller settings">
<div class="panel-handle"><div class="panel-handle-bar"></div></div> <div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Settings</span> <span class="panel-title">{{ t('scroller.settings') }}</span>
</div> </div>
<div class="filter-scroll-area"> <div class="filter-scroll-area">
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-label">Appearance</div> <div class="filter-section-label">{{ t('scroller.appearance') }}</div>
<div class="settings-toggle-row"> <div class="settings-toggle-row">
<div class="settings-toggle-info"> <div class="settings-toggle-info">
<span class="settings-toggle-name">Hide UI</span> <span class="settings-toggle-name">{{ t('scroller.hide_ui') }}</span>
<span class="settings-toggle-desc">Hides the top bar and action buttons for full immersion</span> <span class="settings-toggle-desc">{{ t('scroller.hide_ui_desc') }}</span>
</div> </div>
<div class="settings-toggle-switch" id="st-hide-ui"><div class="settings-toggle-knob"></div></div> <div class="settings-toggle-switch" id="st-hide-ui"><div class="settings-toggle-knob"></div></div>
</div> </div>
<div class="settings-toggle-row"> <div class="settings-toggle-row">
<div class="settings-toggle-info"> <div class="settings-toggle-info">
<span class="settings-toggle-name">Start with sound</span> <span class="settings-toggle-name">{{ t('scroller.start_sound') }}</span>
<span class="settings-toggle-desc">Automatically unmute when you open the scroller</span> <span class="settings-toggle-desc">{{ t('scroller.start_sound_desc') }}</span>
</div> </div>
<div class="settings-toggle-switch" id="st-start-unmuted"><div class="settings-toggle-knob"></div></div> <div class="settings-toggle-switch" id="st-start-unmuted"><div class="settings-toggle-knob"></div></div>
</div> </div>
<div class="settings-toggle-row"> <div class="settings-toggle-row">
<div class="settings-toggle-info"> <div class="settings-toggle-info">
<span class="settings-toggle-name">Animated background</span> <span class="settings-toggle-name">{{ t('scroller.animated_bg') }}</span>
<span class="settings-toggle-desc">Live video frames behind the player; disable for static thumbnail</span> <span class="settings-toggle-desc">{{ t('scroller.animated_bg_desc') }}</span>
</div> </div>
<div class="settings-toggle-switch" id="st-canvas-bg"><div class="settings-toggle-knob"></div></div> <div class="settings-toggle-switch" id="st-canvas-bg"><div class="settings-toggle-knob"></div></div>
</div> </div>
</div>
<div class="filter-section">
<div class="filter-section-label">Playback</div>
<div class="settings-toggle-row"> <div class="settings-toggle-row">
<div class="settings-toggle-info"> <div class="settings-toggle-info">
<span class="settings-toggle-name">Auto-next</span> <span class="settings-toggle-name">{{ t('scroller.left_hand') }}</span>
<span class="settings-toggle-desc">Automatically advance to the next item when media ends</span> <span class="settings-toggle-desc">{{ t('scroller.left_hand_desc') }}</span>
</div>
<div class="settings-toggle-switch" id="st-left-hand"><div class="settings-toggle-knob"></div></div>
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">{{ t('scroller.playback') }}</div>
<div class="settings-toggle-row">
<div class="settings-toggle-info">
<span class="settings-toggle-name">{{ t('scroller.auto_next') }}</span>
<span class="settings-toggle-desc">{{ t('scroller.auto_next_desc') }}</span>
</div> </div>
<div class="settings-toggle-switch" id="st-auto-next"><div class="settings-toggle-knob"></div></div> <div class="settings-toggle-switch" id="st-auto-next"><div class="settings-toggle-knob"></div></div>
</div> </div>
<div class="settings-toggle-row" style="margin-top:2px;"> <div class="settings-toggle-row" style="margin-top:2px;">
<div class="settings-toggle-info"> <div class="settings-toggle-info">
<span class="settings-toggle-name">Loops before next</span> <span class="settings-toggle-name">{{ t('scroller.loops_before_next') }}</span>
<span class="settings-toggle-desc">How many times to play before advancing (videos &amp; audio)</span> <span class="settings-toggle-desc">{{ t('scroller.loops_before_next_desc') }}</span>
</div> </div>
<input type="number" id="st-auto-next-loops" min="0" max="99" value="1" <input type="number" id="st-auto-next-loops" min="0" max="99" value="1"
style="width:56px;padding:5px 8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:.88rem;text-align:center;outline:none;" /> style="width:56px;padding:5px 8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:.88rem;text-align:center;outline:none;" />
@@ -881,26 +1228,31 @@
<div id="comments-panel" class="scroller-panel" role="dialog" aria-label="Comments"> <div id="comments-panel" class="scroller-panel" role="dialog" aria-label="Comments">
<div class="panel-handle"><div class="panel-handle-bar"></div></div> <div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Comments <span id="comments-count" style="color:rgba(255,255,255,.45);font-weight:400;font-size:.85rem"></span></span> <span class="panel-title">{{ t('scroller.comments') }} <span id="comments-count" style="color:rgba(255,255,255,.45);font-weight:400;font-size:.85rem"></span></span>
<a id="comments-open-link" href="#" target="_blank" style="color:var(--accent);font-size:.78rem;font-weight:700;text-decoration:none;padding:4px 8px;"> <a id="comments-open-link" href="#" target="_blank" style="color:var(--accent);font-size:.78rem;font-weight:700;text-decoration:none;padding:4px 8px;">
<i class="fa-solid fa-up-right-from-square" style="margin-right:4px;font-size:.72rem"></i>Open <i class="fa-solid fa-up-right-from-square" style="margin-right:4px;font-size:.72rem"></i>{{ t('scroller.open') }}
</a> </a>
</div> </div>
<div id="comments-list"> <div id="comments-list">
<div id="comments-loading"><div class="loader-spinner"></div>Loading</div> <div id="comments-loading"><div class="loader-spinner"></div>{{ t('scroller.loading') }}</div>
<div id="comments-empty" style="display:none"><i class="fa-regular fa-comment" style="font-size:2rem;display:block;margin-bottom:10px"></i>No comments yet</div> <div id="comments-empty" style="display:none"><i class="fa-regular fa-comment" style="font-size:2rem;display:block;margin-bottom:10px"></i>{{ t('scroller.no_comments') }}</div>
</div> </div>
@if(typeof session !== 'undefined' && session) @if(typeof session !== 'undefined' && session)
<div id="comments-input-area"> <div id="comments-input-area">
<div id="mention-dropdown"></div> <div id="mention-dropdown"></div>
<div id="reply-indicator" style="display:none">
<i class="fa-solid fa-reply reply-indicator-icon"></i>
<span class="reply-indicator-text"></span>
<button id="reply-cancel-btn" type="button"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="comment-input-row"> <div class="comment-input-row">
<button id="comment-emoji-trigger" class="emoji-trigger" title="Emoji" type="button"></button> <button id="comment-emoji-trigger" class="emoji-trigger" title="Emoji" type="button"></button>
<textarea id="comment-input" rows="1" placeholder="Write a comment..." maxlength="2000"></textarea> <textarea id="comment-input" rows="1" placeholder="{{ t('scroller.write_comment') }}" maxlength="2000"></textarea>
<button id="comment-send-btn" disabled><i class="fa-solid fa-paper-plane"></i></button> <button id="comment-send-btn" disabled><i class="fa-solid fa-paper-plane"></i></button>
</div> </div>
</div> </div>
@else @else
<div class="comments-login-note"><a href="/login">Log in</a> to comment</div> <div class="comments-login-note"><a href="/login">{{ t('scroller.login_to_comment') }}</a></div>
@endif @endif
</div> </div>
@@ -909,11 +1261,11 @@
<div id="tag-bar"> <div id="tag-bar">
<div id="tag-bar-inner"> <div id="tag-bar-inner">
<div style="position:relative;flex:1;min-width:0"> <div style="position:relative;flex:1;min-width:0">
<input id="scroll-tag-input" type="text" placeholder="Add a tag to this item…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="70"> <input id="scroll-tag-input" type="text" placeholder="{{ t('scroller.add_tag_placeholder') }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="70">
<div id="scroll-tag-suggestions"></div> <div id="scroll-tag-suggestions"></div>
</div> </div>
<button id="scroll-tag-send-btn" title="Add tag"><i class="fa-solid fa-plus"></i></button> <button id="scroll-tag-send-btn" title="{{ t('scroller.add_tag') }}"><i class="fa-solid fa-plus"></i></button>
<button id="tag-bar-close-btn" title="Close"><i class="fa-solid fa-xmark"></i></button> <button id="tag-bar-close-btn" title="{{ t('scroller.close') }}"><i class="fa-solid fa-xmark"></i></button>
</div> </div>
</div> </div>
@endif @endif
@@ -922,24 +1274,24 @@
<div id="share-backdrop"></div> <div id="share-backdrop"></div>
<div id="share-panel"> <div id="share-panel">
<div id="share-panel-handle"></div> <div id="share-panel-handle"></div>
<div id="share-panel-title">Share</div> <div id="share-panel-title">{{ t('scroller.share') }}</div>
<div class="share-row" id="share-copy-row"> <div class="share-row" id="share-copy-row">
<div class="share-row-icon copy-icon"><i class="fa-solid fa-link"></i></div> <div class="share-row-icon copy-icon"><i class="fa-solid fa-link"></i></div>
<div class="share-row-text"> <div class="share-row-text">
<div class="share-row-title">Copy link</div> <div class="share-row-title">{{ t('scroller.copy_link') }}</div>
<div class="share-row-sub" id="share-copy-sub">Copy to clipboard</div> <div class="share-row-sub" id="share-copy-sub">{{ t('scroller.copy_clipboard') }}</div>
</div> </div>
</div> </div>
@if(typeof session !== 'undefined' && session && private_messages) @if(typeof session !== 'undefined' && session && private_messages)
<div class="share-row" id="share-dm-row"> <div class="share-row" id="share-dm-row">
<div class="share-row-icon dm-icon"><i class="fa-solid fa-paper-plane"></i></div> <div class="share-row-icon dm-icon"><i class="fa-solid fa-paper-plane"></i></div>
<div class="share-row-text"> <div class="share-row-text">
<div class="share-row-title">Send via DM</div> <div class="share-row-title">{{ t('scroller.send_dm') }}</div>
<div class="share-row-sub">Share to a user's inbox</div> <div class="share-row-sub">{{ t('scroller.share_inbox') }}</div>
</div> </div>
</div> </div>
<div id="share-dm-search"> <div id="share-dm-search">
<input id="share-user-input" type="text" placeholder="Search for a user…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"> <input id="share-user-input" type="text" placeholder="{{ t('scroller.search_user') }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<div id="share-user-results"></div> <div id="share-user-results"></div>
</div> </div>
@endif @endif

View File

@@ -490,6 +490,8 @@
notif_system: "{{ t('notifications.system') }}", notif_system: "{{ t('notifications.system') }}",
notif_admin: "{{ t('notifications.admin') }}", notif_admin: "{{ t('notifications.admin') }}",
notif_moderation: "{{ t('notifications.moderation') }}", notif_moderation: "{{ t('notifications.moderation') }}",
notif_tab_user: "{{ t('nav.notif_tab_user') }}",
notif_tab_system: "{{ t('nav.notif_tab_system') }}",
no_notifications: "{{ t('nav.no_notifications') }}", no_notifications: "{{ t('nav.no_notifications') }}",
// meme creator // meme creator
meme: { meme: {

View File

@@ -46,7 +46,7 @@
@endif @endif
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}"> <link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
@endif @endif
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }};</script> <script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}";</script>
@if(!private_society || session) @if(!private_society || session)
<script src="/s/js/marked.min.js" defer></script> <script src="/s/js/marked.min.js" defer></script>
<script src="/s/js/comments.js?v={{ ts }}" defer></script> <script src="/s/js/comments.js?v={{ ts }}" defer></script>

View File

@@ -24,7 +24,7 @@
</a> </a>
</div> </div>
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tag"></i></a> <a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tag"></i></a>
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dungeon"></i></a> <a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
<a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a> <a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a>
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d$/.test(url.pathname))
<a href="/random" id="nav-random" title="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a> <a href="/random" id="nav-random" title="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a>
@@ -80,10 +80,13 @@
</a> </a>
<div id="notif-dropdown" class="notif-dropdown"> <div id="notif-dropdown" class="notif-dropdown">
<div class="notif-header"> <div class="notif-header">
<span>{{ t('nav.notifications') }}</span> <div class="notif-tabs">
<button id="mark-all-read">{{ t('nav.mark_all_read') }}</button> <button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="notif-tab-badge-user" style="display:none">0</span></button>
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
</div> </div>
<div class="notif-list"> <button id="mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div>
<div class="notif-list" data-active-tab="user">
<div class="notif-empty">{{ t('nav.no_notifications') }}</div> <div class="notif-empty">{{ t('nav.no_notifications') }}</div>
</div> </div>
<div class="notif-footer"> <div class="notif-footer">
@@ -165,7 +168,7 @@
</div> </div>
</div> </div>
<a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a> <a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a>
<a href="/abyss" title="Abyss"><i class="fa-solid fa-dungeon"></i></a> <a href="/abyss" title="Abyss"><i class="fa-solid fa-dice-d6"></i></a>
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d$/.test(url.pathname))
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a> <a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
@endif @endif

View File

@@ -22,19 +22,15 @@
@if(enable_profile_description && user.description) @if(enable_profile_description && user.description)
<div class="profile_description">{!! user.description !!}</div> <div class="profile_description">{!! user.description !!}</div>
@endif @endif
@if(session && session.id !== user.user_id && private_messages)
<button id="send-dm-btn" class="btn btn-sm btn-outline-info" data-username="{!! user.user !!}" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.message_btn') }}</button>
@endif
@if(session && session.id === user.user_id)
<!--<button id="subscribe-all-uploads-btn" class="btn btn-sm btn-outline-info" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">Subscribe to all my uploads</button>-->
@endif
</div> </div>
<div class="profile_head_user_stats"> <div class="profile_head_user_stats">
@if(user.is_ghost) @if(user.is_ghost)
<div class="stat-legacy">{{ t('profile.legacy_record') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div> <div class="stat-legacy">{{ t('profile.legacy_record') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
@else @else
<div class="stat-id">ID: {{ user.user_id || user.id }}</div> <div class="stat-id">ID: {{ user.user_id || user.id }}</div>
<div class="stat-joined">{{ t('profile.joined') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
<div class="stat-joined" title="{{ user.timestamp.timefull }}">{{ t('profile.age_days', { n: user.age_days }) }}</div>
@if(!user.is_ghost) @if(!user.is_ghost)
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div> <div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
<div class="stat-tags">{{ t('profile.stat_tags') }} {{ count.tags }}</div> <div class="stat-tags">{{ t('profile.stat_tags') }} {{ count.tags }}</div>
@@ -42,25 +38,26 @@
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div> <div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
@endif @endif
@endif @endif
@if(session)
@if(session.id !== user.user_id) @endif
</div>
@if(session && session.id !== user.user_id)
<div class="profile-actions" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;">
@if(private_messages)
<button id="send-dm-btn" class="btn btn-sm btn-outline-info" data-username="{!! user.user !!}" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.message_btn') }}</button>
@endif
@if(session.admin || session.is_moderator) @if(session.admin || session.is_moderator)
@if(session.admin || !user.admin) @if(session.admin || !user.admin)
@if(user.banned) @if(user.banned)
<button id="unban-user-btn" class="btn btn-sm btn-outline-success" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #5cb85c; color: #5cb85c; background: transparent; cursor: pointer;">{{ t('profile.unban_btn') }}</button> <button id="unban-user-btn" class="btn btn-sm btn-outline-success" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid #5cb85c; color: #5cb85c; background: transparent; cursor: pointer;">{{ t('profile.unban_btn') }}</button>
@else @else
<button id="ban-user-btn" class="btn btn-sm btn-outline-danger" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.ban_btn') }}</button> <button id="ban-user-btn" class="btn btn-sm btn-outline-danger" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.ban_btn') }}</button>
@endif
<button id="warn-user-btn" class="btn btn-sm btn-outline-warning" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.warn_btn') }}</button>
@endif
@if(session.admin)
<button id="admin-subscribe-user-btn" class="btn btn-sm btn-outline-warning" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.subscribe_uploads_btn') }}</button>
@endif
@endif
@endif @endif
<button id="warn-user-btn" class="btn btn-sm btn-outline-warning" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.warn_btn') }}</button>
@endif @endif
@endif @endif
</div> </div>
@endif
</div> </div>
</div> </div>
<div class="user_content_wrapper"> <div class="user_content_wrapper">
@@ -313,39 +310,7 @@
}; };
} }
const subUserBtn = document.getElementById('admin-subscribe-user-btn');
if (subUserBtn && userId) {
subUserBtn.onclick = async () => {
if (!confirm('{{ t('profile.confirm_subscribe_uploads') }}')) return;
subUserBtn.disabled = true;
subUserBtn.innerText = '{{ t('profile.subscribing') }}';
try {
const res = await fetch('/api/v2/admin/subscribe-user-to-uploads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ user_id: userId })
});
const data = await res.json();
if (data.success) {
alert('{{ t('profile.subscribed') }}');
subUserBtn.innerText = '{{ t('profile.subscribed') }}';
} else {
alert('Error: ' + (data.msg || data.message));
subUserBtn.disabled = false;
subUserBtn.innerText = '{{ t('profile.subscribe_uploads_btn') }}';
}
} catch (err) {
alert('Failed to subscribe user: ' + err.message);
subUserBtn.disabled = false;
subUserBtn.innerText = '{{ t('profile.subscribe_uploads_btn') }}';
}
};
}
})(); })();
</script> </script>
@endif @endif
@@ -353,48 +318,7 @@
@endif @endif
@endif @endif
@if(session && session.id === user.user_id)
<script>
(function () {
const subAllBtn = document.getElementById('subscribe-all-uploads-btn');
if (subAllBtn) {
subAllBtn.onclick = async () => {
if (!confirm('{{ t('profile.confirm_subscribe_uploads') }}')) return;
subAllBtn.disabled = true;
subAllBtn.innerText = '{{ t('profile.subscribing') }}';
try {
const res = await fetch('/api/v2/user/subscribe-all-uploads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
}
});
const data = await res.json();
if (data.success) {
alert(data.message);
subAllBtn.innerText = '{{ t('profile.subscribed') }}';
setTimeout(() => {
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
subAllBtn.disabled = false;
}, 3000);
} else {
alert('Error: ' + data.message);
subAllBtn.disabled = false;
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
}
} catch (err) {
alert('Failed to subscribe: ' + err.message);
subAllBtn.disabled = false;
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
}
};
}
})();
</script>
@endif
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif> <div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination"> <div class="pagination-wrapper bottom-pagination fixed-pagination">