diff --git a/config_example.json b/config_example.json
index ab3c785..586a40f 100644
--- a/config_example.json
+++ b/config_example.json
@@ -20,11 +20,17 @@
"development": true
},
"allowedModes": [ "sfw", "nsfw", "untagged", "all", "nsfl" ],
+ "enable_pdf": true,
"enable_nsfl": true,
"nsfl_tag_id": 1234,
- "allowedMimes": [ "audio", "image", "video" ],
+ "allowedMimes": [
+ "audio",
+ "image",
+ "video",
+ "pdf"
+ ],
"nsfp": [
- 2, 3
+ 1, 2, 3
],
"websrv": {
"port": "1337",
@@ -46,9 +52,9 @@
"enable_global_chat": true,
"enable_danmaku": true,
"private_messages": true,
- "halls_enabled": false,
- "userhalls_enabled": false,
- "abyss_enabled": false,
+ "halls_enabled": true,
+ "userhalls_enabled": true,
+ "abyss_enabled": true,
"meme_creator": true,
"web_url_upload": true,
@@ -65,15 +71,16 @@
"embed_youtube_in_comments": true,
"show_content_warning": true,
+ "default_comment_display_mode": 0,
"phrases": [
"Hello World"
],
- "ban_video": "",
- "enable_xd_score": false,
+ "ban_video": "/b/17fd9881.mp4",
+ "enable_xd_score": true,
"enable_autoplay": false,
"enable_swiping": true,
"enable_profile_description": true,
- "user_alternative_infobox": false,
+ "use_ententeich": true,
"enable_swf": true,
"swf_thumb": "/s/img/swf.png",
@@ -153,7 +160,8 @@
"video/x-m4v": "mp4",
"video/x-matroska": "mkv",
"application/x-shockwave-flash": "swf",
- "application/vnd.adobe.flash.movie": "swf"
+ "application/vnd.adobe.flash.movie": "swf",
+ "application/pdf": "pdf"
},
"apis": {},
"smtp": {
diff --git a/deleted/.gitkeep b/deleted/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/pending/.gitkeep b/pending/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/pending/ca/.gitkeep b/pending/ca/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/pending/t/.gitkeep b/pending/t/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index bc85117..e1c1248 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -1,5 +1,13 @@
-/* f0ckwork beyond what was ever imagined, spiced up with 3928484 spices */
+/* =============================================
+ f0ckm.css - Unified Stylesheet
+ Merged: f0ck.css + w0bm.css + view styles
+ ============================================= */
+
+/* f0ckwork omega */
+/* written by sirx for f0ck.me */
+/* use whatever you like */
/* once upon a time this was a stiefelstrapse! but no more! */
+/* Licensed under wtfpl */
html[theme='f0ck'] {
--accent: #9f0;
@@ -695,7 +703,8 @@ html[theme='f0ck95'] {
--bg: teal;
--black: #000;
--white: #fff;
- --gray: rgba(255, 255, 255, .05);;
+ --gray: rgba(255, 255, 255, .05);
+ ;
--nav-bg: silver;
--nav-brand-border: inset 1px black;
--nav-brand-bg: silver;
@@ -812,48 +821,410 @@ html[theme="f0ck95"] #next {
}
html[theme="4d"] {
- --accent:#f2ef0b;
- --accent-rgb:31,178,176;
- --bg:#242424;
- --black:#000;
- --white:#fff;
- --gray:#262626;
- --nav-bg:#171717;
- --nav-brand-border:inset 1px #242424;
- --nav-brand-bg:#171717;
- --motd-bg:#252424;
- --navigation-links-bg:rgb(32,32,32);
- --navigation-links-background-linear-gradient:rgba(0,0,0,.12),rgba(0,0,0,0);
- --navigation-links-border-color:rgba(0,0,0,.8) rgba(0,0,0,.65) rgba(0,0,0,.5);
- --navigation-links-box-shadow:rgba(255,255,255,.05);
- --nav-link-background-linear-gradient:rgba(255,255,255,.04),rgba(255,255,255,0);
- --nav-link-box-shadow:inset 0 0 0 1px rgba(255,255,255,.04),inset 0 0px rgba(255,255,255,.04),inset 0 0px rgba(0,0,0,.15),0 0px 0px rgba(0,0,0,.1);
- --nav-link-hover-bg:#333;
- --nav-border-color:rgba(255,255,255,.05);
- --dropdown-bg:#232323;
- --dropdown-item-hover:#0d0d0d;
- --nav-brand-font:'VCR';
- --font:monospace;
- --pagination-background:#171717;
- --pagination-box-shadow:inset 0 0 0 1px rgba(255,255,255,.04),inset 0 0px rgba(255,255,255,.04),inset 0 0px rgba(0,0,0,.15),0 0px 0px rgba(0,0,0,.1);
- --pagination-anchor-box-shadow:inset 0 0 0 1px rgba(255,255,255,.04),inset 0 1px rgba(255,255,255,.04),inset 0 -1px rgba(0,0,0,.15),0 1px 1px rgba(0,0,0,.1);
- --pagination-background-hover:#333;
- --pagination-border-color:rgba(0,0,0,.8) rgba(0,0,0,.65) rgba(0,0,0,.5);
- --metadata-bg:rgba(34,34,34,0.8);
- --badge-bg:#131313;
- --posts-meta-bg:#000000b8;
- --badge-sfw:#68a728;
- --badge-nsfw:#E10DC3;
+ --accent: #f2ef0b;
+ --accent-rgb: 31, 178, 176;
+ --bg: #242424;
+ --black: #000;
+ --white: #fff;
+ --gray: #262626;
+ --nav-bg: #171717;
+ --nav-brand-border: inset 1px #242424;
+ --nav-brand-bg: #171717;
+ --motd-bg: #252424;
+ --navigation-links-bg: rgb(32, 32, 32);
+ --navigation-links-background-linear-gradient: rgba(0, 0, 0, .12), rgba(0, 0, 0, 0);
+ --navigation-links-border-color: rgba(0, 0, 0, .8) rgba(0, 0, 0, .65) rgba(0, 0, 0, .5);
+ --navigation-links-box-shadow: rgba(255, 255, 255, .05);
+ --nav-link-background-linear-gradient: rgba(255, 255, 255, .04), rgba(255, 255, 255, 0);
+ --nav-link-box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04), inset 0 0px rgba(255, 255, 255, .04), inset 0 0px rgba(0, 0, 0, .15), 0 0px 0px rgba(0, 0, 0, .1);
+ --nav-link-hover-bg: #333;
+ --nav-border-color: rgba(255, 255, 255, .05);
+ --dropdown-bg: #232323;
+ --dropdown-item-hover: #0d0d0d;
+ --nav-brand-font: 'VCR';
+ --font: monospace;
+ --pagination-background: #171717;
+ --pagination-box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04), inset 0 0px rgba(255, 255, 255, .04), inset 0 0px rgba(0, 0, 0, .15), 0 0px 0px rgba(0, 0, 0, .1);
+ --pagination-anchor-box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04), inset 0 1px rgba(255, 255, 255, .04), inset 0 -1px rgba(0, 0, 0, .15), 0 1px 1px rgba(0, 0, 0, .1);
+ --pagination-background-hover: #333;
+ --pagination-border-color: rgba(0, 0, 0, .8) rgba(0, 0, 0, .65) rgba(0, 0, 0, .5);
+ --metadata-bg: rgba(34, 34, 34, 0.8);
+ --badge-bg: #131313;
+ --posts-meta-bg: #000000b8;
+ --badge-sfw: #68a728;
+ --badge-nsfw: #E10DC3;
--badge-nsfl: #660000;
- --badge-tag:#353535;
- --scrollbar-color:#2b2b2b;
- --footbar-color:#1fb2b0;
- --loading-indicator-color:#1fb2b0;
- --img-border-width:0;
- --img-border-color:#363636;
- --maximize_button:#1fb2b0;
- --bg-gradient:linear-gradient(0deg,rgba(0,0,0,0.94) 0%,rgb(6,6,6) 10%,rgb(43,43,43) 100%);
- background:black;
+ --badge-tag: #353535;
+ --scrollbar-color: #2b2b2b;
+ --footbar-color: #1fb2b0;
+ --loading-indicator-color: #1fb2b0;
+ --img-border-width: 0;
+ --img-border-color: #363636;
+ --maximize_button: #1fb2b0;
+ --bg-gradient: linear-gradient(0deg, rgba(0, 0, 0, 0.94) 0%, rgb(6, 6, 6) 10%, rgb(43, 43, 43) 100%);
+ background: black;
+}
+
+/* xd */
+
+html[theme="xd"] {
+ --accent: #17786a;
+ --accent-rgb: 31,178,176;
+ --bg: #0c0c0c;
+ --black: #000;
+ --white: #fff;
+ --gray: #262626;
+ --nav-bg: #101010;
+ --nav-brand-border: inset 1px #242424;
+ --nav-brand-bg: #171717;
+ --motd-bg: #101010;
+ --navigation-links-bg: rgb(32,32,32);
+ --navigation-links-background-linear-gradient: rgba(0,0,0,.12),rgba(0,0,0,0);
+ --navigation-links-border-color: rgba(0,0,0,.8) rgba(0,0,0,.65) rgba(0,0,0,.5);
+ --navigation-links-box-shadow: rgba(255,255,255,.05);
+ --nav-link-background-linear-gradient: rgba(255,255,255,.04),rgba(255,255,255,0);
+ --nav-link-box-shadow: inset 0 0 0 1px rgba(255,255,255,.04),inset 0 0px rgba(255,255,255,.04),inset 0 0px rgba(0,0,0,.15),0 0px 0px rgba(0,0,0,.1);
+ --nav-link-hover-bg: #333;
+ --nav-border-color: #3b3b3c;
+ --dropdown-bg: #232323;
+ --dropdown-item-hover: #0d0d0d;
+ --nav-brand-font: 'VCR';
+ --font: monospace;
+ --pagination-background: #171717;
+ --pagination-box-shadow: inset 0 0 0 1px rgba(255,255,255,.04),inset 0 0px rgba(255,255,255,.04),inset 0 0px rgba(0,0,0,.15),0 0px 0px rgba(0,0,0,.1);
+ --pagination-anchor-box-shadow: inset 0 0 0 1px rgba(255,255,255,.04),inset 0 1px rgba(255,255,255,.04),inset 0 -1px rgba(0,0,0,.15),0 1px 1px rgba(0,0,0,.1);
+ --pagination-background-hover: #333;
+ --pagination-border-color: rgba(0,0,0,.8) rgba(0,0,0,.65) rgba(0,0,0,.5);
+ --metadata-bg: rgba(34,34,34,0.8);
+ --badge-bg: #131313;
+ --posts-meta-bg: #000000b8;
+ --badge-sfw: #68a728;
+ --badge-nsfw: #E10DC3;
+ --badge-nsfl: #660000;
+ --badge-tag: #353535;
+ --scrollbar-color: #2b2b2b;
+ --footbar-color: #1fb2b0;
+ --loading-indicator-color: #1fb2b0;
+ --img-border-width: 0;
+ --img-border-color: #363636;
+ --maximize_button: #1fb2b0;
+ --bg-gradient: linear-gradient(0deg,rgba(0,0,0,0.94) 0%,rgb(6,6,6) 10%,rgb(43,43,43) 100%);
+}
+
+html[theme="xd"] .motd-container {
+ border-top: 2px solid var(--nav-border-color);
+ border-bottom: 1px solid var(--nav-border-color);
+}
+
+html[theme="xd"] .item-sidebar-right,
+html[theme="xd"] .index-sidebar-right,
+html[theme="xd"] .global-sidebar-right {
+ background: #131313 !important;
+ border-left: 2px solid var(--nav-border-color) !important;
+}
+
+/* f0ck95 - v2 (theme '95') */
+html[theme='95'] {
+ --accent: #000080;
+ --accent-rgb: 0, 0, 128;
+ --bg: #008080;
+ --black: #000;
+ --white: #fff;
+ --gray: #808080;
+ --silver: #c0c0c0;
+ --nav-bg: #c0c0c0;
+ --nav-brand-border: outset 2px #dfdfdf;
+ --nav-brand-bg: #c0c0c0;
+ --navigation-links-bg: #c0c0c0;
+ --navigation-links-background-linear-gradient: none;
+ --navigation-links-border-color: #dfdfdf #808080 #808080 #dfdfdf;
+ --navigation-links-box-shadow: none;
+ --nav-link-background-linear-gradient: none;
+ --nav-link-box-shadow: none;
+ --nav-link-hover-bg: #000080;
+ --nav-border-color: #808080;
+ --dropdown-bg: #c0c0c0;
+ --dropdown-item-hover: #000080;
+ --nav-brand-font: 'Tahoma', 'MS Sans Serif', sans-serif;
+ --font: 'Tahoma', 'MS Sans Serif', sans-serif;
+ --pagination-background: #c0c0c0;
+ --pagination-box-shadow: none;
+ --pagination-anchor-box-shadow: none;
+ --pagination-background-hover: #000080;
+ --pagination-border-color: #808080;
+ --metadata-bg: #c0c0c0;
+ --badge-bg: #c0c0c0;
+ --posts-meta-bg: #c0c0c0cc;
+ --badge-sfw: #008000;
+ --badge-nsfw: #800000;
+ --badge-nsfl: #000;
+ --badge-tag: #808080;
+ --scrollbar-color: #c0c0c0;
+ --scroller-bg: #008080;
+ --footbar-color: #000;
+ --loading-indicator-color: #000080;
+ --img-border-width: 2px;
+ --img-border-color: #808080;
+ --maximize_button: #000;
+ --bg-gradient: none;
+ background: #008080;
+}
+
+html[theme='95'] body {
+ color: #000;
+ background: var(--bg);
+}
+
+html[theme='95'] a {
+ color: #0000ee;
+}
+
+html[theme='95'] a:hover {
+ text-decoration: underline;
+}
+
+html[theme='95'] .navbar {
+ border-bottom: 2px outset #dfdfdf;
+ background: #c0c0c0;
+}
+
+html[theme='95'] .navbar-brand {
+ background: #000080;
+ color: #fff !important;
+ padding: 2px 10px;
+ font-family: var(--nav-brand-font);
+ box-shadow: inset 1px 1px #dfdfdf, inset -1px -1px #808080;
+}
+
+html[theme='95'] .nav-link,
+html[theme='95'] .pagination>a,
+html[theme='95'] .pagination>span,
+html[theme='95'] button,
+html[theme='95'] .btn {
+ border: 2px outset #dfdfdf !important;
+ background: #c0c0c0 !important;
+ color: #000 !important;
+ padding: 4px 8px !important;
+ border-radius: 0 !important;
+ text-decoration: none !important;
+ box-shadow: none !important;
+}
+
+html[theme='95'] .nav-link:hover,
+html[theme='95'] .pagination>a:hover,
+html[theme='95'] button:hover,
+html[theme='95'] .btn:hover {
+ background: #c0c0c0 !important;
+}
+
+html[theme='95'] .nav-link:active,
+html[theme='95'] .pagination>a:active,
+html[theme='95'] button:active,
+html[theme='95'] .btn:active {
+ border: 2px inset #dfdfdf !important;
+}
+
+html[theme='95'] .dropdown-menu {
+ background: #c0c0c0;
+ border: 2px outset #dfdfdf;
+ border-radius: 0;
+}
+
+html[theme='95'] .dropdown-item {
+ color: #000;
+}
+
+html[theme='95'] .dropdown-item:hover {
+ background: #000080;
+ color: #fff;
+}
+
+html[theme='95'] .metadata,
+html[theme='95'] .comment,
+html[theme='95'] .login-form,
+html[theme='95'] .user-infobox-block {
+ border: 2px inset #dfdfdf !important;
+ background: #c0c0c0 !important;
+ color: #000 !important;
+ padding: 10px !important;
+ border-radius: 0 !important;
+}
+
+html[theme='95'] input,
+html[theme='95'] textarea,
+html[theme='95'] select {
+ border: 2px inset #dfdfdf !important;
+ background: #fff !important;
+ color: #000 !important;
+ border-radius: 0 !important;
+ padding: 2px 4px !important;
+}
+
+html[theme='95'] .badge {
+ border-radius: 0 !important;
+ border: 1px solid #000 !important;
+}
+
+html[theme='95'] .posts>a {
+ border: 2px outset #dfdfdf !important;
+ background: #c0c0c0 !important;
+}
+
+html[theme='95'] .v0ck_player_controls {
+ background: #c0c0c0 !important;
+ border-top: 2px outset #dfdfdf !important;
+ padding: 2px !important;
+ display: flex !important;
+ align-items: center !important;
+}
+
+html[theme='95'] .v0ck_player_button {
+ background: #c0c0c0 !important;
+ border: 2px outset #dfdfdf !important;
+ border-radius: 0 !important;
+ margin: 0 2px !important;
+ padding: 2px !important;
+}
+
+html[theme='95'] .v0ck_player_button:active {
+ border: 2px inset #dfdfdf !important;
+}
+
+html[theme='95'] .v0ck_player_button svg,
+html[theme='95'] .v0ck_player_controls svg {
+ fill: #000 !important;
+ color: #000 !important;
+}
+
+html[theme='95'] .v0ck_progress {
+ background: #fff !important;
+ border: 2px inset #dfdfdf !important;
+ height: 14px !important;
+ border-radius: 0 !important;
+}
+
+html[theme='95'] .v0ck_progress_filled {
+ background: #000080 !important;
+}
+
+html[theme='95'] .about {
+ color: #000;
+}
+
+html[theme='95'] .gchat-online-inner {
+ background: #c0c0c0 !important;
+ border-bottom: 2px outset #dfdfdf !important;
+ color: #000 !important;
+}
+
+html[theme='95'] .gchat-online-count {
+ color: #000 !important;
+}
+
+html[theme='95'] .global-sidebar-right-footer,
+html[theme='95'] .item-sidebar-right-footer,
+html[theme='95'] .index-sidebar-right-footer {
+ background: #c0c0c0 !important;
+ border-top: 2px outset #dfdfdf !important;
+ color: #000 !important;
+}
+
+html[theme='95'] .global-sidebar-right-footer>a {
+ border-right: 2px outset #dfdfdf !important;
+ color: #000 !important;
+ opacity: 1 !important;
+}
+
+html[theme='95'] #sidebar-activity-container {
+ background: #fff !important;
+ border: 2px inset #dfdfdf !important;
+ color: #000 !important;
+}
+
+html[theme='95'] .linear-view .comment {
+ border: 2px inset #dfdfdf !important;
+ background: #fff !important;
+ margin-bottom: 8px !important;
+ color: #000 !important;
+}
+
+html[theme='95'] .sidebar-activity-header {
+ background: #000080 !important;
+ color: #fff !important;
+ padding: 4px 10px !important;
+ border-bottom: 2px outset #dfdfdf !important;
+}
+
+html[theme='95'] #comments-container {
+ background: #fff !important;
+ border: 2px inset #dfdfdf !important;
+ color: #000 !important;
+}
+
+html[theme='95'] .item-main-content {
+ background: transparent !important;
+ border: none !important;
+ padding: 20px !important;
+}
+
+html[theme='95'] .item-main-content ._204863 {
+ background: linear-gradient(90deg, #000080, #1084d0) !important;
+ color: #fff !important;
+ font-family: var(--font) !important;
+ font-weight: bold !important;
+ font-size: 13px !important;
+ padding: 2px 4px !important;
+ display: flex !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ margin: 0 !important;
+ width: 100% !important;
+ height: 24px !important;
+ border-top: 2px outset #dfdfdf !important;
+ border-left: 2px outset #dfdfdf !important;
+ border-right: 2px outset #dfdfdf !important;
+ box-sizing: border-box !important;
+}
+
+html[theme='95'] .item-main-content ._204863::after {
+ content: " _ ❐ ✕ ";
+ font-family: 'Arial', sans-serif;
+ font-size: 10px;
+ background: #c0c0c0;
+ color: #000;
+ border: 2px outset #dfdfdf;
+ padding: 0 4px;
+ line-height: 14px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+}
+
+html[theme='95'] .item-main-content .content {
+ background: #c0c0c0 !important;
+ border-bottom: 2px outset #dfdfdf !important;
+ border-left: 2px outset #dfdfdf !important;
+ border-right: 2px outset #dfdfdf !important;
+ padding: 4px !important;
+ flex: none !important;
+ display: flex !important;
+ box-sizing: border-box !important;
+}
+
+html[theme='95'] .item-main-content .media-object {
+ background: #000 !important;
+ border: 2px inset #dfdfdf !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+}
+
+html[theme='95'] .item-main-content .metadata {
+ border: 2px inset #dfdfdf !important;
+ background: #c0c0c0 !important;
+ padding: 10px !important;
+ margin-top: 10px !important;
+ color: #000 !important;
}
/* removing in favor of new appearance */
@@ -949,6 +1320,7 @@ html[theme="4d"] {
max-width: calc(100vw - 16px);
margin-top: 0;
}
+
/* Hide arrow on mobile — bell position varies */
.notif-dropdown::before,
.notif-dropdown::after {
@@ -1187,10 +1559,22 @@ html[theme="4d"] {
}
@keyframes notif-pulse {
- 0% { box-shadow: 0 0 0 0 var(--accent, #e91e63); background: rgba(233,30,99,.18); }
- 50% { box-shadow: 0 0 0 6px rgba(233,30,99,.0); background: rgba(233,30,99,.1); }
- 100% { box-shadow: none; background: transparent; }
+ 0% {
+ box-shadow: 0 0 0 0 var(--accent, #e91e63);
+ background: rgba(233, 30, 99, .18);
+ }
+
+ 50% {
+ box-shadow: 0 0 0 6px rgba(233, 30, 99, .0);
+ background: rgba(233, 30, 99, .1);
+ }
+
+ 100% {
+ box-shadow: none;
+ background: transparent;
+ }
}
+
.notif-highlight {
animation: notif-pulse 1.2s ease-out 2;
border-radius: 6px;
@@ -1212,18 +1596,19 @@ html[theme="4d"] {
max-width: 85%;
height: auto !important;
max-height: none !important;
- background: var(--bg) !important;
- border-left: 1px solid var(--nav-border-color) !important;
- z-index: 900 !important; /* below navbar dropdowns (1000+) but above page content */
+ background: var(--bg);
+ border-left: 1px solid var(--nav-border-color);
+ z-index: 900 !important;
+ /* below navbar dropdowns (1000+) but above page content */
transition: right 0.3s ease-in-out, visibility 0.3s !important;
- box-shadow: -5px 0 15px rgba(0,0,0,0.5) !important;
+ box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5) !important;
overflow: visible !important;
/* iOS Safari: allow vertical scroll but let JS handle horizontal swipe-to-close */
touch-action: pan-y;
}
body.sidebar-right-hidden .global-sidebar-right {
- right: -300px !important;
+ right: -300px !important;
}
/* Prevent native image drag from hijacking the sidebar mouse-drag gesture */
@@ -1272,10 +1657,12 @@ body.sidebar-right-hidden .global-sidebar-right {
/* Ensure intermediate tablet screens also hide correctly */
@media (max-width: 1199px) {
+
body.sidebar-right-hidden .item-sidebar-right,
body.sidebar-right-hidden .index-sidebar-right,
body.sidebar-right-hidden .global-sidebar-right {
- right: -100% !important; /* Fallback for variable width sidebars */
+ right: -100% !important;
+ /* Fallback for variable width sidebars */
right: -300px !important;
}
}
@@ -1295,25 +1682,26 @@ body.sidebar-right-hidden .global-sidebar-right {
---------------------------------------------------------- */
body.layout-modern .item-layout-container {
display: grid;
- grid-template-columns: 350px minmax(0, 1fr) 350px;
- grid-template-rows: minmax(0, 1fr);
+ grid-template-columns: 400px minmax(0, 1fr) 300px;
+ grid-template-rows: auto;
flex: 1;
- min-height: 0;
+ min-height: calc(100vh - var(--navbar-h, 50px));
width: 100%;
- height: 100%;
- overflow: hidden;
+ height: auto;
+ overflow: visible;
transition: grid-template-columns 0.3s ease;
}
/* Collapsed right sidebar — grid track shrinks to 0 */
body.layout-modern.sidebar-right-hidden .item-layout-container {
- grid-template-columns: 300px minmax(0, 1fr) 0px;
+ grid-template-columns: 400px minmax(0, 1fr) 0px;
}
body.layout-modern .item-sidebar-left {
grid-column: 1;
- height: 100%;
- max-height: 100%;
+ height: calc(100vh - var(--navbar-h, 50px));
+ position: sticky;
+ top: var(--navbar-h, 50px);
display: flex;
flex-direction: column;
overflow-y: auto;
@@ -1324,12 +1712,12 @@ body.sidebar-right-hidden .global-sidebar-right {
body.layout-modern .item-layout-container .item-main-content {
grid-column: 2;
- height: 100%;
+ height: auto;
display: grid;
- grid-template-rows: 0.5fr auto 2fr;
+ grid-template-rows: auto auto auto;
justify-items: center;
- overflow-y: scroll;
- overflow-x: hidden;
+ align-content: baseline; /*with center the layout shifts when item has favs, with baseline it stays consistent and expands to the bottom cleanly */
+ overflow: visible;
padding: 20px;
min-height: 0;
min-width: 0;
@@ -1352,9 +1740,11 @@ body.sidebar-right-hidden .global-sidebar-right {
body.layout-modern .item-layout-container.sidebar-hidden {
grid-template-columns: 1fr;
}
+
body.layout-modern .item-layout-container.sidebar-hidden .item-sidebar-left {
display: none;
}
+
body.layout-modern .item-layout-container.sidebar-hidden .item-main-content {
grid-column: 1;
}
@@ -1375,13 +1765,15 @@ body.sidebar-right-hidden .global-sidebar-right {
.item-sidebar-left {
position: relative;
}
+
/* Removed local sidebar and toggle overrides — now using universal rules */
/* Collapse right sidebar on narrower screens in modern view */
@media (max-width: 1400px) {
body.layout-modern .item-layout-container {
- grid-template-columns: 300px minmax(0, 1fr);
+ grid-template-columns: 400px minmax(0, 1fr);
}
+
/* Uses universal fixed slide-out logic; no need for display: none at this breakpoint */
}
@@ -1458,7 +1850,8 @@ body.sidebar-right-hidden .global-sidebar-right {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
- touch-action: pan-y; /* iOS Safari: keep horizontal swipe-to-close claimable by JS */
+ touch-action: pan-y;
+ /* iOS Safari: keep horizontal swipe-to-close claimable by JS */
}
.sidebar-activity-header {
@@ -1482,12 +1875,13 @@ body.sidebar-right-hidden .global-sidebar-right {
margin-bottom: 5px;
padding: 5px;
font-size: 0.9em;
- border-bottom: 1px solid rgba(255,255,255,0.05);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.sidebar-activity .comment-content-inner {
display: block;
- max-height: 80px; /* Roughly 4-5 lines */
+ max-height: 80px;
+ /* Roughly 4-5 lines */
overflow: hidden;
position: relative;
white-space: pre-wrap;
@@ -1501,7 +1895,7 @@ body.sidebar-right-hidden .global-sidebar-right {
right: 0;
width: 100%;
height: 20px;
- background: linear-gradient(to bottom, transparent, rgba(17, 17, 17, 0.9));
+ background: linear-gradient(to bottom, transparent, rgba(28, 27, 27, 0.8));
pointer-events: none;
}
@@ -1519,7 +1913,8 @@ body.sidebar-right-hidden .global-sidebar-right {
padding: 0;
margin-top: 5px;
font-size: 0.85em;
- display: none; /* Hidden by default, shown via JS if overflow detected */
+ display: none;
+ /* Hidden by default, shown via JS if overflow detected */
}
/* Avatar in sidebar activity */
@@ -1532,7 +1927,7 @@ body.sidebar-right-hidden .global-sidebar-right {
width: 24px;
height: 24px;
object-fit: cover;
- border: 1px solid rgba(255,255,255,0.1);
+ border: 1px solid rgba(255, 255, 255, 0.1);
}
.read-more-btn,
@@ -1548,6 +1943,7 @@ body.sidebar-right-hidden .global-sidebar-right {
text-decoration: underline;
text-underline-offset: 2px;
}
+
.read-more-btn:hover,
.see-less-btn:hover {
opacity: 1;
@@ -1570,22 +1966,27 @@ body.sidebar-right-hidden .global-sidebar-right {
width: 100%;
padding: 5px;
}
+
.item-layout-container .item-main-content {
- order: 1; /* Main content (video) on top */
+ order: 1;
+ /* Main content (video) on top */
/* padding: 10px; */
}
+
.item-sidebar-left {
- order: 2; /* Comments/Tags on bottom */
+ order: 2;
+ /* Comments/Tags on bottom */
/* padding: 10px; */
z-index: 1;
}
+
/* Mobile Overrides if necessary — most styles now universal */
.item-sidebar-right,
.index-sidebar-right,
.global-sidebar-right {
width: 280px !important;
}
-
+
body.sidebar-right-hidden .item-sidebar-right,
body.sidebar-right-hidden .index-sidebar-right,
body.sidebar-right-hidden .global-sidebar-right {
@@ -1599,19 +2000,20 @@ body.sidebar-right-hidden .global-sidebar-right {
}
/* Common sizing for the Legacy content area */
-.item-layout-container .item-main-content > ._204863,
-.item-layout-container .item-main-content > .content,
-.item-layout-container .item-main-content > .metadata,
+.item-layout-container .item-main-content>._204863,
+.item-layout-container .item-main-content>.content,
+.item-layout-container .item-main-content>.metadata,
.item-layout-container .item-main-content #comments-container {
width: 100%;
- max-width: 1100px; /* Optional cap to prevent excessive width on huge screens */
+ max-width: 1100px;
+ /* Optional cap to prevent excessive width on huge screens */
min-height: 0;
min-width: 0;
}
-.item-layout-container .item-main-content > .content {
+.item-layout-container .item-main-content>.content {
display: flex;
- flex-direction: row;
+ flex-direction: row;
justify-content: center;
align-items: center;
}
@@ -1630,13 +2032,15 @@ body.sidebar-right-hidden .global-sidebar-right {
.item-layout-container .item-main-content .embed-responsive {
width: 100%;
- max-width: calc(100vh * (16 / 9)); /* Cap width based on viewport height to fit vertically */
+ max-width: calc(100vh * (16 / 9));
+ /* Cap width based on viewport height to fit vertically */
margin: 0 auto;
}
-.item-main-content video,
+.item-main-content video,
.item-main-content img {
- max-height: calc(100vh - 180px); /* Leave space for ID and Metadata bars */
+ max-height: calc(100vh - 180px);
+ /* Leave space for ID and Metadata bars */
object-fit: contain;
}
@@ -1736,14 +2140,15 @@ body.layout-modern .item-sidebar-left .tag-controls {
background: rgba(255, 255, 255, 0.05);
padding: 5px;
border: 1px solid var(--nav-border-color);
- border-radius: 0;;
+ border-radius: 0;
+ ;
margin: 5px;
}
.comment-input textarea {
width: 100%;
background: rgba(0, 0, 0, 0.2);
- border: 1px solid var(--nav-border-color);
+ border: none;
color: var(--white);
padding: 10px;
min-height: 80px;
@@ -1801,8 +2206,10 @@ body.layout-modern .comments-list {
display: flex;
flex-direction: column;
gap: 5px;
- flex: 1 1 auto; /* Grow to fill #comments-container */
- overflow-y: auto; /* Scroll comments here, not on the container */
+ flex: 1 1 auto;
+ /* Grow to fill #comments-container */
+ overflow-y: auto;
+ /* Scroll comments here, not on the container */
overflow-x: hidden;
min-height: 0;
}
@@ -1812,8 +2219,10 @@ body.layout-legacy .comments-list {
display: flex;
flex-direction: column;
gap: 5px;
- flex: 0 0 auto; /* Natural height */
- overflow: visible; /* Unified scrolling */
+ flex: 0 0 auto;
+ /* Natural height */
+ overflow: visible;
+ /* Unified scrolling */
min-height: 0;
}
@@ -1841,7 +2250,7 @@ body.layout-legacy .scroll-to-bottom {
cursor: pointer;
pointer-events: auto;
border-radius: 50%;
- box-shadow: 0 4px 10px rgba(0,0,0,0.5);
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0.8;
}
@@ -1876,6 +2285,7 @@ body.layout-legacy .scroll-to-bottom svg {
.comments-list::-webkit-scrollbar {
width: 6px;
}
+
.comments-list::-webkit-scrollbar-thumb {
background: var(--gray);
border-radius: 3px;
@@ -1891,7 +2301,8 @@ body.layout-legacy .scroll-to-bottom svg {
border-radius: 0;
/* No rounded corners */
position: relative;
- padding-bottom: 25px; /* Room for absolute permalink */
+ padding-bottom: 25px;
+ /* Room for absolute permalink */
}
.comment.deleted {
@@ -1907,9 +2318,9 @@ body.layout-legacy .scroll-to-bottom svg {
}
.comment-avatar::after {
- content: "";
- display: block;
- height: 20px;
+ content: "";
+ display: block;
+ height: 20px;
}
.comment-body {
@@ -1923,7 +2334,7 @@ body.layout-legacy .scroll-to-bottom svg {
}
.comment-content p {
- margin: 0;
+ margin: 0;
}
.comment-header {
@@ -1945,17 +2356,17 @@ body.layout-legacy .scroll-to-bottom svg {
}
.pinned-badge {
- background: var(--accent);
- color: var(--nav-bg);
- font-size: 0.8em;
- padding: 1px 6px;
- border-radius: 2px;
- font-weight: bold;
- display: inline-flex;
- align-items: center;
- gap: 3px;
- line-height: 1;
- margin-right: 5px;
+ background: var(--accent);
+ color: var(--nav-bg);
+ font-size: 0.8em;
+ padding: 1px 6px;
+ border-radius: 2px;
+ font-weight: bold;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ line-height: 1;
+ margin-right: 5px;
}
.comment-header-left .pinned-badge {
@@ -1963,10 +2374,10 @@ body.layout-legacy .scroll-to-bottom svg {
}
.pinned-badge svg {
- width: 10px;
- height: 10px;
- display: block;
- stroke-width: 3;
+ width: 10px;
+ height: 10px;
+ display: block;
+ stroke-width: 3;
}
.comment-footer {
@@ -2004,89 +2415,89 @@ body.layout-legacy .scroll-to-bottom svg {
.comment-footer button,
.comment-footer a,
.comment-footer span {
- padding: 0;
- margin: 0;
- line-height: 1;
- font-family: inherit;
- font-size: inherit;
- vertical-align: top;
+ padding: 0;
+ margin: 0;
+ line-height: 1;
+ font-family: inherit;
+ font-size: inherit;
+ vertical-align: top;
}
.comment-meta button,
.comment-meta a {
- background: none;
- border: none;
- cursor: pointer;
- color: #888;
- padding-right: 8px;
- display: flex;
- align-items: center;
- transition: color 0.1s;
- text-decoration: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #888;
+ padding-right: 8px;
+ display: flex;
+ align-items: center;
+ transition: color 0.1s;
+ text-decoration: none;
}
.comment-meta button svg,
.comment-meta a svg {
- width: 14px;
- height: 14px;
- display: block;
- margin-right: 2px;
+ width: 14px;
+ height: 14px;
+ display: block;
+ margin-right: 2px;
}
.comment-header button:hover,
.comment-header a:hover,
.comment-footer button:hover,
.comment-footer a:hover {
- color: var(--white);
+ color: var(--white);
}
.report-comment-btn:hover {
- opacity: 1 !important;
- color: var(--accent, #e91e63) !important;
+ opacity: 1 !important;
+ color: var(--accent, #e91e63) !important;
}
.comment-permalink {
- position: absolute;
- bottom: 5px;
- left: 10px;
- font-size: 0.7em !important;
- color: #555 !important;
- white-space: nowrap;
- font-family: 'VCR';
+ position: absolute;
+ bottom: 5px;
+ left: 10px;
+ font-size: 0.7em !important;
+ color: #555 !important;
+ white-space: nowrap;
+ font-family: 'VCR';
}
.comment-permalink:hover {
- color: #888 !important;
+ color: #888 !important;
}
.comment-author {
- font-weight: bold;
- color: var(--accent);
- margin-right: 8px;
- padding-right: 0 !important;
+ font-weight: bold;
+ color: var(--accent);
+ margin-right: 8px;
+ padding-right: 0 !important;
}
.comment-time {
- margin-right: 5px;
- font-size: 0.8em;
- color: #777;
- margin-bottom: 1px;
- text-decoration: none;
- align-self: baseline;
+ margin-right: 5px;
+ font-size: 0.8em;
+ color: #777;
+ margin-bottom: 1px;
+ text-decoration: none;
+ align-self: baseline;
}
.comment-time:hover {
- color: #aaa;
- text-decoration: underline;
+ color: #aaa;
+ text-decoration: underline;
}
.admin-delete-btn:hover {
- color: #ff4444 !important;
+ color: #ff4444 !important;
}
.admin-pin-btn.active {
- color: var(--accent) !important;
+ color: var(--accent) !important;
}
@@ -2099,7 +2510,8 @@ body.layout-legacy .scroll-to-bottom svg {
text-decoration: underline;
}
-.reply-btn {
+.reply-btn,
+.quote-btn {
background: none;
border: none;
color: #888;
@@ -2109,6 +2521,136 @@ body.layout-legacy .scroll-to-bottom svg {
margin: 0;
}
+.comment-context-link {
+ color: var(--accent);
+ text-decoration: none;
+ font-family: 'VCR', monospace;
+ font-size: 0.9em;
+ opacity: 1;
+}
+
+.comment-context-link:hover {
+ opacity: 1;
+ text-decoration: underline;
+}
+
+.linear-view .comment {
+ margin-left: 0 !important;
+}
+
+.linear-view .comment-replies {
+ display: none !important;
+}
+
+@keyframes comment-highlight {
+ 0% {
+ background: rgba(var(--accent-rgb, 255, 255, 255), 0.3);
+ }
+
+ 100% {
+ background: rgba(255, 255, 255, 0.03);
+ }
+}
+
+.highlight-comment {
+ animation: comment-highlight 2s ease-out;
+ border-color: var(--accent) !important;
+}
+
+.comment-preview-popup {
+ background: #000000 !important;
+ /* Absolute solid black */
+ border: 1px solid var(--accent);
+ box-shadow: 0 20px 50px #000000;
+ /* Fully opaque black shadow */
+ padding: 15px;
+ max-width: 600px;
+ max-height: 500px;
+ overflow-y: auto;
+ pointer-events: auto;
+ opacity: 1 !important;
+ transform: translateY(5px);
+ animation: preview-fade-in 0.1s forwards ease-out;
+ /* Faster animation */
+ z-index: 1000001;
+}
+
+.comment-preview-popup .comment {
+ border: none !important;
+ background: transparent !important;
+ padding: 0 !important;
+ margin: 0 !important;
+}
+
+@keyframes preview-fade-in {
+ to {
+ transform: translateY(0);
+ }
+}
+
+.comment-backlinks {
+ display: inline-block;
+ font-size: 0.85em;
+ opacity: 0.8;
+ margin-left: 10px;
+}
+
+.comments-header-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 15px;
+ padding: 0 5px;
+}
+
+.display-mode-selector {
+ display: flex;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 4px;
+ gap: 4px;
+}
+
+.mode-btn {
+ background: transparent;
+ border: none;
+ color: #888;
+ padding: 6px 14px;
+ cursor: pointer;
+ font-size: 0.8em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.mode-btn:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.mode-btn.active {
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
+}
+
+.mode-btn i {
+ font-size: 1.1em;
+}
+
+.comment-backlinks a {
+ margin-left: 4px;
+ text-decoration: none;
+ color: var(--accent);
+}
+
+.comment-backlinks a:hover {
+ text-decoration: underline;
+}
+
.comment-content {
line-height: 1.4;
word-break: break-word;
@@ -2121,7 +2663,8 @@ body.layout-legacy .scroll-to-bottom svg {
cursor: pointer !important;
transition: color 0.1s;
border-radius: 4px;
- pointer-events: auto !important; /* Ensure interaction even if parent has pointer-events: none */
+ pointer-events: auto !important;
+ /* Ensure interaction even if parent has pointer-events: none */
}
/* Ensure spoilers properly envelope media */
@@ -2130,13 +2673,14 @@ body.layout-legacy .scroll-to-bottom svg {
max-width: 100%;
}
-.spoiler:has(.video-embed-wrap, .yt-embed-wrap, .audio-embed-wrap, img, video, iframe, audio) {
+.spoiler:has(.vocaroo-embed-wrap, .video-embed-wrap, .yt-embed-wrap, .audio-embed-wrap, img, video, iframe, audio) {
display: inline-grid;
grid-auto-flow: column;
max-width: 100%;
vertical-align: middle;
}
+.spoiler .vocaroo-embed-wrap,
.spoiler .video-embed-wrap,
.spoiler .yt-embed-wrap,
.spoiler .audio-embed-wrap {
@@ -2148,7 +2692,8 @@ body.layout-legacy .scroll-to-bottom svg {
}
.spoiler .yt-embed-wrap {
- width: 480px; /* YouTube needs a base width for the aspect ratio padding trick */
+ width: 480px;
+ /* YouTube needs a base width for the aspect ratio padding trick */
}
.spoiler img,
@@ -2226,6 +2771,7 @@ body.layout-legacy .scroll-to-bottom svg {
.blur-text video,
.blur-text iframe,
.blur-text audio,
+.blur-text .vocaroo-embed-wrap,
.blur-text .video-embed-wrap,
.blur-text .yt-embed-wrap,
.blur-text .audio-embed-wrap {
@@ -2237,6 +2783,7 @@ body.layout-legacy .scroll-to-bottom svg {
.blur-text:hover video,
.blur-text:hover iframe,
.blur-text:hover audio,
+.blur-text:hover .vocaroo-embed-wrap,
.blur-text:hover .video-embed-wrap,
.blur-text:hover .yt-embed-wrap,
.blur-text:hover .audio-embed-wrap,
@@ -2244,6 +2791,7 @@ body.layout-legacy .scroll-to-bottom svg {
.blur-text.revealed video,
.blur-text.revealed iframe,
.blur-text.revealed audio,
+.blur-text.revealed .vocaroo-embed-wrap,
.blur-text.revealed .video-embed-wrap,
.blur-text.revealed .yt-embed-wrap,
.blur-text.revealed .audio-embed-wrap {
@@ -2643,6 +3191,7 @@ html[res="fullscreen"] .container {
html[res="fullscreen"] .container {
max-width: 100% !important;
}
+
html[res="fullscreen"] #main {
padding: 0 !important;
}
@@ -2811,12 +3360,12 @@ div.posts>a:not(.notif-item).touch-active::after {
.navbar.scrolled {
- background: black !important;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
+ background: var(--nav-bg) !important;
+ /*box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);*/
}
.navbar.scrolled .navbar-brand {
- background: transparent !important;
+ /*background: transparent !important;*/
}
.navbar-brand {
@@ -2981,6 +3530,10 @@ ul.navbar-nav li.nav-item {
content: "" !important;
} */
+ .fa-solid.fa-angle-up, .fa-regular.fa-image {
+ margin-right: 5px;
+ }
+
.navbar {
display: flex !important;
flex-wrap: wrap !important;
@@ -3024,6 +3577,7 @@ ul.navbar-nav li.nav-item {
left: auto;
transform: none;
}
+
/* Hide caret arrow on mobile */
.nav-right-group .notif-dropdown::before,
.nav-right-group .notif-dropdown::after {
@@ -3176,7 +3730,8 @@ span.placeholder {
bottom: 0;
left: 0;
right: 0;
- pointer-events: none; /* Allow clicking through container if needed, but not on wrapper */
+ pointer-events: none;
+ /* Allow clicking through container if needed, but not on wrapper */
z-index: 10;
}
@@ -3371,6 +3926,7 @@ span.placeholder {
}
@media (min-width: 1200px) {
+
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
.index-layout-wrapper {
padding-right: 300px;
@@ -3498,7 +4054,8 @@ span.placeholder {
@media (max-width: 768px) {
.ruffle-gesture-overlay {
- display: none; /* No overlay on mobile to keep Flash interactive */
+ display: none;
+ /* No overlay on mobile to keep Flash interactive */
}
}
@@ -3667,7 +4224,7 @@ a#elfe {
}
a.remove-from-hall {
- color: rgba(255,255,255,0.4);
+ color: rgba(255, 255, 255, 0.4);
font-size: 0.8em;
text-decoration: none;
vertical-align: middle;
@@ -3684,12 +4241,12 @@ a.remove-from-hall:hover {
position: relative;
display: inline-flex;
align-items: center;
- background: rgba(255,255,255,0.08);
- border: 1px solid rgba(255,255,255,0.15);
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 3px;
padding: 0 4px;
font-size: 0.78em;
- color: rgba(255,255,255,0.55);
+ color: rgba(255, 255, 255, 0.55);
cursor: default;
line-height: 1.6;
user-select: none;
@@ -3697,7 +4254,7 @@ a.remove-from-hall:hover {
.hall-overflow-pill:hover {
color: var(--white);
- background: rgba(255,255,255,0.14);
+ background: rgba(255, 255, 255, 0.14);
}
.hall-overflow-tooltip {
@@ -3707,13 +4264,13 @@ a.remove-from-hall:hover {
left: 50%;
transform: translateX(-50%);
background: var(--dropdown-bg, #1e1e1e);
- border: 1px solid rgba(255,255,255,0.15);
+ border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 4px 0;
min-width: 120px;
max-width: 200px;
z-index: 200;
- box-shadow: 0 4px 16px rgba(0,0,0,0.6);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
white-space: nowrap;
}
@@ -3724,7 +4281,7 @@ a.remove-from-hall:hover {
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
- border-top-color: rgba(255,255,255,0.15);
+ border-top-color: rgba(255, 255, 255, 0.15);
}
.hall-overflow-pill:hover .hall-overflow-tooltip {
@@ -3741,7 +4298,7 @@ a.remove-from-hall:hover {
}
.hall-overflow-tooltip a:hover {
- background: rgba(255,255,255,0.08);
+ background: rgba(255, 255, 255, 0.08);
color: var(--accent);
}
@@ -3793,8 +4350,11 @@ a.remove-from-hall:hover {
}
@keyframes newTagGlow {
- 0% { background-color: #302f2fa3; }
- 100% { }
+ 0% {
+ background-color: #302f2fa3;
+ }
+
+ 100% {}
}
.new-tag-glow {
@@ -3817,13 +4377,13 @@ span#tags:empty {
display: contents;
}
-span#tags .tags-inner > span {
+span#tags .tags-inner>span {
display: inline-block;
margin-top: 2.5px;
margin-bottom: 2.5px;
}
-span#tags:not(.tags-expanded) .tags-inner > span:nth-child(n+11) {
+span#tags:not(.tags-expanded) .tags-inner>span:nth-child(n+11) {
display: none;
}
@@ -3876,6 +4436,7 @@ span#tags:not(.tags-expanded) .tags-inner > span:nth-child(n+11) {
text-shadow: inherit !important;
background-color: #252525;
}
+
.badge-greentext .tag-name {
color: #789922 !important;
}
@@ -4210,7 +4771,7 @@ body[type='login'] {
background: #222;
border: 1px solid #444;
border-radius: 4px;
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
max-height: 200px;
overflow-y: auto;
font-family: var(--font);
@@ -4262,12 +4823,13 @@ body[type='login'] {
/* ── Inline emoji autocomplete dropdown ── */
.emoji-autocomplete {
- position: fixed; /* set by JS via getBoundingClientRect */
+ position: fixed;
+ /* set by JS via getBoundingClientRect */
z-index: 9999;
background: var(--dropdown-bg, #1a1a1a);
- border: 1px solid rgba(255,255,255,0.1);
+ border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
- box-shadow: 0 -4px 20px rgba(0,0,0,0.6);
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.6);
flex-direction: column;
max-height: 220px;
overflow-y: auto;
@@ -4289,7 +4851,7 @@ body[type='login'] {
.emoji-ac-item:hover,
.emoji-ac-item.active {
- background: rgba(255,255,255,0.1);
+ background: rgba(255, 255, 255, 0.1);
}
.emoji-ac-item img {
@@ -4425,6 +4987,7 @@ input {
opacity: 0;
transform: translateY(10px);
}
+
to {
opacity: 1;
transform: translateY(0);
@@ -4542,16 +5105,16 @@ div.posts>a>p:before {
}
div.posts>a[data-mode="sfw"]>p:before {
-background: #000000;
-background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(63, 196, 61, 0) 85%, rgba(63, 196, 61, 1) 100%);
+ background: #000000;
+ background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(63, 196, 61, 0) 85%, rgba(63, 196, 61, 1) 100%);
}
div.posts>a[data-mode="nsfw"]>p:before {
-background: #000000;
-background: linear-gradient(90deg,rgba(0,0,0,0) 0%,rgba(227,7,7,0) 85%,rgb(227, 7, 203) 100%);
+ background: #000000;
+ background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(227, 7, 7, 0) 85%, rgb(227, 7, 203) 100%);
}
-div.posts > a[data-mode="nsfl"] > p::before {
+div.posts>a[data-mode="nsfl"]>p::before {
background: #000;
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(227, 7, 7, 0) 85%, rgb(231, 3, 3) 100%);
}
@@ -4576,7 +5139,7 @@ img.avatar {
position: relative;
width: 25px;
margin: 1px;
-/* left: -7px; */
+ /* left: -7px; */
top: 0;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
@@ -4606,7 +5169,8 @@ div.logwrap>p {
margin: 0 auto;
}
-.ranking-page h1, .ranking-page h3 {
+.ranking-page h1,
+.ranking-page h3 {
color: var(--white);
letter-spacing: 1px;
}
@@ -4623,8 +5187,9 @@ div.logwrap>p {
flex-direction: column;
gap: 30px;
}
-
- .section-big, .section-small {
+
+ .section-big,
+ .section-small {
width: 100%;
flex: none;
}
@@ -4733,12 +5298,14 @@ div.logwrap>p {
margin-bottom: 15px;
}
-.stats-table td:first-child, .f0cks-table td:first-child {
+.stats-table td:first-child,
+.f0cks-table td:first-child {
font-size: 0.9em;
opacity: 0.7;
}
-.stats-table td:last-child, .f0cks-table td:last-child {
+.stats-table td:last-child,
+.f0cks-table td:last-child {
text-align: right;
font-weight: bold;
}
@@ -4829,23 +5396,68 @@ div.logwrap>p {
}
/* Rank specific podium styles */
-.podium-item.rank-1 { order: 2; transform: scale(1.1); }
-.podium-item.rank-1:hover { transform: scale(1.15) translateY(-15px); }
-.podium-item.rank-2 { order: 1; }
-.podium-item.rank-3 { order: 3; }
+.podium-item.rank-1 {
+ order: 2;
+ transform: scale(1.1);
+}
-.rank-1 .podium-avatar { border-color: #FFD700; width: 140px; height: 140px; box-shadow: 0 0 40px rgba(255, 215, 0, 0.3); }
-.rank-2 .podium-avatar { border-color: #C0C0C0; width: 110px; height: 110px; box-shadow: 0 0 30px rgba(192, 192, 192, 0.2); }
-.rank-3 .podium-avatar { border-color: #CD7F32; width: 100px; height: 100px; box-shadow: 0 0 20px rgba(205, 127, 50, 0.1); }
+.podium-item.rank-1:hover {
+ transform: scale(1.15) translateY(-15px);
+}
-.rank-1 .podium-rank-label { background: linear-gradient(135deg, #FFD700, #FFA500); color: #000; }
-.rank-1 .podium-count { color: #FFD700; }
+.podium-item.rank-2 {
+ order: 1;
+}
-.rank-2 .podium-rank-label { background: linear-gradient(135deg, #C0C0C0, #808080); }
-.rank-2 .podium-count { color: #C0C0C0; }
+.podium-item.rank-3 {
+ order: 3;
+}
-.rank-3 .podium-rank-label { background: linear-gradient(135deg, #CD7F32, #8B4513); }
-.rank-3 .podium-count { color: #CD7F32; }
+.rank-1 .podium-avatar {
+ border-color: #FFD700;
+ width: 140px;
+ height: 140px;
+ box-shadow: 0 0 40px rgba(255, 215, 0, 0.3);
+}
+
+.rank-2 .podium-avatar {
+ border-color: #C0C0C0;
+ width: 110px;
+ height: 110px;
+ box-shadow: 0 0 30px rgba(192, 192, 192, 0.2);
+}
+
+.rank-3 .podium-avatar {
+ border-color: #CD7F32;
+ width: 100px;
+ height: 100px;
+ box-shadow: 0 0 20px rgba(205, 127, 50, 0.1);
+}
+
+.rank-1 .podium-rank-label {
+ background: linear-gradient(135deg, #FFD700, #FFA500);
+ color: #000;
+}
+
+.rank-1 .podium-count {
+ color: #FFD700;
+}
+
+.rank-2 .podium-rank-label {
+ background: linear-gradient(135deg, #C0C0C0, #808080);
+}
+
+.rank-2 .podium-count {
+ color: #C0C0C0;
+}
+
+.rank-3 .podium-rank-label {
+ background: linear-gradient(135deg, #CD7F32, #8B4513);
+}
+
+.rank-3 .podium-count {
+ color: #CD7F32;
+}
@media (max-width: 992px) {
.ranking-podium {
@@ -4854,9 +5466,11 @@ div.logwrap>p {
gap: 50px;
padding-top: 40px;
}
+
.podium-item {
order: unset !important;
}
+
.podium-item.rank-1 {
transform: scale(1);
}
@@ -4866,7 +5480,7 @@ div.logwrap>p {
@media (max-width: 600px) {
.ranking-table tr {
display: grid;
- grid-template-areas:
+ grid-template-areas:
"rank avatar user"
"rank avatar count";
grid-template-columns: 50px 70px 1fr;
@@ -4876,21 +5490,40 @@ div.logwrap>p {
align-items: center;
}
- .rank-cell { grid-area: rank; font-size: 1.4em !important; }
- .avatar-cell { grid-area: avatar; padding: 0 !important; width: auto; }
- .user-cell { grid-area: user; padding: 0 !important; align-self: end; }
- .count-cell {
- grid-area: count;
- padding: 0 !important;
- text-align: left !important;
- align-self: start;
+ .rank-cell {
+ grid-area: rank;
+ font-size: 1.4em !important;
+ }
+
+ .avatar-cell {
+ grid-area: avatar;
+ padding: 0 !important;
+ width: auto;
+ }
+
+ .user-cell {
+ grid-area: user;
+ padding: 0 !important;
+ align-self: end;
+ }
+
+ .count-cell {
+ grid-area: count;
+ padding: 0 !important;
+ text-align: left !important;
+ align-self: start;
opacity: 0.7;
font-size: 0.9em;
}
- .count-cell:after { content: " tags"; }
- .ranking-table thead { display: none; }
-
+ .count-cell:after {
+ content: " tags";
+ }
+
+ .ranking-table thead {
+ display: none;
+ }
+
.rank-avatar {
width: 50px;
height: 50px;
@@ -4900,7 +5533,7 @@ div.logwrap>p {
.ranking-page {
padding: 15px;
}
-
+
.ranking-wrapper {
margin-top: 15px;
}
@@ -5145,15 +5778,15 @@ span#favs {
}
._error_content {
- display: grid;
- grid-template-columns: auto;
+ display: grid;
+ grid-template-columns: auto;
}
._error_content img {
- max-width: 100%;
- width: 100%;
- padding: 5px;
- margin: 0;
+ max-width: 100%;
+ width: 100%;
+ padding: 5px;
+ margin: 0;
}
._error_message {
@@ -5212,6 +5845,7 @@ table img {
/* fix for mobile table view in search! */
@media screen and (max-width: 650px) {
+
.results table.table th,
.results table.table td,
.admin-users-table th,
@@ -5270,17 +5904,19 @@ table img {
color: inherit;
fill: inherit;
}
+
.iconset#a_pin::before {
display: inline-block;
transition: transform 0.25s ease;
}
+
/* pinned: rotate just the glyph so tip points down – "pushed into the board" */
.iconset#a_pin.active::before {
transform: rotate(-45deg);
}
.iconset#subscribe-btn {
- border-style: outset;
+ border-style: outset;
}
.imageDoor:hover:after {
@@ -5566,9 +6202,11 @@ video {
border-radius: 3px;
background: var(--nav-border-color, #555);
outline: none;
- touch-action: manipulation; /* prevent scroll stealing */
+ touch-action: manipulation;
+ /* prevent scroll stealing */
cursor: pointer;
}
+
#font_size_slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
@@ -5578,10 +6216,15 @@ video {
background: var(--accent, #e91e63);
cursor: grab;
border: 3px solid var(--white, #fff);
- box-shadow: 0 2px 6px rgba(0,0,0,.45);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, .45);
transition: transform .1s;
}
-#font_size_slider::-webkit-slider-thumb:active { transform: scale(1.2); cursor: grabbing; }
+
+#font_size_slider::-webkit-slider-thumb:active {
+ transform: scale(1.2);
+ cursor: grabbing;
+}
+
#font_size_slider::-moz-range-thumb {
width: 28px;
height: 28px;
@@ -5589,10 +6232,14 @@ video {
background: var(--accent, #e91e63);
cursor: grab;
border: 3px solid var(--white, #fff);
- box-shadow: 0 2px 6px rgba(0,0,0,.45);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, .45);
transition: transform .1s;
}
-#font_size_slider::-moz-range-thumb:active { transform: scale(1.2); cursor: grabbing; }
+
+#font_size_slider::-moz-range-thumb:active {
+ transform: scale(1.2);
+ cursor: grabbing;
+}
input[name="i_avatar"] {
text-align: center;
@@ -5707,15 +6354,13 @@ input#s_avatar {
letter-spacing: 0.04em;
text-transform: uppercase;
text-shadow:
- 0 0 8px rgba(0,0,0,0.9),
- 0 1px 3px rgba(0,0,0,0.8),
- 0 0 24px rgba(0,0,0,0.7);
- background: linear-gradient(
- to bottom,
- transparent 0%,
- transparent 35%,
- rgba(0,0,0,0.4) 100%
- );
+ 0 0 8px rgba(0, 0, 0, 0.9),
+ 0 1px 3px rgba(0, 0, 0, 0.8),
+ 0 0 24px rgba(0, 0, 0, 0.7);
+ background: linear-gradient(to bottom,
+ transparent 0%,
+ transparent 35%,
+ rgba(0, 0, 0, 0.4) 100%);
transition: letter-spacing 0.25s, font-size 0.25s;
pointer-events: none;
}
@@ -6058,8 +6703,15 @@ input#s_avatar {
}
@keyframes fadeInDown {
- from { opacity: 0; transform: translateY(-10px); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
.nav-user-btn {
@@ -6109,6 +6761,8 @@ input#s_avatar {
.nav-avatar-btn.is-active {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
}
.nav-avatar-btn.is-active .nav-avatar-caret {
@@ -6157,10 +6811,11 @@ input#s_avatar {
.nav-user-menu {
display: none;
position: absolute;
- top: calc(100% + 5px);
+ top: calc(100%);
left: auto;
right: 0;
- min-width: 100%; /* at least as wide as the avatar button */
+ min-width: 100%;
+ /* at least as wide as the avatar button */
background: var(--dropdown-bg);
border: 1px solid var(--nav-border-color);
border-radius: 0;
@@ -6283,8 +6938,15 @@ input#s_avatar {
}
@keyframes slideDown {
- from { transform: translateY(-20px); opacity: 0; }
- to { transform: translateY(0); opacity: 1; }
+ from {
+ transform: translateY(-20px);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
}
.flash-message.flash-success {
@@ -6415,7 +7077,7 @@ input#s_avatar {
#comments-container {
color: var(--white);
font-family: var(--font);
- background: rgba(0,0,0,0.2);
+ background: rgba(0, 0, 0, 0.2);
}
/* Definitions consolidated at line 1099 */
@@ -6472,7 +7134,7 @@ input#s_avatar {
width: 100%;
min-height: 80px;
background: #000000a1;
- border: 1px solid var(--gray);
+ border: none;
color: var(--white);
padding: 10px;
border-radius: 4px;
@@ -6563,13 +7225,14 @@ input#s_avatar {
margin: 8px 0;
border-radius: 4px;
overflow: hidden;
- box-shadow: 0 2px 12px rgba(0,0,0,0.5);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
}
.yt-embed-wrap::before {
content: '';
display: block;
- padding-top: 56.25%; /* 16:9 */
+ padding-top: 56.25%;
+ /* 16:9 */
}
.yt-embed-wrap iframe {
@@ -6590,7 +7253,7 @@ input#s_avatar {
margin: 8px 0;
border-radius: 4px;
overflow: hidden;
- box-shadow: 0 2px 12px rgba(0,0,0,0.5);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
background: #000;
}
@@ -6609,8 +7272,8 @@ input#s_avatar {
margin: 8px 0;
border-radius: 4px;
overflow: hidden;
- box-shadow: 0 2px 12px rgba(0,0,0,0.5);
- background: rgba(0,0,0,0.3);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
+ background: rgba(0, 0, 0, 0.3);
padding: 6px 8px;
}
@@ -6620,7 +7283,27 @@ input#s_avatar {
outline: none;
}
+/* Vocaroo embed wrapper */
+.vocaroo-embed-wrap {
+ display: block;
+ width: 100%;
+ max-width: 300px;
+ height: 60px;
+ margin: 8px 0;
+ border-radius: 4px;
+ overflow: hidden;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
+ background: #000;
+}
+
+.vocaroo-embed-wrap iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
@media (max-width: 600px) {
+
.yt-embed-wrap,
.video-embed-wrap,
.audio-embed-wrap {
@@ -7134,8 +7817,8 @@ span.badge.badge-current {
}
#help-button {
- padding: 5px;
- cursor: pointer;
+ padding: 5px;
+ cursor: pointer;
}
.comment-content img {
@@ -7165,6 +7848,10 @@ span.badge.badge-current {
font-style: italic;
}
+.sidebar-video-link .yt-title {
+ font-weight: 500;
+}
+
.sidebar-video-link i {
margin-right: 4px;
}
@@ -7204,9 +7891,20 @@ body.layout-modern .comment-content img.emoji {
}
@keyframes newItemFlash {
- 0% { border-left-color: var(--accent); background: rgba(var(--accent-rgb), 0.15); }
- 60% { border-left-color: var(--accent); background: rgba(var(--accent-rgb), 0.05); }
- 100% { border-left-color: transparent; background: transparent; }
+ 0% {
+ border-left-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.15);
+ }
+
+ 60% {
+ border-left-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.05);
+ }
+
+ 100% {
+ border-left-color: transparent;
+ background: transparent;
+ }
}
.new-item-fade {
@@ -7221,11 +7919,32 @@ body.layout-modern .comment-content img.emoji {
/* Comment posted by the current user — fades in + accent border that auto-disappears */
@keyframes commentEnter {
- 0% { opacity: 0; transform: translateY(6px); border-left-color: var(--accent); background: rgba(var(--accent-rgb), 0.15); }
- 20% { opacity: 1; transform: translateY(0); border-left-color: var(--accent); background: rgba(var(--accent-rgb), 0.15); }
- 60% { background: rgba(var(--accent-rgb), 0.05); }
- 70% { border-left-color: var(--accent); background: transparent; }
- 100% { border-left-color: transparent; }
+ 0% {
+ opacity: 0;
+ transform: translateY(6px);
+ border-left-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.15);
+ }
+
+ 20% {
+ opacity: 1;
+ transform: translateY(0);
+ border-left-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.15);
+ }
+
+ 60% {
+ background: rgba(var(--accent-rgb), 0.05);
+ }
+
+ 70% {
+ border-left-color: var(--accent);
+ background: transparent;
+ }
+
+ 100% {
+ border-left-color: transparent;
+ }
}
.comment-entering {
@@ -7289,7 +8008,8 @@ body.layout-modern .comment-content img.emoji {
/* Space for X button */
}
-#motd-display, #motd-display-guest {
+#motd-display,
+#motd-display-guest {
left: 5px;
position: relative;
}
@@ -7351,7 +8071,7 @@ html[theme="f0ck95d"] .badge-dark {
}
.steuerung {
- font-size: x-large;
+ font-size: large;
font-family: monospace;
}
@@ -7362,7 +8082,7 @@ html[theme="f0ck95d"] .badge-dark {
.blahlol {
grid-column: 1 / 4;
width: 100%;
- background: rgba(0,0,0,0.2);
+ background: rgba(0, 0, 0, 0.2);
}
@@ -7530,9 +8250,11 @@ video {
.image-brand {
width: 4cm;
}
+
.pagewrapper {
padding: 5px;
- padding-bottom: 65px; /* Reserve space for fixed pagination */
+ padding-bottom: 65px;
+ /* Reserve space for fixed pagination */
position: relative;
}
@@ -7540,6 +8262,7 @@ video {
.pagewrapper {
padding-right: 300px;
}
+
body.sidebar-right-hidden .pagewrapper {
padding-right: 5px;
}
@@ -7550,6 +8273,7 @@ video {
we disable the generic pagewrapper padding on those pages to avoid "double padding" or broken expansion.
*/
@media (min-width: 1200px) {
+
.pagewrapper:has(.index-layout-wrapper),
.pagewrapper:has(.item-layout-container),
.pagewrapper:has(.meme-layout-wrapper),
@@ -7668,9 +8392,9 @@ ol {
display: flex;
flex-direction: row;
align-items: center;
- flex-wrap: wrap; /* allows motd-container to wrap to its own full-width row */
+ flex-wrap: wrap;
+ /* allows motd-container to wrap to its own full-width row */
font-family: var(--font);
- text-transform: uppercase;
border-bottom: 1px solid var(--nav-border-color);
background: var(--nav-bg);
}
@@ -7681,7 +8405,7 @@ ol {
display: flex;
align-items: center;
height: 40px;
- width: 180px;
+ max-width: 180px;
}
/* Nav collapse: fills middle, always visible on desktop */
@@ -7746,9 +8470,17 @@ ol {
}
/* Animated X when open */
-.navbar-toggler.is-open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
-.navbar-toggler.is-open span:nth-child(2) { opacity: 0; }
-.navbar-toggler.is-open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
+.navbar-toggler.is-open span:nth-child(1) {
+ transform: translateY(7px) rotate(45deg);
+}
+
+.navbar-toggler.is-open span:nth-child(2) {
+ opacity: 0;
+}
+
+.navbar-toggler.is-open span:nth-child(3) {
+ transform: translateY(-7px) rotate(-45deg);
+}
/* Icon cluster: tags / filter / search */
.nav-icon-cluster {
@@ -7761,16 +8493,16 @@ ol {
}
.nav-icon-cluster a,
-.nav-item-rel > a,
-.nav-right-group > a {
+.nav-item-rel>a,
+.nav-right-group>a {
color: #fff;
opacity: 0.75;
transition: opacity 0.2s;
}
.nav-icon-cluster a:hover,
-.nav-item-rel > a:hover,
-.nav-right-group > a:hover {
+.nav-item-rel>a:hover,
+.nav-right-group>a:hover {
opacity: 1;
}
@@ -7783,7 +8515,8 @@ ol {
.nav-right-group .notif-dropdown {
left: auto;
- right: 0; /* anchor to bell container right edge; JS overrides on mobile */
+ right: 0;
+ /* anchor to bell container right edge; JS overrides on mobile */
transform: none;
}
@@ -7830,7 +8563,8 @@ ol {
/* nav-collapse: hidden by default, opens as full-width row below brand+right */
.nav-collapse {
display: none;
- flex: none; /* override desktop flex:1 so width:100% takes effect */
+ flex: none;
+ /* override desktop flex:1 so width:100% takes effect */
order: 10;
width: 100%;
flex-direction: row;
@@ -7839,7 +8573,7 @@ ol {
padding: 6px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: var(--nav-bg);
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
gap: 4px;
}
@@ -7849,8 +8583,15 @@ ol {
}
@keyframes navFadeIn {
- from { opacity: 0; transform: translateY(-4px); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
/* Nav links: horizontal row on mobile too */
@@ -7863,7 +8604,7 @@ ol {
gap: 4px;
}
- .nav-links > a {
+ .nav-links>a {
display: inline-flex;
align-items: center;
padding: 8px 12px;
@@ -7882,7 +8623,7 @@ ol {
.nav-halls-dropdown .nav-user-menu {
position: static !important;
display: none;
- background: rgba(255,255,255,0.04);
+ background: rgba(255, 255, 255, 0.04);
box-shadow: none;
border: none;
right: auto;
@@ -7908,6 +8649,7 @@ ol {
}
@media (max-width: 467px) {
+
.nav-avatar-name,
.nav-avatar-caret {
display: none;
@@ -7979,7 +8721,7 @@ input:checked+.slider:before {
border-radius: 3px;
overflow: hidden;
vertical-align: middle;
- grid-column: 3;
+ grid-column: 1;
}
.mode-btn {
@@ -8028,6 +8770,7 @@ input:checked+.slider:before {
min-width: 50px;
justify-content: center;
opacity: 0.8;
+ grid-row: 2;
}
.shuffle-btn:hover {
@@ -8040,13 +8783,18 @@ input:checked+.slider:before {
.shuffle-btn.active {
background: var(--accent);
color: var(--bg, #000);
- border-color: rgba(255,255,255,0.2);
+ border-color: rgba(255, 255, 255, 0.2);
font-weight: bold;
}
@keyframes shuffle-spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
}
.shuffle-btn.is-shuffling .shuffle-icon {
@@ -8088,10 +8836,13 @@ input:checked+.slider:before {
z-index: 10000;
}
+body.modal-open {
+ overflow: hidden;
+}
+
.modal-content {
background: #222;
padding: 20px;
- border-radius: 8px;
width: 90%;
max-width: 400px;
color: #fff;
@@ -8206,14 +8957,12 @@ textarea.mod-reason {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- padding: 0 15px;
}
.notif-time {
font-family: monospace;
font-size: 0.8em;
color: #444;
- text-align: right;
}
.notif-item:hover .notif-time {
@@ -8328,7 +9077,8 @@ textarea.mod-reason {
}
.approval-card-media img,
-.approval-card-media video {
+.approval-card-media video,
+.approval-card-media iframe {
max-width: 100%;
max-height: 300px;
width: 100%;
@@ -8482,16 +9232,16 @@ textarea.mod-reason {
padding: 0 4px;
}
-div.posts > a.thumb.has-notif {
+div.posts>a.thumb.has-notif {
box-shadow: inset 0 0 0 1000px rgba(255, 0, 0, 0.28) !important;
border: 1px solid var(--accent);
}
-div.posts > a.thumb.has-notif:hover {
+div.posts>a.thumb.has-notif:hover {
box-shadow: inset 0 0 0 1000px rgba(255, 0, 0, 0.4), 0 0 0 2px var(--accent) !important;
}
-div.posts > a.thumb.has-notif::after {
+div.posts>a.thumb.has-notif::after {
content: attr(data-ext) " / " attr(data-user) !important;
visibility: hidden;
opacity: 1 !important;
@@ -8501,11 +9251,11 @@ div.posts > a.thumb.has-notif::after {
z-index: 10 !important;
}
-div.posts > a.thumb.has-notif:hover::after {
+div.posts>a.thumb.has-notif:hover::after {
visibility: visible !important;
}
-div.posts > a.thumb.has-notif p::after {
+div.posts>a.thumb.has-notif p::after {
content: "!" !important;
position: absolute !important;
bottom: 10px !important;
@@ -8524,7 +9274,8 @@ div.posts > a.thumb.has-notif p::after {
position: relative;
overflow: hidden;
}
-.thumb > .preview-video {
+
+.thumb>.preview-video {
position: absolute;
top: 0;
left: 0;
@@ -8536,18 +9287,22 @@ div.posts > a.thumb.has-notif p::after {
opacity: 0;
transition: opacity 0.2s ease-in;
}
-.thumb > .preview-video.playing {
+
+.thumb>.preview-video.playing {
opacity: 1;
}
+
.thumb p {
position: relative;
z-index: 10;
pointer-events: none;
}
-div.posts > a::after {
+
+div.posts>a::after {
z-index: 10 !important;
pointer-events: none;
}
+
/* Fallback placeholder for thumbnails that failed to load */
.thumb.thumb-fallback {
background-size: contain !important;
@@ -8557,15 +9312,15 @@ div.posts > a::after {
.pin-indicator {
- position: absolute;
- top: 5px;
- left: 5px;
- font-size: 13px;
- fill: var(--accent);
- color: var(--accent);
- filter: drop-shadow(0 0 2px rgba(0,0,0,0.8));
- z-index: 15;
- pointer-events: none;
+ position: absolute;
+ top: 5px;
+ left: 5px;
+ font-size: 13px;
+ fill: var(--accent);
+ color: var(--accent);
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8));
+ z-index: 15;
+ pointer-events: none;
}
.oc-indicator {
@@ -8582,8 +9337,8 @@ div.posts > a::after {
/* For sub-cards which have a simpler structure */
.sub-link .pin-indicator {
- top: 3px;
- left: 3px;
+ top: 3px;
+ left: 3px;
}
/* Content Warning Modal */
@@ -8609,7 +9364,7 @@ div.posts > a::after {
width: 90%;
text-align: center;
border-radius: 4px;
- box-shadow: 0 0 20px rgba(0,0,0,0.5);
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
color: var(--font-color);
}
@@ -8661,47 +9416,50 @@ div.posts > a::after {
/* Video Player Settings Menu */
.v0ck_settings_container {
- position: relative;
- display: flex; /* Fix alignment */
- align-items: center;
+ position: relative;
+ display: flex;
+ /* Fix alignment */
+ align-items: center;
}
.v0ck_settings_menu {
- position: absolute;
- bottom: 100%; /* Position above the button */
- right: 0;
- margin-bottom: 5px;
- background-color: rgba(0, 0, 0, 0.9);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- padding: 5px 0;
- display: flex;
- flex-direction: column;
- min-width: 100px;
- z-index: 20; /* Ensure it's above other controls */
- box-shadow: 0 4px 6px rgba(0,0,0,0.3);
+ position: absolute;
+ bottom: 100%;
+ /* Position above the button */
+ right: 0;
+ margin-bottom: 5px;
+ background-color: rgba(0, 0, 0, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 5px 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 100px;
+ z-index: 20;
+ /* Ensure it's above other controls */
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.v0ck_settings_menu.v0ck_hidden {
- display: none;
+ display: none;
}
.v0ck_menu_item {
- background: transparent;
- border: none;
- color: #fff;
- padding: 8px 15px;
- text-align: left;
- cursor: pointer;
- font-size: 14px;
- font-family: inherit;
- white-space: nowrap;
- transition: background-color 0.2s;
- width: 100%;
+ background: transparent;
+ border: none;
+ color: #fff;
+ padding: 8px 15px;
+ text-align: left;
+ cursor: pointer;
+ font-size: 14px;
+ font-family: inherit;
+ white-space: nowrap;
+ transition: background-color 0.2s;
+ width: 100%;
}
.v0ck_menu_item:hover {
- background-color: rgba(255, 255, 255, 0.1);
+ background-color: rgba(255, 255, 255, 0.1);
}
@@ -8709,104 +9467,112 @@ div.posts > a::after {
/* ... (rest of CSS) */
/* Specific styling for SWF/BG buttons now in menu */
#toggleswf {
- display: block;
- width: 100%;
- text-align: center; /* Center text */
+ display: block;
+ width: 100%;
+ text-align: center;
+ /* Center text */
}
/* Background Row Styling */
.v0ck_bg_row {
- display: flex;
- align-items: center;
- justify-content: space-between; /* Label on left, switch on right? Or center? User asked for "items centered". */
- /* If items centered, maybe justify-content: center with a gap? */
- /* Let's try space-between for the row content (Label ... Switch) looks better usually,
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ /* Label on left, switch on right? Or center? User asked for "items centered". */
+ /* If items centered, maybe justify-content: center with a gap? */
+ /* Let's try space-between for the row content (Label ... Switch) looks better usually,
but if the menu is wide, it might look odd.
Let's stick to space-between for the row, but ensure the menu itself centers the row?
The row IS the menu item.
Let's use space-between for label and switch.
*/
- justify-content: space-between;
- padding: 8px 15px;
- width: 100%;
- box-sizing: border-box;
+ justify-content: space-between;
+ padding: 8px 15px;
+ width: 100%;
+ box-sizing: border-box;
}
.v0ck_switch_label {
- margin-right: 10px;
- font-size: 14px;
- color: #fff;
+ margin-right: 10px;
+ font-size: 14px;
+ color: #fff;
}
/* Cool Switch Styling */
.v0ck_cool_switch {
- width: 36px;
- height: 20px;
- background-color: #555;
- border-radius: 10px;
- position: relative;
- cursor: pointer;
- transition: background-color 0.3s;
+ width: 36px;
+ height: 20px;
+ background-color: #555;
+ border-radius: 10px;
+ position: relative;
+ cursor: pointer;
+ transition: background-color 0.3s;
}
.v0ck_cool_switch::after {
- content: '';
- position: absolute;
- top: 2px;
- left: 2px;
- width: 16px;
- height: 16px;
- background-color: #fff;
- border-radius: 50%;
- transition: transform 0.3s;
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 16px;
+ height: 16px;
+ background-color: #fff;
+ border-radius: 50%;
+ transition: transform 0.3s;
}
.v0ck_cool_switch.active {
- background-color: var(--gray);
+ background-color: var(--gray);
}
.v0ck_cool_switch.active::after {
- transform: translateX(16px);
+ transform: translateX(16px);
}
.v0ck_cool_switch:hover {
- opacity: 0.9;
+ opacity: 0.9;
}
/* Update Menu Item Styling for Centering */
.v0ck_menu_item {
- /* ... existing styles ... */
- /* We want to center the SWF button text */
- text-align: center;
+ /* ... existing styles ... */
+ /* We want to center the SWF button text */
+ text-align: center;
}
/* Ensure menu itself centers items? */
.v0ck_settings_menu {
- /* ... existing styles ... */
- align-items: center; /* Center items horizontally */
- min-width: 160px; /* Ensure enough width */
+ /* ... existing styles ... */
+ align-items: center;
+ /* Center items horizontally */
+ min-width: 160px;
+ /* Ensure enough width */
}
/* Fix hover effect for settings button (viewBox scaling issue) */
.v0ck_settings_btn svg:hover {
- stroke-width: 2px !important; /* Override the default 30px which is for large viewBoxes */
+ stroke-width: 2px !important;
+ /* Override the default 30px which is for large viewBoxes */
}
+
/* Ensure SVG is vertically centered */
.v0ck_settings_btn svg {
- vertical-align: middle;
+ vertical-align: middle;
}
/* Mobile Optimized Player Controls */
@media screen and (max-width: 600px) {
- .v0ck_volume:hover+input[type="range"][name="volume"],
- .v0ck_player_controls>input[type="range"][name="volume"]:hover {
- min-width: 40px !important; /* Smaller expansion on mobile */
- max-width: 40px !important;
- }
+
+ .v0ck_volume:hover+input[type="range"][name="volume"],
+ .v0ck_player_controls>input[type="range"][name="volume"]:hover {
+ min-width: 40px !important;
+ /* Smaller expansion on mobile */
+ max-width: 40px !important;
+ }
}
.navbar {
- background: rgba(0,0,0,0.2);
+ background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--nav-border-color);
}
@@ -8815,13 +9581,13 @@ body.layout-legacy .navbar-header {
background: transparent;
}
- .sidebar-tags-container {
- padding: 5px;
- background: rgba(0,0,0,0.2);
- border-top: 1px solid var(--nav-border-color);
- overflow: visible;
- flex-shrink: 0;
- }
+.sidebar-tags-container {
+ padding: 5px;
+ background: rgba(0, 0, 0, 0.2);
+ border-top: 1px solid var(--nav-border-color);
+ overflow: visible;
+ flex-shrink: 0;
+}
/* ===== layout-modern: flexible by default, viewport-locked for item view ===== */
@media (min-width: 1000px) {
@@ -8832,13 +9598,13 @@ body.layout-legacy .navbar-header {
overflow-y: auto;
}
- /* App-like viewport lock: only for item view */
+ /* Removed app-like viewport lock to enable body scrolling */
body.layout-modern:has(.item-layout-container) {
- overflow: hidden;
- height: 100vh;
+ overflow: visible;
+ height: auto;
}
- body.layout-modern > .pagewrapper {
+ body.layout-modern>.pagewrapper {
flex: 1;
display: flex;
flex-direction: column;
@@ -8846,26 +9612,27 @@ body.layout-legacy .navbar-header {
padding-top: 0 !important;
padding-left: 0 !important;
padding-bottom: 0 !important;
- padding-right: 0; /* overridden to 300px by sidebar rule below for generic pages */
+ padding-right: 0;
+ /* overridden to 300px by sidebar rule below for generic pages */
}
- body.layout-modern:has(.item-layout-container) > .pagewrapper {
+ body.layout-modern:has(.item-layout-container)>.pagewrapper {
min-height: 0;
- overflow: hidden;
+ overflow: visible;
}
- body.layout-modern > .pagewrapper > #main,
- body.layout-modern > #main {
+ body.layout-modern>.pagewrapper>#main,
+ body.layout-modern>#main {
flex: 1;
display: flex;
flex-direction: column;
align-items: normal;
}
- body.layout-modern:has(.item-layout-container) > .pagewrapper > #main,
- body.layout-modern:has(.item-layout-container) > #main {
+ body.layout-modern:has(.item-layout-container)>.pagewrapper>#main,
+ body.layout-modern:has(.item-layout-container)>#main {
min-height: 0;
- overflow: hidden;
+ overflow: visible;
}
}
@@ -8873,10 +9640,11 @@ body.layout-legacy .navbar-header {
(profile, settings, subscriptions, etc.) — identical behaviour to layout-legacy.
Specialized pages (index, item, meme) manage their own layout, so exclude them. */
@media (min-width: 1200px) {
- body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
+ body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
padding-right: 300px;
}
- body.layout-modern.sidebar-right-hidden > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
+
+ body.layout-modern.sidebar-right-hidden>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
padding-right: 0;
}
}
@@ -8884,7 +9652,7 @@ body.layout-legacy .navbar-header {
/* For generic pages in layout-modern, reset #main to plain block flow so children
fill the full available width — mirrors layout-legacy exactly and prevents any
flex child centering (e.g. margin:0 auto shrinking content like on /subscriptions). */
-body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)):not(:has(.messages-convo-page)) > #main {
+body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)):not(:has(.messages-convo-page))>#main {
display: block !important;
width: 100%;
}
@@ -8923,15 +9691,15 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
display: block;
}
- body.layout-legacy > .wrapper,
- body.layout-legacy > .pagewrapper {
+ body.layout-legacy>.wrapper,
+ body.layout-legacy>.pagewrapper {
display: block;
height: auto;
min-height: 100vh;
padding-top: 0 !important;
}
- body.layout-legacy > #main:not(.messages-convo-page),
+ body.layout-legacy>#main:not(.messages-convo-page),
body.layout-legacy #main:not(.messages-convo-page) {
display: block;
height: auto;
@@ -9178,7 +9946,7 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
- box-shadow: 0 1px 0 rgba(0,0,0,0.5);
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
}
#help-button:hover {
@@ -9194,7 +9962,7 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
#shortcuts-modal {
padding: 10px;
}
-
+
.shortcuts-content h2 {
font-size: 1.2rem;
margin-bottom: 15px !important;
@@ -9214,6 +9982,7 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
.id-link {
color: var(--white);
}
+
/* Image Modal / Lightbox */
.image-modal-overlay {
background-color: rgba(0, 0, 0, 0.7);
@@ -9227,7 +9996,8 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: opacity 0.3s ease;
- display: none; /* Hidden by default */
+ display: none;
+ /* Hidden by default */
flex-direction: column;
}
@@ -9280,10 +10050,12 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
height: 100% !important;
border-radius: 2px;
animation: modalZoomIn 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
- cursor: move !important; /* Fallback */
+ cursor: move !important;
+ /* Fallback */
cursor: -webkit-grab !important;
cursor: -moz-grab !important;
- cursor: grab !important; /* Standard rules must be last */
+ cursor: grab !important;
+ /* Standard rules must be last */
transform-origin: center;
transition: transform 0.05s ease-out;
user-select: none;
@@ -9294,33 +10066,45 @@ body.layout-modern > .pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.ite
.image-modal-container {
padding: 0 !important;
}
+
.image-modal-container img#image-modal-img {
max-width: 100vw !important;
max-height: 100vh !important;
object-fit: contain;
}
+
.image-modal-close {
top: 10px;
right: 15px;
width: 40px;
height: 40px;
font-size: 28px;
- background: rgba(0, 0, 0, 0.8); /* Higher contrast on mobile */
+ background: rgba(0, 0, 0, 0.8);
+ /* Higher contrast on mobile */
}
}
/* Hardened 'Closed Grab' / Grabbing state */
body.modal-is-grabbing,
body.modal-is-grabbing * {
- cursor: move !important; /* Fallback */
+ cursor: move !important;
+ /* Fallback */
cursor: -webkit-grabbing !important;
cursor: -moz-grabbing !important;
- cursor: grabbing !important; /* Standard rules must be last */
+ cursor: grabbing !important;
+ /* Standard rules must be last */
}
@keyframes modalZoomIn {
- from { transform: scale(0.98); opacity: 0; }
- to { transform: scale(1); opacity: 1; }
+ from {
+ transform: scale(0.98);
+ opacity: 0;
+ }
+
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
}
body.modal-open {
@@ -9329,112 +10113,112 @@ body.modal-open {
}
body.layout-modern .tag-controls {
- background: rgba(0,0,0,0.2);
- border-top: 1px solid var(--nav-border-color);
+ background: rgba(0, 0, 0, 0.2);
+ border-top: 1px solid var(--nav-border-color);
}
.phrase {
text-align: center;
font-size: small;
- background: rgba(0,0,0,0.2);
+ background: rgba(0, 0, 0, 0.2);
}
+
/* Global Drag & Drop Overlay */
#drop-overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(var(--accent-rgb), 0.15);
- backdrop-filter: blur(8px);
- z-index: 10000;
- display: none;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- border: 4px dashed var(--accent);
- margin: 10px;
- width: calc(100% - 20px);
- height: calc(100% - 20px);
- border-radius: 20px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(var(--accent-rgb), 0.15);
+ backdrop-filter: blur(8px);
+ z-index: 10000;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ border: 4px dashed var(--accent);
+ margin: 10px;
+ width: calc(100% - 20px);
+ height: calc(100% - 20px);
+ border-radius: 20px;
}
#drop-overlay.active {
- display: flex;
- pointer-events: auto;
+ display: flex;
+ pointer-events: auto;
}
#drop-overlay .overlay-content {
- background: var(--shade1, #111);
- padding: 40px;
- border-radius: 20px;
- text-align: center;
- box-shadow: 0 20px 60px rgba(0,0,0,1);
- border: 1px solid var(--shade3, #333);
+ background: var(--shade1, #111);
+ padding: 40px;
+ border-radius: 20px;
+ text-align: center;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 1);
+ border: 1px solid var(--shade3, #333);
}
#drop-overlay h2 {
- margin: 15px 0 0 0;
- font-size: 2rem;
- color: var(--white);
- text-transform: uppercase;
- letter-spacing: 2px;
+ margin: 15px 0 0 0;
+ font-size: 2rem;
+ color: var(--white);
+ text-transform: uppercase;
+ letter-spacing: 2px;
}
#drop-overlay svg {
- color: var(--accent);
- filter: drop-shadow(0 0 15px var(--accent));
+ color: var(--accent);
+ filter: drop-shadow(0 0 15px var(--accent));
}
/* Upload Drag Modal */
#upload-drag-modal {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0,0,0,0.8);
- backdrop-filter: blur(5px);
- z-index: 10001;
- display: none;
- align-items: center;
- justify-content: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.8);
+ backdrop-filter: blur(5px);
+ z-index: 10001;
+ display: none;
+ align-items: center;
+ justify-content: center;
}
#upload-drag-modal.show {
- display: flex;
+ display: flex;
}
#upload-drag-modal .modal-content {
- background: var(--shade1, #111);
- border: 1px solid var(--shade3, #333);
- border-radius: 8px;
- padding: 0;
- width: 900px;
- max-width: 95vw;
- position: relative;
- box-shadow: 0 10px 40px rgba(0,0,0,0.8);
- max-height: 90vh;
- overflow-y: auto;
+ background: var(--shade1, #111);
+ border: 1px solid var(--shade3, #333);
+ padding: 0;
+ width: 900px;
+ max-width: 95vw;
+ position: relative;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8);
+ max-height: 90vh;
+ overflow-y: auto;
}
#upload-drag-modal .modal-close {
- position: absolute;
- top: 15px;
- right: 15px;
- background: none;
- border: none;
- color: var(--white);
- font-size: 24px;
- cursor: pointer;
- line-height: 1;
- z-index: 10;
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ background: none;
+ border: none;
+ color: var(--white);
+ font-size: 24px;
+ cursor: pointer;
+ line-height: 1;
+ z-index: 10;
}
#upload-drag-modal .upload-container {
- max-width: 100%;
- width: 100%;
+ max-width: 100%;
+ width: 100%;
}
/* Private Society Gate */
@@ -9504,17 +10288,17 @@ body.layout-modern .tag-controls {
align-content: center;
border-top: 1px solid var(--nav-border-color);
flex-shrink: 0;
- background: rgba(0,0,0,0.3);
+ background: rgba(0, 0, 0, 0.3);
min-height: 30px;
text-align: center;
}
-.global-sidebar-right-footer > a {
+.global-sidebar-right-footer>a {
border-right: 1px solid var(--gray);
opacity: 0.8;
}
-.global-sidebar-right-footer > a:hover {
+.global-sidebar-right-footer>a:hover {
opacity: 1;
}
@@ -9542,7 +10326,7 @@ body.layout-modern .tag-controls {
}
.item-preview a {
- align-self: center;
+ align-self: center;
}
.item-preview a img {
@@ -9552,54 +10336,57 @@ body.layout-modern .tag-controls {
border-radius: 5px;
margin: 5px;
}
+
/* ═══════════════════════════════════════════════════════════
PRIVATE MESSAGES / DM SYSTEM
═══════════════════════════════════════════════════════════ */
/* ── Page layout ─────────────────────────────────────────── */
.messages-page {
- max-width: 720px;
- margin: 0 auto;
- padding: 16px;
- display: flex;
- flex-direction: column;
- min-height: auto;
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ min-height: auto;
}
.messages-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 18px;
- border-bottom: 1px solid var(--border, #333);
- padding-bottom: 12px;
- gap: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 18px;
+ border-bottom: 1px solid var(--border, #333);
+ padding-bottom: 12px;
+ gap: 12px;
}
.messages-header h2 {
- margin: 0;
- font-size: 1.1em;
- letter-spacing: 0.08em;
- color: var(--accent);
+ margin: 0;
+ font-size: 1.1em;
+ letter-spacing: 0.08em;
+ color: var(--accent);
}
.dm-back-btn {
- color: var(--accent);
- font-size: 1.4em;
- text-decoration: none;
- line-height: 1;
- padding: 4px 8px;
- border-radius: 4px;
- transition: background 0.15s;
+ color: var(--accent);
+ font-size: 1.4em;
+ text-decoration: none;
+ line-height: 1;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: background 0.15s;
}
-.dm-back-btn:hover { background: var(--bg2, #222); }
+.dm-back-btn:hover {
+ background: var(--bg2, #222);
+}
.dm-convo-header-info {
- display: flex;
- align-items: center;
- gap: 10px;
- flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
}
.dm-convo-badge.badge-nsfw {
@@ -9625,171 +10412,175 @@ body.layout-modern .tag-controls {
}
.dm-header-avatar {
- width: 32px;
- height: 32px;
- border-radius: 4px;
- object-fit: cover;
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+ object-fit: cover;
}
.dm-header-username {
- font-weight: 600;
- font-size: 1em;
- text-decoration: none;
- color: var(--fg, #ddd);
+ font-weight: 600;
+ font-size: 1em;
+ text-decoration: none;
+ color: var(--fg, #ddd);
}
-.dm-header-username:hover { text-decoration: underline; }
+.dm-header-username:hover {
+ text-decoration: underline;
+}
/* ── Key notice banner ───────────────────────────────────── */
.dm-key-notice {
- background: rgba(255, 200, 80, 0.15);
- border: 1px solid rgba(255, 200, 80, 0.4);
- color: #ffc850;
- padding: 10px 14px;
- border-radius: 6px;
- font-size: 0.88em;
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 14px;
- flex-wrap: wrap;
+ background: rgba(255, 200, 80, 0.15);
+ border: 1px solid rgba(255, 200, 80, 0.4);
+ color: #ffc850;
+ padding: 10px 14px;
+ border-radius: 6px;
+ font-size: 0.88em;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
}
.dm-key-export-inline {
- background: rgba(255, 200, 80, 0.25);
- border: 1px solid rgba(255, 200, 80, 0.5);
- color: #ffc850;
- padding: 3px 10px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 0.9em;
+ background: rgba(255, 200, 80, 0.25);
+ border: 1px solid rgba(255, 200, 80, 0.5);
+ color: #ffc850;
+ padding: 3px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9em;
}
-.dm-key-export-inline:hover { background: rgba(255, 200, 80, 0.4); }
+.dm-key-export-inline:hover {
+ background: rgba(255, 200, 80, 0.4);
+}
/* ── Inbox list ──────────────────────────────────────────── */
.dm-inbox-list {
- display: flex;
- flex-direction: column;
- gap: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
}
.dm-convo-card {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px 14px;
- border-radius: 8px;
- background: var(--bg2, #1a1a1a);
- border: 1px solid transparent;
- text-decoration: none;
- color: var(--fg, #ccc);
- transition: background 0.15s, border-color 0.15s;
- position: relative;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 8px;
+ background: var(--bg2, #1a1a1a);
+ border: 1px solid transparent;
+ text-decoration: none;
+ color: var(--fg, #ccc);
+ transition: background 0.15s, border-color 0.15s;
+ position: relative;
}
.dm-convo-card:hover {
- background: var(--bg3, #222);
- border-color: var(--accent);
+ background: var(--bg3, #222);
+ border-color: var(--accent);
}
.dm-convo-card.dm-convo-unread {
- border-color: var(--accent);
+ border-color: var(--accent);
}
.dm-convo-avatar {
- width: 42px;
- height: 42px;
- border-radius: 6px;
- object-fit: cover;
- flex-shrink: 0;
+ width: 42px;
+ height: 42px;
+ border-radius: 6px;
+ object-fit: cover;
+ flex-shrink: 0;
}
.dm-convo-info {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
}
.dm-convo-name {
- font-weight: 600;
- font-size: 0.95em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ font-weight: 600;
+ font-size: 0.95em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.dm-convo-time {
- font-size: 0.78em;
- color: #666;
+ font-size: 0.78em;
+ color: #666;
}
.dm-convo-badge {
- background: var(--accent);
- color: var(--bg, #000);
- font-size: 0.72em;
- font-weight: 700;
- min-width: 20px;
- height: 20px;
- border-radius: 10px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0 5px;
- flex-shrink: 0;
+ background: var(--accent);
+ color: var(--bg, #000);
+ font-size: 0.72em;
+ font-weight: 700;
+ min-width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 5px;
+ flex-shrink: 0;
}
/* ── Thread ──────────────────────────────────────────────── */
.dm-thread {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 15px;
- min-height: 200px;
- max-height: calc(100vh - 420px);
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 15px;
+ min-height: 200px;
+ max-height: calc(100vh - 420px);
}
/* ── Message bubbles ─────────────────────────────────────── */
.dm-msg {
- display: flex;
- flex-direction: column;
- max-width: 75%;
- gap: 3px;
+ display: flex;
+ flex-direction: column;
+ max-width: 75%;
+ gap: 3px;
}
.dm-msg-mine {
- align-self: flex-end;
- align-items: flex-end;
+ align-self: flex-end;
+ align-items: flex-end;
}
.dm-msg-theirs {
- align-self: flex-start;
- align-items: flex-start;
+ align-self: flex-start;
+ align-items: flex-start;
}
.dm-bubble {
- padding: 9px 13px;
- border-radius: 14px;
- font-size: 0.92em;
- line-height: 1.45;
- word-break: break-word;
+ padding: 9px 13px;
+ border-radius: 14px;
+ font-size: 0.92em;
+ line-height: 1.45;
+ word-break: break-word;
}
/* Full-width when message contains a block embed (YouTube etc.) */
.dm-msg.dm-has-embed {
- width: 90%;
- min-width: 280px;
- max-width: 90%;
+ width: 90%;
+ min-width: 280px;
+ max-width: 90%;
}
.dm-bubble .yt-embed-wrap {
- max-width: 100%;
- margin: 4px 0 0;
- min-width: 400px;
+ max-width: 100%;
+ margin: 4px 0 0;
+ min-width: 400px;
}
@media (max-width: 700px) {
@@ -9799,347 +10590,390 @@ body.layout-modern .tag-controls {
}
.dm-msg-mine .dm-bubble {
- background: var(--badge-bg);
- color: var(--white);
- border-bottom-right-radius: 4px;
+ background: var(--badge-bg);
+ color: var(--white);
+ border-bottom-right-radius: 4px;
}
.dm-msg-theirs .dm-bubble {
- background: var(--bg2, #222);
- color: var(--fg, #ddd);
- border: 1px solid #333;
- border-bottom-left-radius: 4px;
+ background: var(--bg2, #222);
+ color: var(--fg, #ddd);
+ border: 1px solid #333;
+ border-bottom-left-radius: 4px;
}
.dm-msg-time {
- font-size: 0.72em;
- color: #555;
- padding: 0 4px;
+ font-size: 0.72em;
+ color: #555;
+ padding: 0 4px;
}
.dm-unreadable {
- font-style: italic;
- opacity: 0.5;
- font-size: 0.88em;
+ font-style: italic;
+ opacity: 0.5;
+ font-size: 0.88em;
}
/* ── Send form ───────────────────────────────────────────── */
.dm-send-form {
- gap: 8px;
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid #333;
- flex-shrink: 0;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid #333;
+ flex-shrink: 0;
}
.dm-input {
- flex: 1;
- background: var(--bg2, #1a1a1a);
- border: 1px solid #444;
- color: var(--fg, #ddd);
- border-radius: 8px;
- padding: 10px 12px;
- font-size: 0.92em;
- resize: none;
- min-height: 44px;
- max-height: 140px;
- font-family: inherit;
- transition: border-color 0.15s;
- line-height: 1.4;
+ flex: 1;
+ background: var(--bg2, #1a1a1a);
+ border: 1px solid #444;
+ color: var(--fg, #ddd);
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-size: 0.92em;
+ resize: none;
+ min-height: 44px;
+ max-height: 140px;
+ font-family: inherit;
+ transition: border-color 0.15s;
+ line-height: 1.4;
}
.dm-input:focus {
- outline: none;
- border-color: var(--accent);
+ outline: none;
+ border-color: var(--accent);
}
.dm-send-btn {
- background: var(--accent);
- color: var(--bg, #000);
- border: none;
- border-radius: 8px;
- padding: 10px 18px;
- font-weight: 700;
- cursor: pointer;
- font-size: 0.9em;
- height: 44px;
- flex-shrink: 0;
- transition: opacity 0.15s;
+ background: var(--accent);
+ color: var(--bg, #000);
+ border: none;
+ border-radius: 8px;
+ padding: 10px 18px;
+ font-weight: 700;
+ cursor: pointer;
+ font-size: 0.9em;
+ height: 44px;
+ flex-shrink: 0;
+ transition: opacity 0.15s;
}
-.dm-send-btn:hover { opacity: 0.85; }
-.dm-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.dm-send-btn:hover {
+ opacity: 0.85;
+}
+
+.dm-send-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
/* ── State messages ──────────────────────────────────────── */
-.dm-loading, .dm-empty, .dm-error {
- color: #666;
- font-size: 0.9em;
- padding: 24px;
- text-align: center;
- line-height: 1.6;
+.dm-loading,
+.dm-empty,
+.dm-error {
+ color: #666;
+ font-size: 0.9em;
+ padding: 24px;
+ text-align: center;
+ line-height: 1.6;
}
-.dm-error { color: #e06c6c; }
+.dm-error {
+ color: #e06c6c;
+}
/* ── Key Manager Modal ───────────────────────────────────── */
.dm-modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.75);
- z-index: 15000;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ z-index: 15000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
}
.dm-modal {
- background: var(--bg, #111);
- border: 1px solid var(--accent);
- border-radius: 10px;
- width: 100%;
- max-width: 480px;
- max-height: 90vh;
- overflow-y: auto;
- padding: 28px;
- position: relative;
+ background: var(--bg, #111);
+ border: 1px solid var(--accent);
+ border-radius: 10px;
+ width: 100%;
+ max-width: 480px;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding: 28px;
+ position: relative;
}
.dm-modal h2 {
- margin: 0 0 6px;
- font-size: 1.1em;
- color: var(--accent);
+ margin: 0 0 6px;
+ font-size: 1.1em;
+ color: var(--accent);
}
.dm-modal-sub {
- font-size: 0.85em;
- color: #888;
- margin: 0 0 18px;
- line-height: 1.5;
+ font-size: 0.85em;
+ color: #888;
+ margin: 0 0 18px;
+ line-height: 1.5;
}
.dm-modal-close {
- position: absolute;
- top: 14px;
- right: 16px;
- background: none;
- border: none;
- color: #888;
- font-size: 1.4em;
- cursor: pointer;
- line-height: 1;
+ position: absolute;
+ top: 14px;
+ right: 16px;
+ background: none;
+ border: none;
+ color: #888;
+ font-size: 1.4em;
+ cursor: pointer;
+ line-height: 1;
}
-.dm-modal-close:hover { color: var(--fg, #ddd); }
+.dm-modal-close:hover {
+ color: var(--fg, #ddd);
+}
.dm-key-status {
- background: rgba(255,255,255,0.04);
- border-radius: 6px;
- padding: 10px 12px;
- font-size: 0.85em;
- margin-bottom: 18px;
- border: 1px solid #333;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 6px;
+ padding: 10px 12px;
+ font-size: 0.85em;
+ margin-bottom: 18px;
+ border: 1px solid #333;
}
.dm-key-section {
- margin-bottom: 20px;
- padding-bottom: 20px;
- border-bottom: 1px solid #2a2a2a;
+ margin-bottom: 20px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid #2a2a2a;
}
-.dm-key-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
+.dm-key-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
.dm-key-section h3 {
- font-size: 0.9em;
- margin: 0 0 6px;
- color: var(--fg, #ccc);
+ font-size: 0.9em;
+ margin: 0 0 6px;
+ color: var(--fg, #ccc);
}
.dm-key-section p {
- font-size: 0.82em;
- color: #777;
- margin: 0 0 10px;
- line-height: 1.5;
+ font-size: 0.82em;
+ color: #777;
+ margin: 0 0 10px;
+ line-height: 1.5;
}
.dm-key-input {
- display: block;
- width: 100%;
- background: var(--bg2, #1a1a1a);
- border: 1px solid #444;
- color: var(--fg, #ddd);
- border-radius: 6px;
- padding: 8px 10px;
- font-size: 0.88em;
- margin-bottom: 8px;
- box-sizing: border-box;
- font-family: inherit;
+ display: block;
+ width: 100%;
+ background: var(--bg2, #1a1a1a);
+ border: 1px solid #444;
+ color: var(--fg, #ddd);
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-size: 0.88em;
+ margin-bottom: 8px;
+ box-sizing: border-box;
+ font-family: inherit;
}
-.dm-key-input:focus { outline: none; border-color: var(--accent); }
+.dm-key-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
.dm-key-btn {
- background: var(--accent);
- color: var(--bg, #000);
- border: none;
- border-radius: 6px;
- padding: 8px 16px;
- font-weight: 600;
- cursor: pointer;
- font-size: 0.86em;
- transition: opacity 0.15s;
+ background: var(--accent);
+ color: var(--bg, #000);
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ font-weight: 600;
+ cursor: pointer;
+ font-size: 0.86em;
+ transition: opacity 0.15s;
}
-.dm-key-btn:hover { opacity: 0.85; }
+.dm-key-btn:hover {
+ opacity: 0.85;
+}
.dm-key-btn-danger {
- background: #d94f4f;
- color: #fff;
+ background: #d94f4f;
+ color: #fff;
}
-.dm-key-danger h3 { color: #d94f4f; }
+.dm-key-danger h3 {
+ color: #d94f4f;
+}
.dm-key-msg {
- font-size: 0.82em;
- margin-top: 8px;
- min-height: 18px;
+ font-size: 0.82em;
+ margin-top: 8px;
+ min-height: 18px;
}
-.dm-msg-ok { color: #5cb85c; }
-.dm-msg-err { color: #e06c6c; }
+.dm-msg-ok {
+ color: #5cb85c;
+}
+
+.dm-msg-err {
+ color: #e06c6c;
+}
/* ── Blocking modal (seed-phrase setup / recovery) ───────── */
.dm-modal-blocking {
- z-index: 20000; /* above everything */
+ z-index: 20000;
+ /* above everything */
}
+
.dm-modal-blocking .dm-modal {
- max-width: 540px;
+ max-width: 540px;
}
/* ── Seed word grid ──────────────────────────────────────── */
.dm-seed-grid,
.dm-recovery-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 8px;
- margin: 16px 0;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin: 16px 0;
}
.dm-seed-word {
- display: flex;
- align-items: center;
- gap: 8px;
- background: rgba(255,255,255,0.05);
- border: 1px solid #333;
- border-radius: 6px;
- padding: 8px 10px;
- font-size: 0.9em;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid #333;
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-size: 0.9em;
}
.dm-seed-num {
- color: #555;
- font-size: 0.78em;
- min-width: 16px;
- text-align: right;
- flex-shrink: 0;
+ color: #555;
+ font-size: 0.78em;
+ min-width: 16px;
+ text-align: right;
+ flex-shrink: 0;
}
.dm-seed-val {
- color: var(--accent);
- font-weight: 600;
- font-family: monospace;
- letter-spacing: 0.03em;
- word-break: break-all;
+ color: var(--accent);
+ font-weight: 600;
+ font-family: monospace;
+ letter-spacing: 0.03em;
+ word-break: break-all;
}
/* Recovery inputs reuse .dm-seed-word layout */
.dm-seed-input-wrap input.dm-recovery-word {
- flex: 1;
- background: none;
- border: none;
- outline: none;
- color: var(--fg, #ddd);
- font-size: 0.88em;
- font-family: monospace;
- padding: 0;
- width: 100%;
- min-width: 0;
+ flex: 1;
+ background: none;
+ border: none;
+ outline: none;
+ color: var(--fg, #ddd);
+ font-size: 0.88em;
+ font-family: monospace;
+ padding: 0;
+ width: 100%;
+ min-width: 0;
}
.dm-seed-input-wrap:focus-within {
- border-color: var(--accent);
+ border-color: var(--accent);
}
/* Copy / primary button */
.dm-key-btn-primary {
- background: var(--accent);
- color: var(--bg, #000);
- width: 100%;
- margin-top: 4px;
+ background: var(--accent);
+ color: var(--bg, #000);
+ width: 100%;
+ margin-top: 4px;
}
.dm-key-btn-primary:disabled {
- opacity: 0.35;
- cursor: not-allowed;
+ opacity: 0.35;
+ cursor: not-allowed;
}
.dm-copy-seed {
- width: 100%;
- background: rgba(255,255,255,0.07);
- color: var(--fg, #ddd);
- border: 1px solid #444;
- margin-bottom: 14px;
+ width: 100%;
+ background: rgba(255, 255, 255, 0.07);
+ color: var(--fg, #ddd);
+ border: 1px solid #444;
+ margin-bottom: 14px;
}
/* Forced-confirm checkbox */
.dm-seed-confirm {
- background: rgba(255,200,80,0.08);
- border: 1px solid rgba(255,200,80,0.3);
- border-radius: 6px;
- padding: 12px 14px;
- margin-bottom: 14px;
+ background: rgba(255, 200, 80, 0.08);
+ border: 1px solid rgba(255, 200, 80, 0.3);
+ border-radius: 6px;
+ padding: 12px 14px;
+ margin-bottom: 14px;
}
.dm-seed-confirm label {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- cursor: pointer;
- font-size: 0.87em;
- color: #ffc850;
- line-height: 1.4;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ cursor: pointer;
+ font-size: 0.87em;
+ color: #ffc850;
+ line-height: 1.4;
}
.dm-seed-confirm input[type="checkbox"] {
- margin-top: 2px;
- flex-shrink: 0;
- accent-color: var(--accent);
- width: 16px;
- height: 16px;
+ margin-top: 2px;
+ flex-shrink: 0;
+ accent-color: var(--accent);
+ width: 16px;
+ height: 16px;
}
@media (max-width: 500px) {
- .dm-seed-grid, .dm-recovery-grid {
- grid-template-columns: repeat(2, 1fr);
- }
+
+ .dm-seed-grid,
+ .dm-recovery-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
}
/* ── Navbar DM icon ──────────────────────────────────────── */
#nav-dm-btn {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 3px;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
}
/* reuse .notif-count styling for dm badge — already defined */
/* ── Mobile adjustments ──────────────────────────────────── */
@media (max-width: 600px) {
- .messages-page { padding: 10px; }
- .dm-msg { max-width: 90%; }
- .dm-thread { max-height: calc(100vh - 240px); }
- .dm-modal { padding: 20px; }
+ .messages-page {
+ padding: 10px;
+ }
+
+ .dm-msg {
+ max-width: 90%;
+ }
+
+ .dm-thread {
+ max-height: calc(100vh - 240px);
+ }
+
+ .dm-modal {
+ padding: 20px;
+ }
}
/* ══════════════════════════════════════════════════════════
@@ -10150,105 +10984,106 @@ body.layout-modern .tag-controls {
position:fixed takes it out of flow entirely — no pagewrapper
padding, no body scroll involvement, just viewport-anchored. */
@media (min-width: 769px) {
- .messages-convo-page {
- position: fixed !important;
- top: var(--navbar-h, 50px) !important;
- bottom: 0 !important;
- left: 0 !important;
- right: 0 !important;
- z-index: 1;
- display: flex !important;
- flex-direction: column !important;
- }
+ .messages-convo-page {
+ position: fixed !important;
+ top: var(--navbar-h, 50px) !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ z-index: 1;
+ display: flex !important;
+ flex-direction: column !important;
+ }
- /* Kill the layout-legacy pagewrapper min-height:100vh that causes body overflow */
- body:has(.messages-convo-page) .pagewrapper {
- min-height: 0 !important;
- overflow: hidden;
- }
+ /* Kill the layout-legacy pagewrapper min-height:100vh that causes body overflow */
+ body:has(.messages-convo-page) .pagewrapper {
+ min-height: 0 !important;
+ overflow: hidden;
+ }
}
/* ── MOBILE (≤ 768px): position:fixed pagewrapper ─────────
Works on mobile; locks the pagewrapper to the viewport. */
@media (max-width: 768px) {
- html:has(.messages-convo-page),
- body:has(.messages-convo-page) {
- height: 100%;
- overflow: hidden;
- }
- body:has(.messages-convo-page) .pagewrapper {
- position: fixed;
- top: var(--navbar-h, 50px);
- left: 0;
- right: 0;
- bottom: 0;
- overflow: hidden;
- padding: 0;
- display: flex;
- flex-direction: column;
- }
+ html:has(.messages-convo-page),
+ body:has(.messages-convo-page) {
+ height: 100%;
+ overflow: hidden;
+ }
+
+ body:has(.messages-convo-page) .pagewrapper {
+ position: fixed;
+ top: var(--navbar-h, 50px);
+ left: 0;
+ right: 0;
+ bottom: 0;
+ overflow: hidden;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ }
}
/* ── Shared: convo pane is a flex column ──────────────────
Both viewports share this — desktop gets explicit height
above, mobile gets height from fixed pagewrapper. */
.messages-convo-page {
- overflow: hidden;
- padding: 0;
- max-width: 720px;
- width: 100%;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- /* flex:1 + min-height:0 for mobile where parent is fixed pagewrapper */
- flex: 1;
- min-height: 0;
+ overflow: hidden;
+ padding: 0;
+ max-width: 720px;
+ width: 100%;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ /* flex:1 + min-height:0 for mobile where parent is fixed pagewrapper */
+ flex: 1;
+ min-height: 0;
}
/* Header: never shrinks */
.messages-convo-page .messages-header {
- flex-shrink: 0;
- padding: 10px 16px;
- margin-bottom: 0;
- border-bottom: 1px solid var(--border, #333);
+ flex-shrink: 0;
+ padding: 10px 16px;
+ margin-bottom: 0;
+ border-bottom: 1px solid var(--border, #333);
}
/* Key notice banner if visible */
.messages-convo-page #dm-key-notice {
- flex-shrink: 0;
+ flex-shrink: 0;
}
/* Thread: grows + scrolls — min-height:0 is critical */
.messages-convo-page .dm-thread {
- flex: 1;
- min-height: 0;
- max-height: none;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- padding: 12px 16px;
+ flex: 1;
+ min-height: 0;
+ max-height: none;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding: 12px 16px;
}
/* Input bar: never shrinks */
.messages-convo-page .dm-send-form {
- flex-shrink: 0;
- margin-top: 0;
- padding: 10px 16px;
- padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
- border-top: 1px solid #333;
+ flex-shrink: 0;
+ margin-top: 0;
+ padding: 10px 16px;
+ padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
+ border-top: 1px solid #333;
}
.notif-count-dm {
- position: absolute;
- top: -7px;
- right: -5px;
- background: var(--badge-nsfw);
- color: white;
- font-size: 10px;
- padding: 2px 2px;
- border-radius: 3px;
- line-height: 1;
+ position: absolute;
+ top: -7px;
+ right: -5px;
+ background: var(--badge-nsfw);
+ color: white;
+ font-size: 10px;
+ padding: 2px 2px;
+ border-radius: 3px;
+ line-height: 1;
}
.dm-bubble .emoji {
@@ -10258,218 +11093,218 @@ body.layout-modern .tag-controls {
/* ── 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;
+ 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);
+ border-color: var(--accent);
+ background: rgba(0, 0, 0, 0.4);
}
/* Loading state */
span.dm-post-card--loading {
- opacity: 0.55;
+ opacity: 0.55;
}
.dm-post-card__thumb-wrap {
- position: relative;
- flex-shrink: 0;
- width: 72px;
- height: 72px;
- background: #111;
- overflow: hidden;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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);
+ 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);
+ 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;
+ 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);
+ border-color: var(--accent);
+ background: rgba(0, 0, 0, 0.45);
}
span.gchat-post-card--loading {
- opacity: 0.55;
+ opacity: 0.55;
}
.gchat-post-card__thumb-wrap {
- position: relative;
- flex-shrink: 0;
- width: 60px;
- height: 60px;
- background: #111;
- overflow: hidden;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ font-size: 0.85em;
+ opacity: 0.7;
+ flex-shrink: 0;
}
/* Köpfe — random corner image */
@@ -10486,6 +11321,7 @@ span.gchat-post-card--loading {
will-change: opacity;
pointer-events: none;
}
+
#koepfe-img.visible {
opacity: 1;
}
@@ -10496,16 +11332,18 @@ span.gchat-post-card--loading {
margin: 20px;
grid-template-rows: 1fr;
grid-template-columns: auto 1fr;
+ gap: 5px;
}
#nav_excluded_tags_list {
- margin-bottom: 15px;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- justify-content: center;
- max-width: 100%;
+ margin-bottom: 15px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: center;
+ max-width: 100%;
}
+
.nav-exclude {
width: 100%;
max-width: 600px;
@@ -10515,14 +11353,24 @@ span.gchat-post-card--loading {
#nav_exclude_tag_input {
width: 100%;
- background: rgba(255,255,255,0.05);
+ background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--nav-border-color);
color: var(--white);
padding: 10px;
}
#nav_exclude_suggestions {
- display: none; position: absolute; bottom: 100%; left: 0; right: 0; background: #1e1e1e; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 -4px 12px rgba(0,0,0,0.5); max-height: 200px; overflow-y: auto; z-index: 1000;
+ display: none;
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ background: #1e1e1e;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.5);
+ max-height: 200px;
+ overflow-y: auto;
+ z-index: 1000;
}
.nav-mime-filter {
@@ -10531,119 +11379,130 @@ span.gchat-post-card--loading {
grid-template-rows: 1fr;
grid-template-columns: 1fr 1fr 1fr;
}
+
/* ── Meme Pages (Critical) ────────────────────────────── */
-.meme-select-container, .meme-creator-container {
- width: 100%;
- max-width: 1200px;
- margin: 0 auto;
- padding: 5vh 20px 20px; /* matched to .container standards */
- position: relative;
- z-index: 10;
+.meme-select-container,
+.meme-creator-container {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 5vh 20px 20px;
+ /* matched to .container standards */
+ position: relative;
+ z-index: 10;
}
.meme-header {
- margin-bottom: 25px;
- border-bottom: 1px solid var(--accent, #9f0);
- padding-bottom: 15px;
+ margin-bottom: 25px;
+ border-bottom: 1px solid var(--accent, #9f0);
+ padding-bottom: 15px;
}
.meme-title {
- font-family: var(--nav-brand-font, 'VCR'), monospace;
- color: var(--accent, #9f0);
- text-transform: uppercase;
- margin: 0;
+ font-family: var(--nav-brand-font, 'VCR'), monospace;
+ color: var(--accent, #9f0);
+ text-transform: uppercase;
+ margin: 0;
}
.meme-subtitle {
- font-family: var(--font, monospace);
- color: #888;
- margin: 5px 0 20px 0;
+ font-family: var(--font, monospace);
+ color: #888;
+ margin: 5px 0 20px 0;
}
.category-filter-bar {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- margin-bottom: 30px;
- border-bottom: 1px solid rgba(255,255,255,0.05);
- padding-bottom: 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 30px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ padding-bottom: 20px;
}
.category-chip {
- background: var(--nav-bg, #2b2b2b);
- border: 1px solid var(--nav-border-color, rgba(255, 255, 255, .05));
- color: var(--white, #fff);
- padding: 6px 14px;
- border-radius: 20px;
- font-family: var(--font, monospace);
- font-size: 0.85em;
- cursor: pointer;
+ background: var(--nav-bg, #2b2b2b);
+ border: 1px solid var(--nav-border-color, rgba(255, 255, 255, .05));
+ color: var(--white, #fff);
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-family: var(--font, monospace);
+ font-size: 0.85em;
+ cursor: pointer;
}
.category-chip.active {
- background: var(--accent, #9f0);
- color: var(--black, #000);
- border-color: var(--accent, #9f0);
+ background: var(--accent, #9f0);
+ color: var(--black, #000);
+ border-color: var(--accent, #9f0);
}
.template-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
- gap: 15px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 15px;
}
.anim {
- margin: 0;
- animation: color 0.6s linear infinite !important;
+ margin: 0;
+ animation: color 0.6s linear infinite !important;
}
@keyframes color {
- 0% {
- color: red;
- }
- 25% {
- color: yellow;
- }
- 50% {
- color: #1FB2B0;
- }
- 75% {
- color: #23dd06;
- }
- 100% {
- color: red;
- }
+ 0% {
+ color: red;
+ }
+
+ 25% {
+ color: yellow;
+ }
+
+ 50% {
+ color: #1FB2B0;
+ }
+
+ 75% {
+ color: #23dd06;
+ }
+
+ 100% {
+ color: red;
+ }
}
.anim-boxshadow {
- margin: 0;
- animation: boxshadow 0.6s linear infinite !important;
+ margin: 0;
+ animation: boxshadow 0.6s linear infinite !important;
}
@keyframes boxshadow {
- 0% {
- box-shadow: 0 0 0 2px red;
- }
- 25% {
- box-shadow: 0 0 0 2px yellow;
- }
- 50% {
- box-shadow: 0 0 0 2px #1FB2B0;
- }
- 75% {
- box-shadow: 0 0 0 2px #23dd06;
- }
- 100% {
- box-shadow: 0 0 0 2px red;
- }
+ 0% {
+ box-shadow: 0 0 0 2px red;
+ }
+
+ 25% {
+ box-shadow: 0 0 0 2px yellow;
+ }
+
+ 50% {
+ box-shadow: 0 0 0 2px #1FB2B0;
+ }
+
+ 75% {
+ box-shadow: 0 0 0 2px #23dd06;
+ }
+
+ 100% {
+ box-shadow: 0 0 0 2px red;
+ }
}
body.layout-modern .gapRight {
- justify-content: center;
+ justify-content: center;
}
-body.layout-modern .gapRight > svg.iconset,
-body.layout-modern .gapRight > i.iconset {
+body.layout-modern .gapRight>svg.iconset,
+body.layout-modern .gapRight>i.iconset {
height: 30px;
width: 30px;
margin: 5px;
@@ -10658,11 +11517,11 @@ body.layout-modern .gapRight > i.iconset {
}
body.layout-legacy .gapRight {
- justify-content: center;
+ justify-content: center;
}
-body.layout-legacy .gapRight > svg.iconset,
-body.layout-legacy .gapRight > i.iconset {
+body.layout-legacy .gapRight>svg.iconset,
+body.layout-legacy .gapRight>i.iconset {
height: 30px;
width: 30px;
margin: 5px;
@@ -10676,9 +11535,10 @@ body.layout-legacy .gapRight > i.iconset {
box-sizing: border-box;
}
-body.layout-legacy .gapRight > .iconset#a_favo {
+body.layout-legacy .gapRight>.iconset#a_favo {
fill: #ff009b;
}
+
/* OC Toggle Icon - now i element, color inherited from .gapRight i.iconset */
i.iconset#a_oc {
color: #fff !important;
@@ -10707,8 +11567,9 @@ i.iconset#a_oc {
display: flex;
align-items: center;
gap: 8px;
- cursor: pointer;
user-select: none;
+ justify-content: center;
+ margin-bottom: 5px;
}
.oc-option input[type="checkbox"] {
@@ -10735,7 +11596,7 @@ i.iconset#a_oc {
color: var(--white);
}
-.responsive-table th,
+.responsive-table th,
.responsive-table td {
padding: 12px;
text-align: left;
@@ -10743,11 +11604,12 @@ i.iconset#a_oc {
}
@media (max-width: 768px) {
- .responsive-table,
- .responsive-table thead,
- .responsive-table tbody,
- .responsive-table th,
- .responsive-table td,
+
+ .responsive-table,
+ .responsive-table thead,
+ .responsive-table tbody,
+ .responsive-table th,
+ .responsive-table td,
.responsive-table tr {
display: block;
}
@@ -10765,7 +11627,7 @@ i.iconset#a_oc {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 0;
- box-shadow: 0 8px 32px rgba(0,0,0,0.3);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
overflow: hidden;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
@@ -10774,12 +11636,14 @@ i.iconset#a_oc {
.responsive-table td {
border: none;
position: relative;
- padding: 12px 15px 12px 42% !important; /* Slightly reduced labels width for more content space */
+ padding: 12px 15px 12px 42% !important;
+ /* Slightly reduced labels width for more content space */
min-height: 48px;
display: flex;
align-items: center;
justify-content: flex-start;
- flex-wrap: nowrap; /* Prevent wrapping inside standard cells */
+ flex-wrap: nowrap;
+ /* Prevent wrapping inside standard cells */
gap: 12px;
text-align: left !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
@@ -10790,16 +11654,19 @@ i.iconset#a_oc {
.responsive-table td:first-child {
background: rgba(255, 255, 255, 0.06);
padding: 20px 20px !important;
- padding-left: 20px !important; /* Explicit reset to fix "cramped to right" bug */
+ padding-left: 20px !important;
+ /* Explicit reset to fix "cramped to right" bug */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 5px;
- display: block; /* Ensure it takes full width for the header style */
+ display: block;
+ /* Ensure it takes full width for the header style */
width: 100%;
box-sizing: border-box;
}
-
+
.responsive-table td:first-child::before {
- display: none !important; /* Force hide label */
+ display: none !important;
+ /* Force hide label */
}
.user-info-cell {
@@ -10861,100 +11728,107 @@ i.iconset#a_oc {
}
.admin-header-flex {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 35px;
- flex-wrap: wrap;
- gap: 15px;
- padding: 0 10px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 35px;
+ flex-wrap: wrap;
+ gap: 15px;
+ padding: 0 10px;
}
@media (max-width: 600px) {
- .admin-header-flex {
- flex-direction: column;
- align-items: center;
- text-align: center;
- }
- .admin-header-flex h2 {
- margin-bottom: 8px !important;
- font-size: 1.8em;
- }
- .admin-header-flex button {
- width: 100%;
- max-width: 300px;
- }
+ .admin-header-flex {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ .admin-header-flex h2 {
+ margin-bottom: 8px !important;
+ font-size: 1.8em;
+ }
+
+ .admin-header-flex button {
+ width: 100%;
+ max-width: 300px;
+ }
}
/* profile stuff */
.profile_head {
- display: grid;
- grid-template-columns: auto 1fr;
- background: var(--nav-bg);
+ display: grid;
+ grid-template-columns: auto 1fr;
+ background: var(--nav-bg);
}
.profile_head_avatar {
- display: grid;
- align-items: normal;
- padding: 5px;
+ display: grid;
+ align-items: normal;
+ padding: 5px;
}
.layersoffear {
- display: grid;
- grid-template-columns: auto;
+ display: grid;
+ grid-template-columns: auto;
}
.profile_head_user_stats {
- display: grid;
- grid-auto-flow: column;
- grid-auto-columns: max-content;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: max-content;
}
-.stat-id, .stat-joined, .stat-comments, .stat-tags, .stat-halls {
- padding: 5px;
+.stat-id,
+.stat-joined,
+.stat-comments,
+.stat-tags,
+.stat-halls {
+ padding: 5px;
}
-.uploads-header, .favs-header {
- background: var(--black);
- padding: 5px;
+.uploads-header,
+.favs-header {
+ background: var(--black);
+ padding: 5px;
}
@media (max-width: 700px) {
- .profile_head_user_stats {
- grid-auto-flow: row;
- grid-auto-columns: 1fr !important;
- grid-template-columns: 1fr 1fr;
- }
-
- .user_content_wrapper {
- grid-template-columns: auto;
- grid-auto-flow: column;
- grid-template-rows: auto auto;
- }
+ .profile_head_user_stats {
+ grid-auto-flow: row;
+ grid-auto-columns: 1fr !important;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .user_content_wrapper {
+ grid-template-columns: auto;
+ grid-auto-flow: column;
+ grid-template-rows: auto auto;
+ }
}
.setting-item {
- display: grid;
+ display: grid;
}
textarea#profile_description {
- height: 7em;
- background: var(--nav-bg);
- color: var(--white);
+ height: 7em;
+ background: var(--nav-bg);
+ color: var(--white);
}
.profile-settings-actions {
- padding-top: 10px;
+ padding-top: 10px;
}
.profile_description {
- font-size: 0.8em;
- color: var(--white);
- max-width: 500px;
- word-wrap: break-word;
+ font-size: 0.8em;
+ color: var(--white);
+ max-width: 500px;
+ word-wrap: break-word;
}
/* =============================================
@@ -10963,13 +11837,13 @@ textarea#profile_description {
/* When user infobox is active, expand .blahlol to full metadata width */
.blahlol:has(.user-infobox-block) {
- grid-column: 1 / -1;
+ grid-column: 1 / -1;
}
.user-infobox-block {
display: flex;
gap: 10px;
- background: rgba(0,0,0,0.2);
+ background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--author-border, rgba(0, 255, 0, 0.2));
line-height: 1;
padding: 5px;
@@ -10982,7 +11856,7 @@ textarea#profile_description {
.user-infobox-avatar {
- flex-shrink: 0;
+ flex-shrink: 0;
}
.user-infobox-avatar img {
@@ -10992,39 +11866,39 @@ textarea#profile_description {
}
.user-infobox-info {
- flex-grow: 1;
- display: flex;
- flex-direction: column;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
}
.user-infobox-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 6px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
}
.user-infobox-username {
- font-weight: 700;
- color: var(--author-accent, inherit) !important;
- text-decoration: none;
+ font-weight: 700;
+ color: var(--author-accent, inherit) !important;
+ text-decoration: none;
}
.user-infobox-timestamp {
- font-size: 0.8em;
- color: #888;
- letter-spacing: 0.5px;
+ font-size: 0.8em;
+ color: #888;
+ letter-spacing: 0.5px;
}
.user-infobox-description {
- font-size: 0.9em;
- line-height: 1.5;
- color: #ccc;
- font-style: italic;
- text-align: left;
- padding-top: 4px;
- border-top: 1px solid rgba(255, 255, 255, 0.05);
+ font-size: 0.9em;
+ line-height: 1.5;
+ color: #ccc;
+ font-style: italic;
+ text-align: left;
+ padding-top: 4px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
}
/* =============================================
@@ -11032,155 +11906,158 @@ textarea#profile_description {
============================================= */
.xd-score-badge {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 3px 8px;
- border-radius: 4px;
- font-family: var(--font, monospace);
- font-size: 0.82em;
- font-weight: bold;
- letter-spacing: 0.5px;
- cursor: default;
- user-select: none;
- border: 1px solid transparent;
- transition: filter 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-family: var(--font, monospace);
+ font-size: 0.82em;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+ cursor: default;
+ user-select: none;
+ border: 1px solid transparent;
+ transition: filter 0.2s ease;
}
.xd-score-badge:hover {
- filter: brightness(1.15);
+ filter: brightness(1.15);
}
/* xD num */
.xd-score-num {
- font-size: 0.85em;
- opacity: 0.75;
- font-weight: normal;
+ font-size: 0.85em;
+ opacity: 0.75;
+ font-weight: normal;
}
/* Tier 1: 1–4 pts — muted lime */
.xd-tier-1 {
- background: #1a2e10;
- color: #7ec850;
- border-color: #3a5a20;
+ background: #1a2e10;
+ color: #7ec850;
+ border-color: #3a5a20;
}
/* Tier 2: 5–14 pts — yellow-green */
.xd-tier-2 {
- background: #2a2d00;
- color: #c8d830;
- border-color: #5a6400;
+ background: #2a2d00;
+ color: #c8d830;
+ border-color: #5a6400;
}
/* Tier 3: 15–29 pts — orange */
.xd-tier-3 {
- background: #2d1800;
- color: #e08030;
- border-color: #6a3800;
+ background: #2d1800;
+ color: #e08030;
+ border-color: #6a3800;
}
/* Tier 4: 30–59 pts — red-orange */
.xd-tier-4 {
- background: #2d0a00;
- color: #e84020;
- border-color: #7a2010;
+ background: #2d0a00;
+ color: #e84020;
+ border-color: #7a2010;
}
/* Tier 5: 60+ pts — neon, pulsing glow */
.xd-tier-5 {
- background: #1a0000;
- color: #ff5500;
- border-color: #ff3300;
- box-shadow: 0 0 6px rgba(255, 80, 0, 0.5);
- animation: xd-pulse 1.8s ease-in-out infinite;
+ background: #1a0000;
+ color: #ff5500;
+ border-color: #ff3300;
+ box-shadow: 0 0 6px rgba(255, 80, 0, 0.5);
+ animation: xd-pulse 1.8s ease-in-out infinite;
}
/* ─── xD score filter in filter modal ─────────────────────────────── */
.nav-xd-filter {
- margin-top: 14px;
- padding-top: 12px;
- border-top: 1px solid rgba(255, 255, 255, 0.07);
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.nav-xd-label {
- display: block;
- font-size: 0.8em;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- color: #888;
- margin-bottom: 6px;
+ display: block;
+ font-size: 0.8em;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #888;
+ margin-bottom: 6px;
}
.nav-xd-controls {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
}
+
@keyframes xd-pulse {
- 0%,100% {
- box-shadow: 0 0 5px rgba(255, 80, 0, 0.4), 0 0 12px rgba(255, 50, 0, 0.2);
- }
- 50% {
- box-shadow: 0 0 10px rgba(255, 100, 0, 0.8), 0 0 22px rgba(255, 60, 0, 0.4);
- }
+
+ 0%,
+ 100% {
+ box-shadow: 0 0 5px rgba(255, 80, 0, 0.4), 0 0 12px rgba(255, 50, 0, 0.2);
+ }
+
+ 50% {
+ box-shadow: 0 0 10px rgba(255, 100, 0, 0.8), 0 0 22px rgba(255, 60, 0, 0.4);
+ }
}
/* ─── xD Score Slider ──────────────────────────────────────────────── */
.xd-slider {
- -webkit-appearance: none;
- appearance: none;
- flex: 1;
- height: 6px;
- border-radius: 3px;
- background: linear-gradient(to right,
- var(--xd-fill, #4caf50) 0%,
- var(--xd-fill, #4caf50) var(--xd-pct, 0%),
- rgba(255,255,255,0.12) var(--xd-pct, 0%)
- );
- outline: none;
- cursor: pointer;
- min-width: 120px;
+ -webkit-appearance: none;
+ appearance: none;
+ flex: 1;
+ height: 6px;
+ border-radius: 3px;
+ background: linear-gradient(to right,
+ var(--xd-fill, #4caf50) 0%,
+ var(--xd-fill, #4caf50) var(--xd-pct, 0%),
+ rgba(255, 255, 255, 0.12) var(--xd-pct, 0%));
+ outline: none;
+ cursor: pointer;
+ min-width: 120px;
}
.xd-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- appearance: none;
- width: 18px;
- height: 18px;
- border-radius: 50%;
- background: #fff;
- border: 2px solid var(--xd-fill, #4caf50);
- box-shadow: 0 0 4px rgba(0,0,0,0.4);
- cursor: pointer;
- transition: border-color 0.2s, transform 0.1s;
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid var(--xd-fill, #4caf50);
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
+ cursor: pointer;
+ transition: border-color 0.2s, transform 0.1s;
}
.xd-slider::-webkit-slider-thumb:hover {
- transform: scale(1.2);
+ transform: scale(1.2);
}
.xd-slider::-moz-range-thumb {
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: #fff;
- border: 2px solid var(--xd-fill, #4caf50);
- cursor: pointer;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid var(--xd-fill, #4caf50);
+ cursor: pointer;
}
.xd-slider-val {
- display: inline-block;
- min-width: 28px;
- text-align: center;
- font-family: monospace;
- font-size: 0.9em;
- font-weight: bold;
- color: var(--xd-fill, #4caf50);
- padding: 1px 5px;
- border: 1px solid rgba(255,255,255,0.15);
- border-radius: 4px;
- background: rgba(0,0,0,0.2);
+ display: inline-block;
+ min-width: 28px;
+ text-align: center;
+ font-family: monospace;
+ font-size: 0.9em;
+ font-weight: bold;
+ color: var(--xd-fill, #4caf50);
+ padding: 1px 5px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.2);
}
/* ─── xD score wrapper ─────────────────────────────────────────────── */
@@ -11204,23 +12081,44 @@ textarea#profile_description {
letter-spacing: 0.03em;
line-height: 1.4;
pointer-events: none;
- background: rgba(0,0,0,0.6);
+ background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
color: #fff;
- border: 1px solid rgba(255,255,255,0.2);
- z-index: 12;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ z-index: 10;
+}
+
+.thumb-xd-indicator.xd-tier-1 {
+ color: #a8e6a3;
+ border-color: #a8e6a3;
+}
+
+.thumb-xd-indicator.xd-tier-2 {
+ color: #ffe066;
+ border-color: #ffe066;
+}
+
+.thumb-xd-indicator.xd-tier-3 {
+ color: #ffa94d;
+ border-color: #ffa94d;
+}
+
+.thumb-xd-indicator.xd-tier-4 {
+ color: #ff6b6b;
+ border-color: #ff6b6b;
+}
+
+.thumb-xd-indicator.xd-tier-5 {
+ color: #ff3cac;
+ border-color: #ff3cac;
}
-.thumb-xd-indicator.xd-tier-1 { color: #a8e6a3; border-color: #a8e6a3; }
-.thumb-xd-indicator.xd-tier-2 { color: #ffe066; border-color: #ffe066; }
-.thumb-xd-indicator.xd-tier-3 { color: #ffa94d; border-color: #ffa94d; }
-.thumb-xd-indicator.xd-tier-4 { color: #ff6b6b; border-color: #ff6b6b; }
-.thumb-xd-indicator.xd-tier-5 { color: #ff3cac; border-color: #ff3cac; }
/* Performance & Utility */
.desktop-only {
display: inline-block !important;
}
+
.mobile-only {
display: none !important;
}
@@ -11229,6 +12127,7 @@ textarea#profile_description {
.desktop-only {
display: none !important;
}
+
.mobile-only {
display: block !important;
}
@@ -11237,180 +12136,205 @@ textarea#profile_description {
#reports-table-body tr {
text-align: center;
}
+
/* Meta Suggestions */
.meta-suggestions-list {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-top: 10px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 10px;
}
.meta-suggestion {
- padding: 0.35rem 0.75rem;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 50px;
- font-size: 0.85rem;
- cursor: pointer;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- color: rgba(255, 255, 255, 0.8);
- user-select: text;
- max-width: 100%;
- min-width: 0;
+ padding: 0.35rem 0.75rem;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 50px;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: rgba(255, 255, 255, 0.8);
+ user-select: text;
+ max-width: 100%;
+ min-width: 0;
}
.meta-suggestion span {
- overflow-wrap: break-word;
- word-break: break-word;
- white-space: normal;
- min-width: 0;
- flex: 1;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ white-space: normal;
+ min-width: 0;
+ flex: 1;
}
.meta-suggestion:hover {
- background: var(--bg);
- border-color: var(--accent);
- color: #000 !important;
- transform: translateY(-1px);
+ background: var(--bg);
+ border-color: var(--accent);
+ transform: translateY(-1px);
}
.meta-suggestion i {
- font-size: 0.7rem;
- opacity: 0.6;
+ font-size: 0.7rem;
+ opacity: 0.6;
}
.meta-suggestion.selected {
- opacity: 0.4;
- cursor: default;
- pointer-events: none;
- background: rgba(255, 255, 255, 0.02);
- border-color: rgba(255, 255, 255, 0.05);
- color: rgba(255, 255, 255, 0.4) !important;
+ opacity: 0.4;
+ cursor: default;
+ pointer-events: none;
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.4) !important;
}
.meta-suggestion.selected i {
- color: var(--accent);
- opacity: 1;
+ color: var(--accent);
+ opacity: 1;
}
#metadata-modal .f0ck-modal-body {
- max-height: 400px;
- overflow-y: auto;
+ max-height: 400px;
+ overflow-y: auto;
}
/* Selection-edit popover for meta-suggestion pills */
.sel-tag-popover {
- position: fixed;
- z-index: 99999;
- display: flex;
- align-items: center;
- gap: 6px;
- background: #1e1e2e;
- border: 1px solid var(--accent, #9f0);
- border-radius: 8px;
- padding: 5px 8px;
- box-shadow: 0 4px 20px rgba(0,0,0,0.6);
- animation: sel-pop-in 0.12s ease;
+ position: fixed;
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: #1e1e2e;
+ border: 1px solid var(--accent, #9f0);
+ border-radius: 8px;
+ padding: 5px 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
+ animation: sel-pop-in 0.12s ease;
}
+
@keyframes sel-pop-in {
- from { opacity: 0; transform: translateY(4px) scale(0.96); }
- to { opacity: 1; transform: translateY(0) scale(1); }
+ from {
+ opacity: 0;
+ transform: translateY(4px) scale(0.96);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
+
.sel-tag-popover input {
- background: transparent;
- border: none;
- outline: none;
- color: #fff;
- font-size: 0.85em;
- font-family: var(--font, monospace);
- min-width: 80px;
- max-width: 220px;
- width: auto;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: #fff;
+ font-size: 0.85em;
+ font-family: var(--font, monospace);
+ min-width: 80px;
+ max-width: 220px;
+ width: auto;
}
+
.sel-tag-popover-confirm {
- background: var(--accent, #9f0);
- color: #000;
- border: none;
- border-radius: 4px;
- padding: 2px 8px;
- font-size: 0.8em;
- font-weight: bold;
- cursor: pointer;
- white-space: nowrap;
+ background: var(--accent, #9f0);
+ color: #000;
+ border: none;
+ border-radius: 4px;
+ padding: 2px 8px;
+ font-size: 0.8em;
+ font-weight: bold;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.sel-tag-popover-confirm:hover {
+ filter: brightness(1.15);
}
-.sel-tag-popover-confirm:hover { filter: brightness(1.15); }
/* ── Emoji Admin Card Grid ─────────────────────────────────────────── */
.emoji-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 12px;
- padding: 4px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 12px;
+ padding: 4px;
}
+
.emoji-card {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- padding: 16px 10px 12px;
- background: var(--dropdown-bg);
- border: 1px solid var(--nav-border-color);
- border-radius: 6px;
- transition: border-color .2s, box-shadow .2s, transform .15s;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 16px 10px 12px;
+ background: var(--dropdown-bg);
+ border: 1px solid var(--nav-border-color);
+ border-radius: 6px;
+ transition: border-color .2s, box-shadow .2s, transform .15s;
}
+
.emoji-card:hover {
- border-color: var(--accent);
- box-shadow: 0 0 12px rgba(var(--accent-rgb, 100,100,255), .25);
- transform: translateY(-2px);
+ border-color: var(--accent);
+ box-shadow: 0 0 12px rgba(var(--accent-rgb, 100, 100, 255), .25);
+ transform: translateY(-2px);
}
+
.emoji-card .emoji-preview {
- height: 48px;
- max-width: 80px;
- object-fit: contain;
- image-rendering: auto;
+ height: 48px;
+ max-width: 80px;
+ object-fit: contain;
+ image-rendering: auto;
}
+
.emoji-card .emoji-label {
- font-family: monospace;
- font-size: .85em;
- color: var(--accent);
- text-align: center;
- word-break: break-all;
- line-height: 1.2;
+ font-family: monospace;
+ font-size: .85em;
+ color: var(--accent);
+ text-align: center;
+ word-break: break-all;
+ line-height: 1.2;
}
+
.emoji-card .emoji-url {
- font-size: .65em;
- opacity: .45;
- text-align: center;
- word-break: break-all;
- line-height: 1.15;
- max-height: 2.3em;
- overflow: hidden;
+ font-size: .65em;
+ opacity: .45;
+ text-align: center;
+ word-break: break-all;
+ line-height: 1.15;
+ max-height: 2.3em;
+ overflow: hidden;
}
+
.emoji-card .emoji-delete {
- position: absolute;
- top: 5px;
- right: 5px;
- width: 22px;
- height: 22px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(200, 0, 0, .85);
- color: #fff;
- border: none;
- border-radius: 50%;
- font-size: 13px;
- line-height: 1;
- cursor: pointer;
- opacity: 0;
- transition: opacity .15s;
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ width: 22px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(200, 0, 0, .85);
+ color: #fff;
+ border: none;
+ border-radius: 50%;
+ font-size: 13px;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity .15s;
+}
+
+.emoji-card:hover .emoji-delete {
+ opacity: 1;
+}
+
+.emoji-card .emoji-delete:hover {
+ background: #e00;
}
-.emoji-card:hover .emoji-delete { opacity: 1; }
-.emoji-card .emoji-delete:hover { background: #e00; }
/* ================================================================
GLOBAL CHAT WIDGET — Facebook-style, bottom-right (left of sidebar)
@@ -11455,14 +12379,16 @@ body.scroller-active #gchat-widget {
display: flex;
align-items: center;
justify-content: center;
- box-shadow: 0 2px 12px rgba(0,0,0,0.4);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
opacity: 0.75;
transition: opacity 0.15s, transform 0.15s;
}
+
#gchat-reopen-bubble:hover {
opacity: 1;
transform: scale(1.08);
}
+
.gchat-bubble-badge {
position: absolute;
top: -5px;
@@ -11480,9 +12406,11 @@ body.scroller-active #gchat-widget {
pointer-events: none;
animation: gchat-badge-pop 0.2s ease;
}
+
body.sidebar-right-hidden #gchat-reopen-bubble {
right: 18px;
}
+
body.scroller-active #gchat-reopen-bubble {
display: none !important;
}
@@ -11491,6 +12419,7 @@ body.scroller-active #gchat-reopen-bubble {
#gchat-widget {
right: calc(280px + 14px);
}
+
body.sidebar-right-hidden #gchat-widget {
right: 14px;
}
@@ -11502,10 +12431,11 @@ body.scroller-active #gchat-reopen-bubble {
display: flex;
flex-direction: column;
width: 300px;
+ height: 40dvh;
max-height: calc(100dvh - var(--navbar-h, 50px));
background: var(--bg, #1a1a1a);
- border: 1px solid var(--nav-border-color, rgba(255,255,255,0.08));
- box-shadow: 0 8px 32px rgba(0,0,0,0.65);
+ border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.08));
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65);
overflow: hidden;
}
@@ -11524,6 +12454,7 @@ body.scroller-active #gchat-reopen-bubble {
/* Minimized state — only header visible */
#gchat-panel.gchat-minimized {
+ height: auto !important;
max-height: 42px;
}
@@ -11540,7 +12471,7 @@ body.scroller-active #gchat-reopen-bubble {
padding: 0 10px;
height: 42px;
background: var(--nav-bg, #111);
- border-bottom: 1px solid var(--nav-border-color, rgba(255,255,255,0.08));
+ border-bottom: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.08));
cursor: default;
user-select: none;
flex-shrink: 0;
@@ -11581,15 +12512,25 @@ body.scroller-active #gchat-reopen-bubble {
}
@keyframes gchat-badge-pop {
- 0% { transform: scale(0.6); opacity: 0; }
- 60% { transform: scale(1.2); }
- 100% { transform: scale(1); opacity: 1; }
+ 0% {
+ transform: scale(0.6);
+ opacity: 0;
+ }
+
+ 60% {
+ transform: scale(1.2);
+ }
+
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
}
.gchat-icon-btn {
background: none;
border: none;
- color: rgba(255,255,255,0.5);
+ color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
@@ -11600,7 +12541,7 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-icon-btn:hover {
color: #fff;
- background: rgba(255,255,255,0.08);
+ background: rgba(255, 255, 255, 0.08);
}
/* Messages area */
@@ -11609,9 +12550,10 @@ body.scroller-active #gchat-reopen-bubble {
#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);
+ 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;
@@ -11619,14 +12561,16 @@ body.scroller-active #gchat-reopen-bubble {
padding: 4px 10px;
gap: 8px;
}
+
.gchat-online-count {
font-size: 0.68em;
- color: rgba(255,255,255,0.45);
+ 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;
@@ -11636,11 +12580,14 @@ body.scroller-active #gchat-reopen-bubble {
background: #4caf50;
flex-shrink: 0;
}
+
.gchat-online-avatars {
display: flex;
align-items: center;
- flex-direction: row-reverse; /* stack right-to-left */
+ flex-direction: row-reverse;
+ /* stack right-to-left */
}
+
.gchat-online-avatar {
width: 20px;
height: 20px;
@@ -11651,16 +12598,19 @@ body.scroller-active #gchat-reopen-bubble {
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);
+ color: rgba(255, 255, 255, 0.5);
+ background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 1px 5px;
margin-left: -2px;
@@ -11673,7 +12623,7 @@ body.scroller-active #gchat-reopen-bubble {
font-weight: 600;
color: var(--bg, #111);
background: var(--accent, #f2ef0b);
- border-bottom: 1px solid rgba(0,0,0,0.15);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.15);
word-break: break-word;
white-space: pre-wrap;
flex-shrink: 0;
@@ -11724,7 +12674,7 @@ body.scroller-active #gchat-reopen-bubble {
height: 26px;
border-radius: 50%;
object-fit: cover;
- border: 1px solid rgba(255,255,255,0.1);
+ border: 1px solid rgba(255, 255, 255, 0.1);
}
.gchat-bubble-wrap {
@@ -11751,7 +12701,7 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-bubble {
background: rgba(0, 0, 0, 0.98);
- border: 1px solid rgba(255,255,255,0.06);
+ border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px 14px 14px 3px;
padding: 6px 10px;
font-size: 0.82em;
@@ -11764,7 +12714,7 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-msg-self .gchat-bubble {
background: rgba(0, 0, 0, 0.98);
- border-color: rgba(var(--accent-rgb, 242,239,11), 0.25);
+ border-color: rgba(var(--accent-rgb, 242, 239, 11), 0.25);
border-radius: 14px 14px 3px 14px;
color: #fff;
}
@@ -11800,13 +12750,16 @@ body.scroller-active #gchat-reopen-bubble {
text-decoration: none;
font-weight: bold;
}
-.gchat-bubble .mention:hover { text-decoration: underline; }
+
+.gchat-bubble .mention:hover {
+ text-decoration: underline;
+}
/* Input area — stacked: toolbar on top, input row below */
#gchat-input-area {
display: flex;
flex-direction: column;
- border-top: 1px solid var(--nav-border-color, rgba(255,255,255,0.07));
+ border-top: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.07));
background: var(--nav-bg, #0d0d0d);
flex-shrink: 0;
position: relative;
@@ -11823,7 +12776,7 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-tool-btn {
background: none;
border: none;
- color: rgba(255,255,255,0.45);
+ color: rgba(255, 255, 255, 0.45);
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
@@ -11835,7 +12788,7 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-tool-btn:hover {
color: #fff;
- background: rgba(255,255,255,0.08);
+ background: rgba(255, 255, 255, 0.08);
}
/* Input + send row */
@@ -11848,8 +12801,8 @@ body.scroller-active #gchat-reopen-bubble {
#gchat-input {
flex: 1;
- background: rgba(255,255,255,0.06);
- border: 1px solid rgba(255,255,255,0.1);
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--white, #fff);
font-family: var(--font, monospace);
font-size: 0.82em;
@@ -11887,7 +12840,7 @@ body.scroller-active #gchat-reopen-bubble {
#gchat-send-btn:hover {
transform: scale(1.1);
- box-shadow: 0 2px 10px rgba(0,0,0,0.4);
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
}
#gchat-send-btn:active {
@@ -11903,7 +12856,7 @@ body.scroller-active #gchat-reopen-bubble {
overflow-y: auto;
max-height: 140px;
background: var(--nav-bg, #111);
- border-top: 1px solid var(--nav-border-color, rgba(255,255,255,0.08));
+ border-top: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.08));
scrollbar-width: thin;
}
@@ -11918,7 +12871,7 @@ body.scroller-active #gchat-reopen-bubble {
}
#gchat-emoji-picker img:hover {
- background: rgba(255,255,255,0.12);
+ background: rgba(255, 255, 255, 0.12);
}
/* :emoji inline autocomplete — portaled to body */
@@ -11926,9 +12879,9 @@ body.scroller-active #gchat-reopen-bubble {
position: fixed;
z-index: 2000;
background: var(--bg, #1a1a1a);
- border: 1px solid var(--nav-border-color, rgba(255,255,255,0.12));
+ border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.12));
border-radius: 8px;
- box-shadow: 0 4px 20px rgba(0,0,0,0.6);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow-y: auto;
@@ -11943,13 +12896,13 @@ body.scroller-active #gchat-reopen-bubble {
padding: 5px 10px;
cursor: pointer;
font-size: 0.82em;
- color: rgba(255,255,255,0.8);
+ color: rgba(255, 255, 255, 0.8);
transition: background 0.1s;
}
.gchat-emoji-ac-item:hover,
.gchat-emoji-ac-item.active {
- background: rgba(255,255,255,0.08);
+ background: rgba(255, 255, 255, 0.08);
color: #fff;
}
@@ -11964,6 +12917,7 @@ body.scroller-active #gchat-reopen-bubble {
#gchat-widget {
right: calc(280px + 14px);
}
+
body.sidebar-right-hidden #gchat-widget {
right: 14px;
}
@@ -11974,9 +12928,9 @@ body.scroller-active #gchat-reopen-bubble {
position: fixed;
z-index: 2000;
background: var(--bg, #1a1a1a);
- border: 1px solid var(--nav-border-color, rgba(255,255,255,0.12));
+ border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.12));
border-radius: 8px;
- box-shadow: 0 4px 20px rgba(0,0,0,0.6);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow-y: auto;
@@ -11991,13 +12945,13 @@ body.scroller-active #gchat-reopen-bubble {
padding: 5px 10px;
cursor: pointer;
font-size: 0.82em;
- color: rgba(255,255,255,0.8);
+ color: rgba(255, 255, 255, 0.8);
transition: background 0.1s;
}
.gchat-mention-item:hover,
.gchat-mention-item.active {
- background: rgba(255,255,255,0.08);
+ background: rgba(255, 255, 255, 0.08);
color: #fff;
}
@@ -12090,21 +13044,21 @@ body.scroller-active #gchat-reopen-bubble {
}
.gchat-reply-btn {
- color: rgba(255,255,255,0.4);
+ color: rgba(255, 255, 255, 0.4);
}
.gchat-reply-btn:hover {
color: var(--accent, #f2ef0b);
- background: rgba(255,255,255,0.06);
+ background: rgba(255, 255, 255, 0.06);
}
.gchat-del-btn {
- color: rgba(255,100,100,0.5);
+ color: rgba(255, 100, 100, 0.5);
}
.gchat-del-btn:hover {
color: #ff5555;
- background: rgba(255,80,80,0.1);
+ background: rgba(255, 80, 80, 0.1);
}
/* ── Chat embedded media ─────────────────────────────────────────────────── */
@@ -12154,7 +13108,8 @@ body.scroller-active #gchat-reopen-bubble {
.gchat-embed-yt iframe {
position: absolute;
- top: 0; left: 0;
+ top: 0;
+ left: 0;
width: 100%;
height: 100%;
border: none;
@@ -12168,16 +13123,18 @@ body.scroller-active #gchat-reopen-bubble {
margin-top: 6px;
border-radius: 8px;
overflow: hidden;
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.08);
+ 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;
max-width: 320px;
}
+
.gchat-yt-card:hover {
- background: rgba(255,255,255,0.1);
+ background: rgba(255, 255, 255, 0.1);
}
+
.gchat-yt-thumb-wrap {
position: relative;
flex-shrink: 0;
@@ -12185,27 +13142,31 @@ body.scroller-active #gchat-reopen-bubble {
height: 62px;
overflow: hidden;
}
+
.gchat-yt-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
+
.gchat-yt-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
- background: rgba(0,0,0,0.35);
+ background: rgba(0, 0, 0, 0.35);
font-size: 1.6rem;
color: #ff0000;
pointer-events: none;
transition: background 0.15s;
}
+
.gchat-yt-card:hover .gchat-yt-play {
- background: rgba(0,0,0,0.55);
+ background: rgba(0, 0, 0, 0.55);
}
+
.gchat-yt-info {
flex: 1;
min-width: 0;
@@ -12214,6 +13175,7 @@ body.scroller-active #gchat-reopen-bubble {
flex-direction: column;
gap: 2px;
}
+
.gchat-yt-title {
font-size: 0.78rem;
font-weight: 600;
@@ -12225,6 +13187,7 @@ body.scroller-active #gchat-reopen-bubble {
-webkit-box-orient: vertical;
overflow: hidden;
}
+
.gchat-yt-author {
font-size: 0.7rem;
opacity: 0.55;
@@ -12239,19 +13202,25 @@ body.scroller-active #gchat-reopen-bubble {
position: fixed;
inset: 0;
z-index: 99999;
- background: rgba(0,0,0,0.85);
+ background: rgba(0, 0, 0, 0.85);
align-items: center;
justify-content: center;
cursor: zoom-out;
animation: gchat-modal-fade 0.18s ease;
}
+
#gchat-img-modal.gchat-img-modal-open {
display: flex;
}
@keyframes gchat-modal-fade {
- from { opacity: 0; }
- to { opacity: 1; }
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
}
#gchat-img-modal-inner {
@@ -12268,7 +13237,7 @@ body.scroller-active #gchat-reopen-bubble {
max-height: 92vh;
object-fit: contain;
border-radius: 4px;
- box-shadow: 0 8px 48px rgba(0,0,0,0.9);
+ box-shadow: 0 8px 48px rgba(0, 0, 0, 0.9);
cursor: default;
}
@@ -12281,38 +13250,42 @@ body.scroller-active #gchat-reopen-bubble {
margin-top: 6px;
border-radius: 8px;
overflow: hidden;
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.08);
+ 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));
+ 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);
+ 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);
+ background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
padding: 2px 5px;
font-size: 0.72rem;
@@ -12320,6 +13293,7 @@ body.scroller-active #gchat-reopen-bubble {
line-height: 1;
pointer-events: none;
}
+
.gchat-item-card-info {
flex: 1;
min-width: 0;
@@ -12328,12 +13302,14 @@ body.scroller-active #gchat-reopen-bubble {
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;
@@ -12350,38 +13326,42 @@ body.scroller-active #gchat-reopen-bubble {
margin-top: 6px;
border-radius: 8px;
overflow: hidden;
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.08);
+ 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));
+ 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);
+ 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);
+ background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
padding: 2px 5px;
font-size: 0.72rem;
@@ -12389,6 +13369,7 @@ body.scroller-active #gchat-reopen-bubble {
line-height: 1;
pointer-events: none;
}
+
.gchat-item-card-info {
flex: 1;
min-width: 0;
@@ -12397,12 +13378,14 @@ body.scroller-active #gchat-reopen-bubble {
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;
@@ -12438,7 +13421,8 @@ body.scroller-active #gchat-reopen-bubble {
}
.settings fieldset {
- min-width: 0; /* prevent fieldsets from blowing out grid */
+ min-width: 0;
+ /* prevent fieldsets from blowing out grid */
max-width: 100%;
box-sizing: border-box;
}
@@ -12454,6 +13438,7 @@ body.scroller-active #gchat-reopen-bubble {
width: 100% !important;
padding: 2px 0;
}
+
.settings .account-info-table tr {
display: block;
margin-bottom: 8px;
@@ -12514,3 +13499,7 @@ body.scroller-active #gchat-reopen-bubble {
overflow-wrap: anywhere;
}
}
+
+#nav-meme-link, #nav-upload-link {
+ white-space: nowrap;
+}
\ No newline at end of file
diff --git a/public/s/css/upload.css b/public/s/css/upload.css
index 637cd20..9aeed59 100644
--- a/public/s/css/upload.css
+++ b/public/s/css/upload.css
@@ -19,17 +19,19 @@
transform: translateY(0);
}
}
-
.upload-container h2 {
margin-bottom: 0.5rem;
color: var(--accent);
text-align: center;
}
+.upload-title {
+ font-size: x-large;
+}
+
/* Upload Limit Info */
.upload-limit-info {
text-align: center;
- margin-bottom: 1.5rem;
font-size: 0.9rem;
opacity: 0.7;
}
@@ -56,7 +58,6 @@
.upload-form {
display: flex;
flex-direction: column;
- gap: 1.5rem;
background: rgba(255, 255, 255, 0.02);
padding: 1rem;
border-radius: 0;
@@ -82,7 +83,7 @@
cursor: pointer;
transition: all 0.2s;
position: relative;
- min-height: 200px;
+ min-height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
@@ -282,6 +283,7 @@
position: relative;
gap: 0.5rem;
z-index: 10000 !important;
+ margin-bottom: 5px;
}
.tags-list {
@@ -337,6 +339,7 @@
/* Upload Comment */
.upload-comment-input {
position: relative;
+ margin: 0px 0px 5px 0px;
}
.upload-comment {
@@ -734,14 +737,6 @@
color: rgba(255, 255, 255, 0.8);
}
-.meta-suggestion:hover {
- background: var(--accent);
- border-color: var(--accent);
- color: white !important;
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
-}
-
.meta-suggestion i {
font-size: 0.7rem;
opacity: 0.6;
diff --git a/public/s/img/pdf.webp b/public/s/img/pdf.webp
new file mode 100644
index 0000000..bbd3242
Binary files /dev/null and b/public/s/img/pdf.webp differ
diff --git a/public/s/img/swf.png b/public/s/img/swf.png
index 50bebc8..25b08a6 100644
Binary files a/public/s/img/swf.png and b/public/s/img/swf.png differ
diff --git a/public/s/js/admin.js b/public/s/js/admin.js
index 3b9d690..759d269 100644
--- a/public/s/js/admin.js
+++ b/public/s/js/admin.js
@@ -290,7 +290,7 @@
old.innerText = res.tag.trim();
break;
default:
- console.log(res);
+ window.f0ckDebug(res);
break;
}
}
diff --git a/public/s/js/comments.js b/public/s/js/comments.js
index b0d4204..e5ef61e 100644
--- a/public/s/js/comments.js
+++ b/public/s/js/comments.js
@@ -5,7 +5,12 @@ class CommentSystem {
this.user = this.container ? this.container.dataset.user : null; // logged in user?
this.isAdmin = this.container ? this.container.dataset.isAdmin === 'true' : false;
this.isLocked = this.container ? this.container.dataset.isLocked === 'true' : false;
+ this.displayMode = window.f0ckSession?.comment_display_mode || 0; // 0=Tree, 1=Linear
this.sort = (document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view')) ? 'old' : 'new';
+
+ // Linear mode usually implies chronological order (4chan style)
+ if (this.displayMode === 1) this.sort = 'old';
+
this.customEmojis = CommentSystem.emojiCache || {};
this.icons = {
@@ -40,6 +45,11 @@ class CommentSystem {
this.pendingSubmissions = new Set();
this.isMainSubmitting = false;
this.scrollListenerAdded = false;
+ this.commentCache = new Map();
+ this._anchorScrollDone = false; // true after the first hash-anchor scroll on initial load
+
+ this.loadEmojis(); // Always load emojis for previews
+ this.setupHoverPreviews();
if (this.itemId) {
this.init();
@@ -52,13 +62,12 @@ class CommentSystem {
return;
}
if (this.container.dataset.commentSystemInit) {
- console.log('[CommentSystem] Already initialized for this container');
+ window.f0ckDebug('[CommentSystem] Already initialized for this container');
return;
}
this.container.dataset.commentSystemInit = 'true';
- console.log('[CommentSystem] Initializing for item:', this.itemId);
+ window.f0ckDebug('[CommentSystem] Initializing for item:', this.itemId);
- this.loadEmojis(); // Don't await
this.loadComments();
this.setupGlobalListeners();
this.setupDelegatedEvents();
@@ -181,7 +190,7 @@ class CommentSystem {
this.stabilizationObserver.disconnect();
}
this.stopStabilization();
- console.log('[CommentSystem] Instance destroyed');
+ window.f0ckDebug('[CommentSystem] Instance destroyed');
}
async loadEmojis() {
@@ -203,7 +212,7 @@ class CommentSystem {
this.customEmojis[e.name] = e.url;
});
CommentSystem.emojiCache = this.customEmojis;
- console.log('Loaded Emojis:', this.customEmojis);
+ window.f0ckDebug('Loaded Emojis:', this.customEmojis);
// Preload images to prevent NS Binding Aborted errors
this.preloadEmojiImages();
@@ -221,9 +230,10 @@ class CommentSystem {
// even when emojis load late.
this.reconcile(this.lastData, this.lastUserId, this.lastIsSubscribed);
this.restoreState(state);
- // Only scroll to anchor if we're not in a preserve-scroll refresh
- if (!this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
+ // Only scroll to anchor on the very first load — never on async emoji reloads
+ if (!this._anchorScrollDone && !this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
const hashId = window.location.hash.substring(2);
+ this._anchorScrollDone = true;
this.scrollToComment(hashId);
}
}
@@ -255,7 +265,7 @@ class CommentSystem {
// ...
renderEmoji(match, name) {
- // console.log('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
+ // window.f0ckDebug('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
if (this.customEmojis && this.customEmojis[name]) {
return `
`;
}
@@ -364,7 +374,7 @@ class CommentSystem {
// However, if we ARE currently loading comments from server, skip re-render.
if (this.initialLoadDone === false && this.lastData.length === 0) {
- console.log('[CommentSystem] Live comment skipped - initial comments load in progress.');
+ window.f0ckDebug('[CommentSystem] Live comment skipped - initial comments load in progress.');
return;
}
@@ -398,6 +408,17 @@ class CommentSystem {
data.username_color || null
);
}
+
+ // Update backlinks for live comment
+ if (data.body) {
+ const matches = data.body.matchAll(/(?>(\d+)/g);
+ for (const match of matches) {
+ this.updateCommentBacklinks(match[1], data.id || data.comment_id);
+ }
+ }
+ if (data.parent_id) {
+ this.updateCommentBacklinks(data.parent_id, data.id || data.comment_id);
+ }
if (!this.lastData) this.lastData = [];
@@ -503,6 +524,9 @@ class CommentSystem {
// Check for server-side preloaded comments
const dataEl = document.getElementById('initial-comments');
+ const subEl = document.getElementById('initial-subscription');
+ const initialIsSubscribed = subEl ? (subEl.textContent.trim() === 'true') : false;
+
if (dataEl) {
try {
// Decode Base64 for safe template transfer
@@ -510,22 +534,21 @@ class CommentSystem {
const json = decodeURIComponent(escape(atob(raw)));
const comments = JSON.parse(json);
- const subEl = document.getElementById('initial-subscription');
- const isSubscribed = subEl && (subEl.textContent.trim() === 'true');
-
// Consume
dataEl.remove();
if (subEl) subEl.remove();
- this.render(comments, this.user, isSubscribed);
+ this.render(comments, this.user, initialIsSubscribed);
this.initialLoadDone = true;
this.restoreState(state);
this._loadDanmaku(comments);
if (scrollToId) {
+ this._anchorScrollDone = true;
this.scrollToComment(scrollToId);
- } else if (!preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
+ } else if (!this._anchorScrollDone && !preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
const hashId = window.location.hash.substring(2);
+ this._anchorScrollDone = true;
this.scrollToComment(hashId);
}
return;
@@ -538,7 +561,7 @@ class CommentSystem {
// Skip when preserveScroll=true (tab re-focus refresh): the user already sees comments,
// so wiping the DOM causes the browser to lose the #c anchor element and auto-scroll to top.
if (!scrollToId && !preserveScroll) {
- this.render([], this.user, false);
+ this.render([], this.user, initialIsSubscribed);
this.restoreState(state);
}
@@ -576,7 +599,7 @@ class CommentSystem {
// 1. Early Bail-out: If data is bit-for-bit identical, do nothing.
// This is the primary defense against tab-switch reloads when nothing changed.
if (preserveScroll && this._isDeeplyIdentical(data.comments, data.user_id, data.is_subscribed)) {
- console.log('[CommentSystem] Sync: Data identical, bailing early to protect media.');
+ window.f0ckDebug('[CommentSystem] Sync: Data identical, bailing early to protect media.');
this.restoreState(state);
this.preservingScroll = false;
return;
@@ -584,7 +607,7 @@ class CommentSystem {
// 2. Reconciliation: If data changed but we want to preserve media.
if (preserveScroll && this.lastData && this.lastData.length > 0) {
- console.log('[CommentSystem] Sync: Data changed, reconciling DOM.');
+ window.f0ckDebug('[CommentSystem] Sync: Data changed, reconciling DOM.');
this.reconcile(data.comments, data.user_id, data.is_subscribed);
this.initialLoadDone = true;
this.restoreState(state);
@@ -643,8 +666,9 @@ class CommentSystem {
this.preservingScroll = false;
if (savedHash) history.replaceState(null, '', window.location.pathname + window.location.search + savedHash);
// Only jump to anchor on first load — never on tab re-focus (preserveScroll).
- if (!preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
+ if (!this._anchorScrollDone && !preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
const hashId = window.location.hash.substring(2);
+ this._anchorScrollDone = true;
this.scrollToComment(hashId);
}
}
@@ -691,7 +715,7 @@ class CommentSystem {
this.startStabilization(id);
}
} else if (retries > 0) {
- console.log(`[CommentSystem] Scroll target #c${id} not found, retrying... (${retries} left)`);
+ window.f0ckDebug(`[CommentSystem] Scroll target #c${id} not found, retrying... (${retries} left)`);
setTimeout(() => this.scrollToComment(id, retries - 1), 200);
}
};
@@ -715,7 +739,7 @@ class CommentSystem {
const scrollKeys = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', ' ', 'Home', 'End'];
if (!scrollKeys.includes(e.key)) return;
}
- console.log(`[CommentSystem] Stabilization aborted due to ${e.type}`);
+ window.f0ckDebug(`[CommentSystem] Stabilization aborted due to ${e.type}`);
this.isUserInteracting = true;
this.stopStabilization();
};
@@ -738,7 +762,7 @@ class CommentSystem {
// If it shifted more than 10px (e.g. media loaded), re-scroll
if (diff > 10 && checks < maxChecks) {
- console.log(`[CommentSystem] Layout shift detected (${Math.round(diff)}px), re-stabilizing scroll...`);
+ window.f0ckDebug(`[CommentSystem] Layout shift detected (${Math.round(diff)}px), re-stabilizing scroll...`);
this.scrollToComment(id, 0, true);
lastTop = currentEl.getBoundingClientRect().top;
}
@@ -786,6 +810,177 @@ class CommentSystem {
this.boundStopStabilization = null;
}
}
+
+ async showCommentPreview(link, event) {
+ if (this.previewCloseTimer) {
+ clearTimeout(this.previewCloseTimer);
+ this.previewCloseTimer = null;
+ }
+
+ const targetId = link.dataset.id;
+ let targetEl = document.getElementById('c' + targetId);
+
+ const parentPopup = link.closest('.comment-preview-popup');
+ const level = parentPopup ? parseInt(parentPopup.dataset.level || 0) + 1 : 0;
+
+ if (!targetEl) {
+ const cached = this.commentCache.get(targetId);
+ if (cached) {
+ targetEl = this.createTemporaryCommentNode(cached);
+ } else {
+ // Fetch from API
+ this.fetchCommentForPreview(targetId, link, level);
+ return;
+ }
+ }
+
+ // Close any previews at this level or higher (replacing the current branch)
+ this.closePreviewsAboveLevel(level - 1);
+
+ // If this exact ID is already the immediate child of this parent, don't re-open
+ const existing = document.querySelector(`.comment-preview-popup[data-id="${targetId}"][data-level="${level}"]`);
+ if (existing) return;
+
+ // Clone the target comment
+ const preview = targetEl.cloneNode(true);
+ preview.id = 'comment-preview-' + Date.now();
+ preview.classList.add('comment-preview-popup');
+ preview.dataset.level = level;
+ preview.dataset.id = targetId;
+
+ // Remove temporary animation/highlight classes
+ preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
+
+ // Remove input forms or action buttons
+ const actions = preview.querySelector('.comment-actions');
+ if (actions) actions.remove();
+ const footer = preview.querySelector('.comment-footer');
+ if (footer) footer.remove();
+
+ // Position the preview
+ const rect = link.getBoundingClientRect();
+ preview.style.position = 'fixed';
+ preview.style.zIndex = (100000 + level).toString();
+
+ // Try to place it to the right of the link, or top/bottom if needed
+ let left = rect.right + 10;
+ let top = rect.top;
+
+ document.body.appendChild(preview);
+
+ const previewRect = preview.getBoundingClientRect();
+
+ // Boundary checks
+ if (left + previewRect.width > window.innerWidth) {
+ left = rect.left - previewRect.width - 10;
+ }
+ if (left < 0) left = 10;
+
+ if (top + previewRect.height > window.innerHeight) {
+ top = window.innerHeight - previewRect.height - 10;
+ }
+ if (top < 0) top = 10;
+
+ preview.style.left = left + 'px';
+ preview.style.top = top + 'px';
+ }
+
+ async fetchCommentForPreview(id, link, level) {
+ if (this.commentCache.has(id)) return; // Already fetching or fetched
+
+ // Prevent double fetches
+ this.commentCache.set(id, { loading: true });
+
+ try {
+ const res = await fetch(`/api/comment/${id}`);
+ const json = await res.json();
+ if (json.success && json.comment) {
+ this.commentCache.set(id, json.comment);
+ // If the link is still being hovered (or we are in the middle of a dwell), show it
+ if (this.currentHoverLink === link) {
+ this.showCommentPreview(link);
+ }
+ } else {
+ this.commentCache.delete(id);
+ }
+ } catch (e) {
+ console.error('[CommentSystem] Failed to fetch comment for preview:', e);
+ this.commentCache.delete(id);
+ }
+ }
+
+ createTemporaryCommentNode(comment) {
+ if (comment.loading) {
+ const div = document.createElement('div');
+ div.className = 'comment loading-comment';
+ div.innerHTML = '
Loading...
';
+ return div;
+ }
+ const html = this.renderComment(comment, window.f0ckSession?.user, false, true);
+ const div = document.createElement('div');
+ div.innerHTML = html;
+ return div.firstElementChild;
+ }
+
+ closePreviewsAboveLevel(level) {
+ document.querySelectorAll('.comment-preview-popup').forEach(p => {
+ if (parseInt(p.dataset.level || 0) > level) {
+ p.remove();
+ }
+ });
+ }
+
+ hideCommentPreview(force = false) {
+ if (force) {
+ this.closePreviewsAboveLevel(-1);
+ return;
+ }
+
+ if (this.previewCloseTimer) clearTimeout(this.previewCloseTimer);
+ this.previewCloseTimer = setTimeout(() => {
+ this.closePreviewsAboveLevel(-1);
+ }, 400); // 400ms grace period to move mouse between link and popup
+ }
+
+ quoteComment(id, openerEl, body) {
+ // If no reply input open anywhere, open the local one
+ let textarea = document.querySelector('.comment-input.reply-input textarea');
+ let isNew = false;
+ if (!textarea && !body.querySelector('.reply-input')) {
+ const div = document.createElement('div');
+ div.innerHTML = this.renderInput(id);
+ body.appendChild(div.firstElementChild);
+ const newForm = body.querySelector('.reply-input');
+ this.setupEmojiPicker(newForm);
+ textarea = newForm.querySelector('textarea');
+ newForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ isNew = true;
+ } else if (!textarea) {
+ textarea = body.querySelector('.reply-input textarea');
+ }
+
+ if (textarea) {
+ const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
+ const contentEl = body.querySelector('.comment-content');
+ if (contentEl) {
+ const rawText = (contentEl.dataset.raw || '').trim();
+ const lines = rawText.split('\n');
+ const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
+
+ if (isNew) {
+ textarea.value = quote;
+ } else {
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const val = textarea.value;
+ textarea.value = val.substring(0, start) + quote + val.substring(end);
+ }
+ textarea.focus({ preventScroll: true });
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }
+ }
saveMediaState() {
// Snapshot currently playing or paused-mid-way media elements
@@ -824,54 +1019,66 @@ class CommentSystem {
this.lastData = comments;
this.lastUserId = currentUserId;
this.lastIsSubscribed = isSubscribed;
+
+ // Build map of who replied to whom for back-references (>>ID)
+ this.buildBacklinkMap(comments);
- // Build two-level tree: top-level comments + all replies at one level
- const map = new Map();
- const roots = [];
+ // Render logic based on display mode
+ let renderedHtml = '';
+ if (this.displayMode === 1) {
+ // Linear Mode: Just sort all comments by date and render them flat
+ const sortedComments = [...comments].sort((a, b) => {
+ if (this.sort === 'old') return new Date(a.created_at) - new Date(b.created_at);
+ return new Date(b.created_at) - new Date(a.created_at);
+ });
+ renderedHtml = sortedComments.map(c => this.renderComment(c, currentUserId, false, true)).join('');
+ } else {
+ // Tree Mode: Group by roots and replies
+ const map = new Map();
+ const roots = [];
- comments.forEach(c => {
- c.replies = [];
- c.replyTo = null; // Username being replied to (for @mentions)
- map.set(c.id, c);
- });
+ comments.forEach(c => {
+ c.replies = [];
+ c.replyTo = null; // Username being replied to (for @mentions)
+ map.set(c.id, c);
+ });
- // Find root parent for any comment
- const findRoot = (comment) => {
- if (!comment.parent_id) return null;
- let current = comment;
- while (current.parent_id && map.has(current.parent_id)) {
- current = map.get(current.parent_id);
- }
- return current;
- };
-
- comments.forEach(c => {
- if (!c.parent_id) {
- // Top-level comment
- roots.push(c);
- } else {
- // It's a reply - find root and attach there
- const root = findRoot(c);
- if (root && root !== c) {
- // If replying to a non-root, capture the username for @mention
- const directParent = map.get(c.parent_id);
- if (directParent && directParent.id !== root.id) {
- c.replyTo = directParent.username;
- }
- root.replies.push(c);
- } else {
- // Orphaned reply (parent deleted?) - show as root
- roots.push(c);
+ // Find root parent for any comment
+ const findRoot = (comment) => {
+ if (!comment.parent_id) return null;
+ let current = comment;
+ while (current.parent_id && map.has(current.parent_id)) {
+ current = map.get(current.parent_id);
}
- }
- });
+ return current;
+ };
- // Sort replies by date (oldest first)
- roots.forEach(r => {
- if (r.replies && r.replies.length > 0) {
- r.replies.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
- }
- });
+ comments.forEach(c => {
+ if (!c.parent_id) {
+ roots.push(c);
+ } else {
+ const root = findRoot(c);
+ if (root && root !== c) {
+ const directParent = map.get(c.parent_id);
+ if (directParent && directParent.id !== root.id) {
+ c.replyTo = directParent.username;
+ }
+ root.replies.push(c);
+ } else {
+ roots.push(c);
+ }
+ }
+ });
+
+ // Sort replies by date (oldest first)
+ roots.forEach(r => {
+ if (r.replies && r.replies.length > 0) {
+ r.replies.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+ }
+ });
+
+ renderedHtml = roots.map(c => this.renderComment(c, currentUserId)).join('');
+ }
// Determine what to show for input
let inputSection = '';
@@ -896,23 +1103,36 @@ class CommentSystem {
`;
}
- let html = `
-
- ${inputSection}
- `;
+ let html = '';
+ if (isLegacy) {
+ html = `
+
+ ${inputSection}
+ `;
+ } else {
+ html = `
+ ${inputSection}
+
+ `;
+ }
+
const mediaState = this.saveMediaState();
- this.container.innerHTML = Sanitizer.clean(html);
+ this.container.innerHTML = html;
this.restoreMediaState(mediaState);
this.syncSubscribeButton(isSubscribed);
// Attach media load listeners to re-stabilize scroll if a hash is active.
- // Skipped during a preserveScroll refresh (tab re-focus) to avoid fighting the
- // position restoration and causing the visible jump-then-snap behaviour.
- if (!this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
+ // Only during the initial anchor scroll — never on subsequent renders (tab re-focus,
+ // emoji reloads, live comment updates). _anchorScrollDone is set after the first
+ // scrollToComment call, so this block only runs on the initial page load.
+ if (!this._anchorScrollDone && !this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
const hashId = window.location.hash.substring(2);
this.container.querySelectorAll('img, video, audio').forEach(media => {
const handler = () => {
@@ -989,6 +1209,9 @@ class CommentSystem {
this.lastData = comments;
this.lastUserId = currentUserId;
this.lastIsSubscribed = isSubscribed;
+
+ // Build map of who replied to whom for back-references (>>ID)
+ this.buildBacklinkMap(comments);
const incomingMap = new Map();
comments.forEach(c => incomingMap.set(String(c.id), c));
@@ -1013,8 +1236,8 @@ class CommentSystem {
// Check for edits or state changes using robust data-attributes
const contentEl = el.querySelector('.comment-content');
if (contentEl && contentEl.dataset.raw !== incoming.content) {
- console.log(`[CommentSystem] Reconcile: Updating content for #c${id}`);
- contentEl.innerHTML = Sanitizer.clean(this.renderCommentContent(incoming.content));
+ window.f0ckDebug(`[CommentSystem] Reconcile: Updating content for #c${id}`);
+ contentEl.innerHTML = this.renderCommentContent(incoming.content);
contentEl.dataset.raw = incoming.content;
}
@@ -1026,7 +1249,39 @@ class CommentSystem {
});
// 2. Insert new comments
- // We re-run the tree logic to determine correct order
+ if (this.displayMode === 1) {
+ // Linear Mode Insertion
+ const sortedComments = [...comments].sort((a, b) => {
+ if (this.sort === 'old') return new Date(a.created_at) - new Date(b.created_at);
+ return new Date(b.created_at) - new Date(a.created_at);
+ });
+
+ sortedComments.forEach((c, idx) => {
+ const idStr = String(c.id);
+ if (document.getElementById('c' + idStr)) return;
+
+ window.f0ckDebug(`[CommentSystem] Reconcile: Injecting new flat comment #c${idStr}`);
+ const html = this.renderComment(c, currentUserId, false, true);
+ const tmp = document.createElement('div');
+ tmp.innerHTML = html;
+ const newEl = tmp.firstElementChild;
+
+ if (idx === 0) {
+ const nav = list.querySelector('.scroll-nav-wrapper');
+ if (nav) nav.insertAdjacentElement('afterend', newEl);
+ else list.prepend(newEl);
+ } else {
+ const prevId = String(sortedComments[idx - 1].id);
+ const prevEl = document.getElementById('c' + prevId);
+ if (prevEl) prevEl.insertAdjacentElement('afterend', newEl);
+ else list.appendChild(newEl);
+ }
+ newEl.classList.add('comment-entering', 'new-item-fade');
+ });
+ return;
+ }
+
+ // Tree Mode Insertion (Existing Logic)
const map = new Map();
const roots = [];
comments.forEach(c => {
@@ -1067,8 +1322,8 @@ class CommentSystem {
let el = document.getElementById('c' + idStr);
if (!el) {
- console.log(`[CommentSystem] Reconcile: Injecting new comment #c${idStr}`);
- const html = Sanitizer.clean(this.renderComment(c, currentUserId, isReply));
+ window.f0ckDebug(`[CommentSystem] Reconcile: Injecting new comment #c${idStr}`);
+ const html = this.renderComment(c, currentUserId, isReply);
const tmp = document.createElement('div');
tmp.innerHTML = html;
const newEl = tmp.firstElementChild;
@@ -1235,7 +1490,9 @@ class CommentSystem {
const trimmed = line.trimStart();
// 1. Manual Greentext/Quote handling (avoids recursive blockquote parsing)
- if (trimmed.startsWith('>')) {
+ // Exclude only the numeric context links (>>ID) so they can be handled as interactive links.
+ // Multiple chevrons (>>text, >>>text) should still be greentext.
+ if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const renderedContent = quoteEmojis
@@ -1257,6 +1514,11 @@ class CommentSystem {
const user = g1 || g2;
return `@${user}`;
});
+
+ // Handle Comment Context Links (>>ID)
+ processedLine = processedLine.replace(/(?>(\d+)/g, (match, id) => {
+ return ``;
+ });
// Handle Image Embeds
processedLine = processedLine.replace(imageRegex, (match, url) => {
@@ -1307,6 +1569,15 @@ class CommentSystem {
}
);
}
+
+ // Vocaroo embed
+ md = md.replace(
+ /]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
+ (match, vocarooId) => {
+ if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
+ return ``;
+ }
+ );
// Build regex for allowed media hosters (video/audio)
const mediaHosts = [escapedSiteHost];
@@ -1359,12 +1630,43 @@ class CommentSystem {
return codeBlocks[index] || '';
});
+ if (window.Sanitizer && typeof Sanitizer.clean === 'function') {
+ md = Sanitizer.clean(md);
+ }
return md;
} catch (e) {
console.error('Markdown error:', e);
return this.escapeHtml(content);
}
}
+
+ buildBacklinkMap(comments) {
+ this.backlinkMap = {};
+ const process = (c) => {
+ if (!c.content) return;
+ // Scan for >>ID patterns
+ const matches = c.content.matchAll(/(?>(\d+)/g);
+ for (const match of matches) {
+ const targetId = match[1];
+ if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
+ this.backlinkMap[targetId].add(c.id);
+ }
+ // Also treat parent_id as a direct reply
+ if (c.parent_id) {
+ const targetId = String(c.parent_id);
+ if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
+ this.backlinkMap[targetId].add(c.id);
+ }
+ };
+
+ const scan = (list) => {
+ list.forEach(c => {
+ process(c);
+ if (c.replies && c.replies.length > 0) scan(c.replies);
+ });
+ };
+ scan(comments);
+ }
renderEmoji(match, name) {
if (this.customEmojis && this.customEmojis[name]) {
@@ -1386,7 +1688,36 @@ class CommentSystem {
}, 30000);
}
- renderComment(comment, currentUserId, isReply = false) {
+ updateCommentBacklinks(targetId, replierId) {
+ if (!this.backlinkMap) this.backlinkMap = {};
+ if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
+ this.backlinkMap[targetId].add(replierId);
+
+ const targetEl = document.getElementById('c' + targetId);
+ if (targetEl) {
+ const headerLeft = targetEl.querySelector('.comment-header-left');
+ if (headerLeft) {
+ let span = headerLeft.querySelector('.comment-backlinks');
+ if (!span) {
+ span = document.createElement('span');
+ span.className = 'comment-backlinks';
+ headerLeft.appendChild(span);
+ }
+ // Check if already present
+ if (!span.querySelector(`a[data-id="${replierId}"]`)) {
+ const link = document.createElement('a');
+ link.href = `#c${replierId}`;
+ link.className = 'comment-context-link';
+ link.dataset.id = replierId;
+ link.textContent = `>>${replierId}`;
+ span.appendChild(document.createTextNode(' '));
+ span.appendChild(link);
+ }
+ }
+ }
+ }
+
+ renderComment(comment, currentUserId, isReply = false, isLinear = false) {
const isDeleted = comment.is_deleted;
const isPinned = comment.is_pinned;
@@ -1406,13 +1737,25 @@ class CommentSystem {
// Build replies HTML (only for root comments, max 1 level deep)
let repliesHtml = '';
- if (!isReply && comment.replies && comment.replies.length > 0) {
+ if (!isReply && !isLinear && comment.replies && comment.replies.length > 0) {
repliesHtml = ``;
}
const timeAgo = this.timeAgo(comment.created_at);
const fullDate = new Date(comment.created_at).toISOString();
+ // Parent context marker removed (redundant with back-references)
+ let contextMarker = '';
+
+ // Back-references (replies to this comment)
+ let backlinkHtml = '';
+ if (this.backlinkMap && this.backlinkMap[comment.id]) {
+ const repliers = Array.from(this.backlinkMap[comment.id]);
+ if (repliers.length > 0) {
+ backlinkHtml = ``;
+ }
+ }
+
return `
${repliesHtml}
`;
@@ -1488,8 +1833,117 @@ class CommentSystem {
`;
}
+ setupHoverPreviews() {
+ if (CommentSystem.hoverPreviewsAttached) return;
+ CommentSystem.hoverPreviewsAttached = true;
+
+ // Hover for Comment Context Links (>>ID) - Global delegation for nested previews (inception)
+ this.mouseCurrentLevel = -1;
+ this.currentHoverLink = null;
+ document.addEventListener('mouseover', (e) => {
+ const contextLink = e.target.closest('.comment-context-link');
+ const popup = e.target.closest('.comment-preview-popup');
+
+ if (contextLink || popup) {
+ if (this.previewCloseTimer) {
+ clearTimeout(this.previewCloseTimer);
+ this.previewCloseTimer = null;
+ }
+ }
+
+ if (contextLink) {
+ // Ignore mouseover for previews on mobile touch devices to prevent tap-to-preview
+ // We handle mobile previews via the touchstart timer instead.
+ if (window.matchMedia('(pointer: coarse)').matches) return;
+
+ if (this.currentHoverLink === contextLink) return;
+ this.currentHoverLink = contextLink;
+
+ if (this.previewOpenTimer) clearTimeout(this.previewOpenTimer);
+ this.previewOpenTimer = setTimeout(() => {
+ this.showCommentPreview(contextLink, e);
+ }, 150); // 150ms dwell time to prevent flickering while moving mouse
+ } else {
+ if (this.previewOpenTimer) {
+ clearTimeout(this.previewOpenTimer);
+ this.previewOpenTimer = null;
+ }
+ this.currentHoverLink = null;
+
+ const level = popup ? parseInt(popup.dataset.level || 0) : -1;
+
+ // If we move back to a parent level or blank area of a popup, close its children
+ // but use a delay so the user can reach the child popup if they are moving towards it.
+ if (popup && !contextLink) {
+ this.previewCloseTimer = setTimeout(() => {
+ this.closePreviewsAboveLevel(level);
+ }, 400);
+ }
+
+ this.mouseCurrentLevel = level;
+ }
+ });
+
+ document.addEventListener('mouseout', (e) => {
+ const contextLink = e.target.closest('.comment-context-link');
+ const popup = e.target.closest('.comment-preview-popup');
+
+ if (contextLink) {
+ if (this.previewOpenTimer) {
+ clearTimeout(this.previewOpenTimer);
+ this.previewOpenTimer = null;
+ }
+ this.currentHoverLink = null;
+ }
+
+ if (contextLink || popup) {
+ if (this.previewCloseTimer) clearTimeout(this.previewCloseTimer);
+ this.previewCloseTimer = setTimeout(() => {
+ this.closePreviewsAboveLevel(-1);
+ this.mouseCurrentLevel = -1;
+ }, 400);
+ }
+ });
+
+ // Mobile Touch Support: Touch-and-hold to preview
+ let touchPreviewTimer = null;
+ document.addEventListener('touchstart', (e) => {
+ const contextLink = e.target.closest('.comment-context-link');
+ if (contextLink) {
+ if (touchPreviewTimer) clearTimeout(touchPreviewTimer);
+ touchPreviewTimer = setTimeout(() => {
+ this.showCommentPreview(contextLink, e);
+ }, 150); // 150ms hold to trigger preview on mobile
+ }
+ }, { passive: true });
+
+ document.addEventListener('touchmove', () => {
+ if (touchPreviewTimer) {
+ clearTimeout(touchPreviewTimer);
+ touchPreviewTimer = null;
+ }
+ }, { passive: true });
+
+ document.addEventListener('touchend', () => {
+ if (touchPreviewTimer) {
+ clearTimeout(touchPreviewTimer);
+ touchPreviewTimer = null;
+ }
+ }, { passive: true });
+
+ // Global click listener to close popups (useful for mobile dismissal)
+ document.addEventListener('click', (e) => {
+ const isLink = e.target.closest('.comment-context-link');
+ const isPopup = e.target.closest('.comment-preview-popup');
+
+ if (!isLink && !isPopup) {
+ this.closePreviewsAboveLevel(-1);
+ }
+ });
+ }
+
setupDelegatedEvents() {
- console.log('[DEBUG] Setting up delegated events for container:', this.container);
+ window.f0ckDebug('[DEBUG] Setting up delegated events for container:', this.container);
if (!this.container) return;
// Ctrl+Enter to submit comment
@@ -1517,13 +1971,9 @@ class CommentSystem {
// Single Click Listener for Everything
this.container.addEventListener('click', async (e) => {
- console.log('[DEBUG] Click on container:', e.target);
+ window.f0ckDebug('[DEBUG] Click on container:', e.target);
const target = e.target;
- // Handle legacy layout focus scroll
- if (target.matches('textarea') && (document.body.classList.contains('legacy-view') || document.body.classList.contains('layout-legacy'))) {
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
// Toggling Scroll Action
const scrollBtn = target.closest('.scroll-to-bottom');
@@ -1559,6 +2009,22 @@ class CommentSystem {
return;
}
+ // Comment Context Link (>>ID)
+ const contextLink = target.closest('.comment-context-link');
+ if (contextLink) {
+ e.preventDefault();
+ const targetId = contextLink.dataset.id;
+ this.scrollToComment(targetId, 0, true);
+
+ // Highlight effect
+ const targetEl = document.getElementById('c' + targetId);
+ if (targetEl) {
+ targetEl.classList.add('highlight-comment');
+ setTimeout(() => targetEl.classList.remove('highlight-comment'), 2000);
+ }
+ return;
+ }
+
// User Delete
const delBtn = target.closest('.delete-btn');
if (delBtn) {
@@ -1678,39 +2144,51 @@ class CommentSystem {
}
// Reply
+ // Reply Button (ID only)
const replyBtn = target.closest('.reply-btn');
if (replyBtn) {
const id = replyBtn.dataset.id;
- const body = replyBtn.closest('.comment-body');
-
- if (body.querySelector('.reply-input')) return;
-
- const div = document.createElement('div');
- div.innerHTML = this.renderInput(id);
- body.appendChild(div.firstElementChild);
- const newForm = body.querySelector('.reply-input');
- this.setupEmojiPicker(newForm);
+ const commentEl = replyBtn.closest('[id^="c"]');
+ const body = commentEl ? commentEl.querySelector('.comment-body') : null;
- // Add quote content — use data-raw to get the original unrendered text,
- // which always has :emojicode:, [spoiler] tags, etc. intact.
- const author = replyBtn.dataset.display || replyBtn.dataset.username || 'System';
- const contentEl = body.querySelector('.comment-content');
- const textarea = newForm.querySelector('textarea');
- if (contentEl && textarea) {
- const rawText = (contentEl.dataset.raw || '').trim();
- const lines = rawText.split('\n');
- const quote = `>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
- textarea.value = quote;
+ if (body) {
+ // Check if any reply input is ALREADY open
+ let textarea = document.querySelector('.comment-input.reply-input textarea');
- // Focus and move cursor to end
- textarea.focus();
- textarea.setSelectionRange(textarea.value.length, textarea.value.length);
+ // If none open, open the local one for this comment
+ if (!textarea && !body.querySelector('.reply-input')) {
+ const div = document.createElement('div');
+ div.innerHTML = this.renderInput(id);
+ body.appendChild(div.firstElementChild);
+ const newForm = body.querySelector('.reply-input');
+ this.setupEmojiPicker(newForm);
+ textarea = newForm.querySelector('textarea');
+ } else if (!textarea) {
+ textarea = body.querySelector('.reply-input textarea');
+ }
+
+ if (textarea) {
+ const quote = `>>${id} `;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const val = textarea.value;
+ textarea.value = val.substring(0, start) + quote + val.substring(end);
+ textarea.focus({ preventScroll: true });
+ textarea.selectionStart = textarea.selectionEnd = start + quote.length;
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+ }
}
+ return;
+ }
- // Smoothly scroll the reply form into view
- newForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
-
- // Buttons in newForm are handled by delegation (.submit-comment, .cancel-reply)
+ // Quote Button (Full Text Quote - Old Style)
+ const quoteBtn = target.closest('.quote-btn');
+ if (quoteBtn) {
+ const id = quoteBtn.dataset.id;
+ const body = quoteBtn.closest('.comment-body');
+ if (body) {
+ this.quoteComment(id, quoteBtn, body);
+ }
return;
}
@@ -1760,25 +2238,19 @@ class CommentSystem {
return;
}
- // Permalinks
- if (target.classList.contains('comment-permalink')) {
- const href = target.getAttribute('href');
- if (href && href.startsWith('#c')) {
- e.preventDefault();
- const id = href.substring(2);
- history.pushState(null, null, href);
- this.scrollToComment(id);
- }
- return;
- }
-
- // Timestamp click → open reply
- if (target.closest('.comment-time')) {
- e.preventDefault();
- const commentEl = target.closest('[id^="c"]');
- if (commentEl) {
- const replyBtn = commentEl.querySelector(':scope > .comment-body .reply-btn');
- if (replyBtn) replyBtn.click();
+ // Permalinks & Timestamp clicks
+ if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
+ const el = target.closest('.comment-permalink, .comment-time');
+ if (el) {
+ const id = el.dataset.id;
+ const commentEl = el.closest('[id^="c"]');
+ const body = commentEl ? commentEl.querySelector('.comment-body') : null;
+ if (body) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ this.quoteComment(id, el, body);
+ }
}
return;
}
@@ -1943,7 +2415,7 @@ class CommentSystem {
repliesEl.className = 'comment-replies';
parentEl.insertAdjacentElement('afterend', repliesEl);
}
- const commentHtml = Sanitizer.clean(this.renderComment(newComment, this.lastUserId, true));
+ const commentHtml = this.renderComment(newComment, this.lastUserId, true);
const tmp = document.createElement('div');
tmp.innerHTML = commentHtml;
const commentEl = tmp.firstElementChild;
@@ -1966,7 +2438,7 @@ class CommentSystem {
} else {
const list = this.container.querySelector('.comments-list');
if (list) {
- const commentHtml = Sanitizer.clean(this.renderComment(newComment, this.lastUserId, false));
+ const commentHtml = this.renderComment(newComment, this.lastUserId, false);
const tmp = document.createElement('div');
tmp.innerHTML = commentHtml;
const commentEl = tmp.firstElementChild;
@@ -2004,7 +2476,7 @@ class CommentSystem {
retryCount++;
// Randomized exponential backoff
const delay = Math.min(1000 * Math.pow(1.5, retryCount) + (Math.random() * 1000), 10000);
- console.log(`[CommentSystem] Retrying in ${Math.round(delay)}ms...`);
+ window.f0ckDebug(`[CommentSystem] Retrying in ${Math.round(delay)}ms...`);
setTimeout(attemptSubmit, delay);
} else {
alert('Failed to send comment after multiple attempts. Please check your connection.');
@@ -2198,6 +2670,7 @@ class CommentSystem {
setupEmojiPicker(container) {
const textarea = container.querySelector('textarea');
+ if (!textarea) return;
if (container.querySelector('.emoji-trigger')) return;
// Attach mentions
@@ -2376,7 +2849,7 @@ class CommentSystem {
if (actions) {
// Add spoiler button
const spoilerBtn = document.createElement('button');
- spoilerBtn.innerText = '[spoiler]';
+ spoilerBtn.innerText = '[S]';
spoilerBtn.className = 'spoiler-trigger';
spoilerBtn.title = 'Insert spoiler tag';
spoilerBtn.addEventListener('click', (e) => {
diff --git a/public/s/js/f0ck_upload_init.js b/public/s/js/f0ck_upload_init.js
index 6ec5b90..6573ae2 100644
--- a/public/s/js/f0ck_upload_init.js
+++ b/public/s/js/f0ck_upload_init.js
@@ -10,6 +10,7 @@
const showModal = () => {
if (!dragModal) return;
dragModal.classList.add('show');
+ document.body.classList.add('modal-open');
// Reset scroll position so it always starts at the top
dragModal.scrollTop = 0;
const modalContent = dragModal.querySelector('.modal-content');
@@ -89,6 +90,7 @@
// Modal Close
dragModalClose.onclick = () => {
dragModal.classList.remove('show');
+ document.body.classList.remove('modal-open');
if (uploader && uploader.reset) {
uploader.reset();
}
diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js
index 1fc5c4d..4317c71 100644
--- a/public/s/js/f0ckm.js
+++ b/public/s/js/f0ckm.js
@@ -40,12 +40,92 @@ window.cancelAnimFrame = (function () {
} catch(e) { console.error('Visit tracking error:', e); }
};
+ window.applyThumbCacheBust = (bgUrlStr) => {
+ if (!bgUrlStr) return bgUrlStr;
+ try {
+ const bustedStr = localStorage.getItem('bustedThumbs');
+ if (!bustedStr) return bgUrlStr;
+ const busted = JSON.parse(bustedStr);
+ const match = bgUrlStr.match(/\/t\/(\d+)(?:_blur)?\.webp/);
+ if (match) {
+ const id = match[1];
+ if (busted[id]) {
+ const url = new URL(bgUrlStr, window.location.origin);
+ url.searchParams.set('t', busted[id]);
+ return url.pathname + url.search;
+ }
+ }
+ } catch(e) {}
+ return bgUrlStr;
+ };
+
+ /**
+ * Forcefully refreshes all thumbnail occurrences for a specific item in the DOM.
+ * Handles grid items (data-bg), images (src), and the background canvas.
+ */
+ window.refreshItemThumbnails = (itemId, timestamp = Date.now()) => {
+ if (!itemId) return;
+ const idStr = String(itemId);
+
+ // Update localStorage so future navigations use the new timestamp
+ try {
+ const bustedStr = localStorage.getItem('bustedThumbs');
+ const busted = bustedStr ? JSON.parse(bustedStr) : {};
+ busted[idStr] = timestamp;
+ const keys = Object.keys(busted);
+ if (keys.length > 50) delete busted[keys[0]];
+ localStorage.setItem('bustedThumbs', JSON.stringify(busted));
+ } catch(e) {}
+
+ // Clear grid cache to force fresh render on next navigation
+ if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear();
+
+ // Update elements with data-bg (grid items).
+ // We look for any data-bg or inline style containing the thumbnail path for this ID.
+ document.querySelectorAll(`[data-bg*="/t/${idStr}.webp"], [data-bg*="/t/${idStr}_blur.webp"], [style*="/t/${idStr}.webp"], [style*="/t/${idStr}_blur.webp"]`).forEach(el => {
+ // If it has data-bg, update it (this handles lazy-thumb logic)
+ if (el.dataset.bg) {
+ el.dataset.bg = window.applyThumbCacheBust(el.dataset.bg);
+ }
+ // If it's already showing the background, update the style directly
+ if (el.style.backgroundImage || el.getAttribute('style')?.includes('background-image')) {
+ const currentStyle = el.getAttribute('style') || '';
+ // Match url(...) contents
+ const newStyle = currentStyle.replace(/url\(['"]?([^'"]+)['"]?\)/g, (match, p1) => {
+ if (p1.includes(`/t/${idStr}.webp`) || p1.includes(`/t/${idStr}_blur.webp`)) {
+ return `url('${window.applyThumbCacheBust(p1)}')`;
+ }
+ return match;
+ });
+ el.setAttribute('style', newStyle);
+ }
+ });
+
+ // Update actual img tags
+ document.querySelectorAll(`img[src*="/t/${idStr}.webp"], img[src*="/t/${idStr}_blur.webp"]`).forEach(el => {
+ try {
+ const url = new URL(el.src, window.location.origin);
+ url.searchParams.set('t', timestamp);
+ el.src = url.pathname + url.search;
+ } catch(e) {}
+ });
+
+ // Refresh background canvas if it matches the current item
+ const pathParts = window.location.pathname.split('/');
+ const numParts = pathParts.filter(s => /^\d+$/.test(s));
+ const currentId = numParts.length > 0 ? numParts[numParts.length - 1] : null;
+ if (currentId === idStr && window.initBackground) {
+ window.initBackground();
+ }
+ };
+
let lazyObserver;
window.initLazyLoading = () => {
if (!('IntersectionObserver' in window)) {
document.querySelectorAll('.lazy-thumb').forEach(thumb => {
if (thumb.dataset.bg) {
- thumb.style.backgroundImage = `url('${thumb.dataset.bg}')`;
+ const finalBg = window.applyThumbCacheBust(thumb.dataset.bg);
+ thumb.style.backgroundImage = `url('${finalBg}')`;
thumb.classList.remove('lazy-thumb');
}
});
@@ -57,8 +137,9 @@ window.cancelAnimFrame = (function () {
entries.forEach(entry => {
if (entry.isIntersecting) {
const thumb = entry.target;
- const bg = thumb.dataset.bg;
+ let bg = thumb.dataset.bg;
if (bg && !thumb.classList.contains('loaded')) {
+ bg = window.applyThumbCacheBust(bg);
const img = new Image();
img.onload = () => {
thumb.style.backgroundImage = `url('${bg}')`;
@@ -293,11 +374,61 @@ window.cancelAnimFrame = (function () {
if (!modal) return;
if (modal === loginModal) switchModalView(view);
modal.style.display = 'flex';
+ document.body.classList.add('modal-open');
if (visitorMenu) visitorMenu.classList.remove('show');
};
const closeModal = (modal) => {
- if (modal) modal.style.display = 'none';
+ if (modal) {
+ modal.style.display = 'none';
+ document.body.classList.remove('modal-open');
+ }
+ };
+
+ /**
+ * Surgical cleanup of scroll-lock state and modal visibility.
+ * Used during AJAX navigation to ensure the UI remains interactive.
+ */
+ window.resetGlobalScrollState = () => {
+ document.body.classList.remove('modal-open');
+ document.documentElement.classList.remove('modal-open');
+ document.body.style.overflow = '';
+ document.body.style.height = '';
+ document.documentElement.style.overflow = '';
+ document.documentElement.style.height = '';
+ const pw = document.querySelector('.pagewrapper');
+ if (pw) {
+ pw.style.overflow = '';
+ pw.style.height = '';
+ }
+ };
+
+ window.hideAllModals = () => {
+ const modalIds = [
+ 'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
+ 'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
+ 'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
+ 'content-warning-modal', 'gchat-img-modal', 'image-modal'
+ ];
+ modalIds.forEach(id => {
+ const el = document.getElementById(id);
+ if (el) {
+ el.classList.remove('show', 'visible');
+ // If the modal uses CSS classes for visibility, we must clear the inline display
+ // to allow those classes to work later. For others, we force display: none.
+ if (['upload-drag-modal', 'image-modal', 'gchat-img-modal', 'excluded-tags-overlay'].includes(id)) {
+ el.style.display = '';
+ } else {
+ el.style.display = 'none';
+ }
+ }
+ });
+ // Also handle class-based modals if any
+ document.querySelectorAll('.modal-overlay, .modal-backdrop').forEach(el => {
+ el.classList.remove('show', 'visible');
+ // Do NOT set display: none here as it might override CSS-based visibility
+ // for modals that use the classes we just removed.
+ });
};
if (loginModal) {
@@ -512,6 +643,8 @@ window.cancelAnimFrame = (function () {
}
}
+
+
if (registerBtn && registerModal) {
registerBtn.addEventListener('click', (e) => {
e.preventDefault();
@@ -844,7 +977,9 @@ window.cancelAnimFrame = (function () {
}
// For audio-only items with no thumbnail, canvas stays blank (nothing to draw)
};
- thumb.src = `/t/${itemId}.webp`;
+ let newSrc = `/t/${itemId}.webp`;
+ if (window.applyThumbCacheBust) newSrc = window.applyThumbCacheBust(newSrc);
+ thumb.src = newSrc;
} else if (isDrawable) {
// No item ID — fall back to waiting for the main image
if (elem.complete) {
@@ -1016,8 +1151,7 @@ window.cancelAnimFrame = (function () {
if (cwModal) {
if (!localStorage.getItem('content_warning_accepted')) {
cwModal.style.display = 'flex';
- document.body.style.overflow = 'hidden'; // Prevent scrolling on body
- document.documentElement.style.overflow = 'hidden'; // Prevent scrolling on html
+ document.body.classList.add('modal-open');
}
const acceptBtn = document.getElementById('cw-accept');
@@ -1027,8 +1161,7 @@ window.cancelAnimFrame = (function () {
acceptBtn.addEventListener('click', () => {
localStorage.setItem('content_warning_accepted', 'true');
cwModal.style.display = 'none';
- document.body.style.overflow = '';
- document.documentElement.style.overflow = '';
+ document.body.classList.remove('modal-open');
});
}
@@ -1079,7 +1212,7 @@ window.cancelAnimFrame = (function () {
const applyRuffleKeepAlive = () => {
if (ruffleKeepAliveApplied) return;
- console.log("[Ruffle] Registering background keep-alive patches (Browser Level)...");
+ window.f0ckDebug("[Ruffle] Registering background keep-alive patches (Browser Level)...");
try {
const docProto = Object.getPrototypeOf(document);
@@ -1422,6 +1555,11 @@ window.cancelAnimFrame = (function () {
const loadPageAjax = async (url, replace = true, options = {}) => {
if (isNavigating) return;
+
+ // Immediately restore scrollability and hide modals
+ if (window.resetGlobalScrollState) window.resetGlobalScrollState();
+ if (window.hideAllModals) window.hideAllModals();
+
isNavigating = true;
// ── Scroller-active cleanup ──────────────────────────────────────────────
@@ -1793,7 +1931,7 @@ window.cancelAnimFrame = (function () {
document.body.style.height = '';
document.body.style.minHeight = '';
- console.log("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial"));
+ window.f0ckDebug("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial"));
if (window.updateMimeLabel) window.updateMimeLabel();
}
@@ -2287,7 +2425,7 @@ window.cancelAnimFrame = (function () {
m.removeAttribute('src');
m.preload = 'none'; // Prevent further buffering
// m.load(); // Intentionally removed: calling load() with no src can fetch current page URL
- console.log("Media aborted:", m);
+ window.f0ckDebug("Media aborted:", m);
} catch (e) { console.error("Error stopping media:", e); }
});
};
@@ -2309,7 +2447,7 @@ window.cancelAnimFrame = (function () {
try {
const fetchUrl = `/ajax/item/${itemid}?${params.toString()}`;
- console.log(`[updateNavForMode] mode=${mode} itemid=${itemid} → ${fetchUrl}`);
+ window.f0ckDebug(`[updateNavForMode] mode=${mode} itemid=${itemid} → ${fetchUrl}`);
const resp = await fetch(fetchUrl, { credentials: 'include' });
if (!resp.ok) { console.warn('[updateNavForMode] bad response:', resp.status); return; }
const data = await resp.json();
@@ -2326,14 +2464,14 @@ window.cancelAnimFrame = (function () {
if (livePrev && newPrev) {
const h = newPrev.getAttribute('href');
livePrev.setAttribute('href', h);
- console.log(`[updateNavForMode] #prev → ${h}`);
+ window.f0ckDebug(`[updateNavForMode] #prev → ${h}`);
} else {
console.warn(`[updateNavForMode] #prev missing: live=${!!livePrev} new=${!!newPrev}`);
}
if (liveNext && newNext) {
const h = newNext.getAttribute('href');
liveNext.setAttribute('href', h);
- console.log(`[updateNavForMode] #next → ${h}`);
+ window.f0ckDebug(`[updateNavForMode] #next → ${h}`);
} else {
console.warn(`[updateNavForMode] #next missing: live=${!!liveNext} new=${!!newNext}`);
}
@@ -2353,6 +2491,11 @@ window.cancelAnimFrame = (function () {
const loadItemAjax = async (url, inheritContext = true, options = {}) => {
if (isNavigating) return;
+
+ // Immediately restore scrollability and hide modals
+ if (window.resetGlobalScrollState) window.resetGlobalScrollState();
+ if (window.hideAllModals) window.hideAllModals();
+
isNavigating = true;
// ── Scroller-active cleanup (same as loadPageAjax) ───────────────────
@@ -2534,7 +2677,7 @@ window.cancelAnimFrame = (function () {
if (window.randomizeLogo) window.randomizeLogo();
- console.log("Fetching:", ajaxUrl);
+ window.f0ckDebug("Fetching:", ajaxUrl);
const tStart = performance.now();
const response = await fetch(ajaxUrl, { credentials: 'include' });
const tHeaders = performance.now();
@@ -2544,7 +2687,7 @@ window.cancelAnimFrame = (function () {
const rawText = await response.text();
const tBody = performance.now();
- console.log(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}:
+ window.f0ckDebug(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}:
- TTFB (Headers): ${(tHeaders - tStart).toFixed(2)}ms
- Content Download: ${(tBody - tHeaders).toFixed(2)}ms
- Total Network: ${(tBody - tStart).toFixed(2)}ms
@@ -2705,7 +2848,7 @@ window.cancelAnimFrame = (function () {
// Try to extract ID from response if possible or just use itemid
document.title = `${window.f0ckDomain} - ${itemid}`;
if (navbar) navbar.classList.remove("pbwork");
- console.log("AJAX load complete");
+ window.f0ckDebug("AJAX load complete");
// Notify extensions — also triggers CommentSystem init which renders comments
document.dispatchEvent(new Event('f0ck:contentLoaded'));
@@ -2956,8 +3099,10 @@ window.cancelAnimFrame = (function () {
if (window.f0ckSession && window.f0ckSession.logged_in) {
window.showFlash('Already logged in lol', 'error');
} else {
- loadPageAjax(anyLink.href, true);
+ if (pathname === '/login') openModal(loginModal, 'login');
+ else openModal(registerModal);
}
+
return false;
}
@@ -3067,6 +3212,9 @@ window.cancelAnimFrame = (function () {
fileInput.value = '';
if (data.success) {
window.flashMessage(data.msg);
+ if (window.refreshItemThumbnails) {
+ window.refreshItemThumbnails(currentItemId);
+ }
} else {
window.flashError(data.msg || 'Upload failed');
}
@@ -3163,7 +3311,7 @@ window.cancelAnimFrame = (function () {
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
- console.log("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
+ window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
if (isItem) {
loadItemAjax(url, true, { skipPush: true });
@@ -4280,6 +4428,7 @@ window.cancelAnimFrame = (function () {
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
if (window.location.pathname.startsWith('/abyss')) return;
+ e.preventDefault();
toggleModal(!overlay.classList.contains('visible'));
}
});
@@ -4510,6 +4659,7 @@ window.cancelAnimFrame = (function () {
};
window.updateXdBadgeFromScore = (itemId, score) => {
+ if (window.f0ckSession && window.f0ckSession.enable_xd_score === false) return;
updateItemPageXdBadge(itemId, score);
updateThumbXdIndicator(itemId, score);
};
@@ -5143,7 +5293,7 @@ class NotificationSystem {
fetch(`/api/notifications/active?tabId=${this.tabId}`).catch(() => {});
// If SSE is closed, reconnect
if (!this.es || this.es.readyState === 2) {
- console.log("[NotificationSystem] SSE was closed, reconnecting as active tab...");
+ window.f0ckDebug("[NotificationSystem] SSE was closed, reconnecting as active tab...");
this.initSSE();
}
};
@@ -5195,17 +5345,19 @@ class NotificationSystem {
});
}
- console.log("[NotificationSystem] Tab visible, signaling active...");
+ window.f0ckDebug("[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.");
+ window.f0ckDebug("[NotificationSystem] SSE was dead, restarting on tab visible.");
this.retryCount = 0;
this.initSSE();
}
if (this.pollDebounced) this.pollDebounced();
if (this.checkForNewItems) this.checkForNewItems();
- // Catch-up on emojis if they were updated while this tab was pruned/backgrounded
- window.dispatchEvent(new CustomEvent('f0ck:emojis_updated'));
+ // Note: emojis_updated dispatch was removed from here — it caused emoji cache flush +
+ // async re-fetch + re-render that could fight the scroll-position restoration.
+ // Emojis rarely change while a tab is hidden; SSE will deliver a targeted emojis_updated
+ // event if they actually changed.
signalActive();
// Sync display name in case it was changed while this tab was inactive
if (window.f0ckSession?.logged_in) this.syncDisplayName();
@@ -5241,11 +5393,11 @@ class NotificationSystem {
this.es.close();
}
- console.log(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`);
+ window.f0ckDebug(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`);
this.es = new EventSource(`/api/notifications/stream?tabId=${this.tabId}`);
this.es.onopen = () => {
- console.log("[NotificationSystem] SSE connection established");
+ window.f0ckDebug("[NotificationSystem] SSE connection established");
this.retryCount = 0;
document.documentElement.dataset.sseReady = '1';
document.dispatchEvent(new CustomEvent('f0ck:sse_ready'));
@@ -5254,16 +5406,17 @@ class NotificationSystem {
this.es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
- console.log(`[SSE] Received message:`, data.type);
+ window.f0ckDebug(`[SSE] Received message:`, data.type);
if (data.type === 'notify') {
this.pollDebounced();
+ const dnd = window.f0ckSession?.do_not_disturb === true;
// Haptic feedback on mobile (supported: Chrome for Android, not iOS)
- if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
+ if (!dnd && navigator.vibrate) navigator.vibrate([200, 80, 200]);
// Live Grid Highlight
if (data.data && data.data.item_id) {
const itemId = data.data.item_id;
const notifType = data.data.type;
- console.log(`[SSE] Live notification for item ${itemId} (type: ${notifType})`);
+ window.f0ckDebug(`[SSE] Live notification for item ${itemId} (type: ${notifType})`);
// System notifications (deletion, deny, reports) require explicit acknowledgment —
// never auto-mark them as read just because the user is viewing that item.
@@ -5273,7 +5426,7 @@ class NotificationSystem {
// (they are live on the thread, so no need to show a badge/highlight)
const currentPath = window.location.pathname;
if (!isSystemNotif && (currentPath === `/${itemId}` || currentPath === `/${itemId}/`)) {
- console.log(`[SSE] Notification for current item ${itemId} — auto-marking as read`);
+ window.f0ckDebug(`[SSE] Notification for current item ${itemId} — auto-marking as read`);
fetch(`/api/notifications/item/${itemId}/read`, {
method: 'POST',
keepalive: true
@@ -5307,13 +5460,13 @@ class NotificationSystem {
}
this.handleActivity(data.data);
} else if (data.type === 'tags') {
- console.log(`[SSE] Tag update received for item ${data.data?.item_id}`);
+ window.f0ckDebug(`[SSE] Tag update received for item ${data.data?.item_id}`);
this.handleTagsUpdate(data.data);
} else if (data.type === 'favorites') {
- console.log(`[SSE] Favorite update received for item ${data.data?.item_id}`);
+ window.f0ckDebug(`[SSE] Favorite update received for item ${data.data?.item_id}`);
this.handleFavoritesUpdate(data.data);
} else if (data.type === 'comments') {
- console.log(`[SSE] Comment update received:`, data.data);
+ window.f0ckDebug(`[SSE] Comment update received:`, data.data);
if (data.data.type === 'comment') {
// New comment posted — update xD badge from server-authoritative score
if (typeof data.data.xd_score === 'number') {
@@ -5339,21 +5492,26 @@ class NotificationSystem {
window.dispatchEvent(new CustomEvent('f0ck:comment_edited', { detail: data.data }));
}
} else if (data.type === 'emojis_updated') {
- console.log("[SSE] Emojis updated, refreshing caches...");
+ window.f0ckDebug("[SSE] Emojis updated, refreshing caches...");
this.loadEmojis();
window.dispatchEvent(new CustomEvent('f0ck:emojis_updated'));
} else if (data.type === 'motd') {
- console.log(`[SSE] MOTD update received:`, data.data.motd);
+ window.f0ckDebug(`[SSE] MOTD update received:`, data.data.motd);
if (typeof window.updateMotdUI === 'function') {
window.updateMotdUI(data.data.motd);
}
+ } else if (data.type === 'rethumb') {
+ window.f0ckDebug(`[SSE] Rethumb update received for item ${data.data?.item_id}`);
+ if (data.data && data.data.item_id && window.refreshItemThumbnails) {
+ window.refreshItemThumbnails(data.data.item_id);
+ }
} else if (data.type === 'new_item') {
- console.log(`[SSE] New item received:`, data.data);
+ window.f0ckDebug(`[SSE] New item received:`, data.data);
this.handleNewItem(data.data);
} else if (data.type === 'delete_item') {
const delId = data.data?.id;
if (!delId) return;
- console.log(`[SSE] Item deleted: ${delId}`);
+ window.f0ckDebug(`[SSE] Item deleted: ${delId}`);
// Remove from main grid — a.thumb is the anchor, li is its parent card
const thumb = document.querySelector(`a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]`);
@@ -5389,22 +5547,23 @@ class NotificationSystem {
// Remove from sidebar activity if present
document.querySelectorAll(`#sidebar-activity-container [data-item="${delId}"]`).forEach(el => el.remove());
} else if (data.type === 'emojis_updated') {
- console.log(`[SSE] Emoji update event received`);
+ window.f0ckDebug(`[SSE] Emoji update event received`);
if (window.commentSystem && typeof window.commentSystem.loadEmojis === 'function') {
window.commentSystem.loadEmojis();
}
// Global dispatch for other listeners (e.g. Admin Dashboard)
document.dispatchEvent(new Event('f0ck:emojis_updated'));
} else if (data.type === 'warning') {
- console.log(`[SSE] Warning received:`, data.data);
+ window.f0ckDebug(`[SSE] Warning received:`, data.data);
const warningModal = document.getElementById('warning-modal');
if (warningModal) {
document.getElementById('warning-reason').textContent = data.data.reason;
document.getElementById('warning-id').value = data.data.warning_id;
warningModal.style.display = 'flex';
+ document.body.classList.add('modal-open');
}
} else if (data.type === 'private_message') {
- console.log(`[SSE] Private message received from user ${data.data?.sender_id}`);
+ window.f0ckDebug(`[SSE] Private message received from user ${data.data?.sender_id}`);
// Haptic feedback for DMs (distinct double-pulse pattern)
if (navigator.vibrate) navigator.vibrate([120, 60, 120]);
// Dispatch event for messages.js thread live-update
@@ -5432,6 +5591,12 @@ class NotificationSystem {
} else if (data.type === 'profile_update') {
const { display_name, user } = data.data;
this.applyDisplayNameUpdate(display_name, user);
+ // Sync preferences to global session object for client-side gating (like DND)
+ if (window.f0ckSession && data.data.user_id === window.f0ckSession.id) {
+ for (const key in data.data) {
+ if (key !== 'user_id') window.f0ckSession[key] = data.data[key];
+ }
+ }
} else if (data.type === 'global_chat') {
document.dispatchEvent(new CustomEvent('f0ck:global_chat', { detail: data.data }));
} else if (data.type === 'global_chat_clear') {
@@ -5505,14 +5670,14 @@ class NotificationSystem {
// If tab is hidden, don't retry now — visibilitychange will restart SSE when visible again
if (document.hidden) {
- console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
+ window.f0ckDebug("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
return;
}
// Exponential backoff, capped at 30s
const delay = Math.min(Math.pow(2, this.retryCount) * 1000, 30000);
if (this.retryCount < this.maxRetries) {
- console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
+ window.f0ckDebug(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
setTimeout(() => this.initSSE(), delay);
this.retryCount++;
} else {
@@ -5618,7 +5783,7 @@ class NotificationSystem {
// Handle "Mark as Read"
if (link.dataset.id && link.classList.contains('unread')) {
- console.log(`[NotificationSystem] Marking ${link.dataset.id} as read...`);
+ window.f0ckDebug(`[NotificationSystem] Marking ${link.dataset.id} as read...`);
// Fire and forget (keepalive ensures it survives navigation)
fetch(`/api/notifications/${link.dataset.id}/read`, {
method: 'POST',
@@ -5664,6 +5829,10 @@ class NotificationSystem {
if (href && href !== '#') {
e.preventDefault();
+ // Immediately restore scrollability and hide modals for better UX
+ if (window.resetGlobalScrollState) window.resetGlobalScrollState();
+ if (window.hideAllModals) window.hideAllModals();
+
// Close dropdown
if (this.isOpen) this.close();
@@ -5766,7 +5935,7 @@ class NotificationSystem {
if (ids.length === 0) return;
const maxId = Math.max(...ids);
- console.log(`[NotificationSystem] Checking for items newer than ${maxId}...`);
+ window.f0ckDebug(`[NotificationSystem] Checking for items newer than ${maxId}...`);
try {
// Build filters from URL
@@ -5806,7 +5975,7 @@ class NotificationSystem {
const data = await res.json();
if (data.success && data.html) {
- console.log(`[NotificationSystem] Loaded new items for grid.`);
+ window.f0ckDebug(`[NotificationSystem] Loaded new items for grid.`);
const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(data.html);
@@ -5911,7 +6080,7 @@ class NotificationSystem {
tabNotifs.forEach(n => {
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
if (!existing) {
- console.log("[NotificationSystem] Adding new item to history:", n.id);
+ window.f0ckDebug("[NotificationSystem] Adding new item to history:", n.id);
const html = this.renderHistoryItem(n);
const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(html);
@@ -5919,7 +6088,7 @@ class NotificationSystem {
node.classList.add('new-item-fade');
historyContainer.prepend(node);
} else {
- console.log("[NotificationSystem] Item already exists:", n.id);
+ window.f0ckDebug("[NotificationSystem] Item already exists:", n.id);
}
});
}
@@ -6177,10 +6346,10 @@ class NotificationSystem {
const idLink = document.querySelector('.id-link');
const currentId = idLink ? parseInt(idLink.innerText) : null;
- console.log(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
+ window.f0ckDebug(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
if (currentId !== parseInt(data.item_id)) {
- console.log("[NotificationSystem] Item ID mismatch, ignoring update.");
+ window.f0ckDebug("[NotificationSystem] Item ID mismatch, ignoring update.");
return;
}
@@ -6192,11 +6361,11 @@ class NotificationSystem {
// DO NOT re-render if the user is currently typing a new tag (has an active input)
if (tagsContainer.querySelector('input')) {
- console.log("[NotificationSystem] Live Tag Update deferred - User is currently typing.");
+ window.f0ckDebug("[NotificationSystem] Live Tag Update deferred - User is currently typing.");
return;
}
- console.log("[NotificationSystem] Re-rendering tags for item:", data.item_id);
+ window.f0ckDebug("[NotificationSystem] Re-rendering tags for item:", data.item_id);
const inner = tagsContainer.querySelector('.tags-inner') || tagsContainer;
@@ -6216,7 +6385,7 @@ class NotificationSystem {
const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator);
const hasSession = !!window.f0ckSession;
- console.log(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`);
+ window.f0ckDebug(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`);
const fragment = document.createDocumentFragment();
data.tags.forEach(tag => {
@@ -6256,7 +6425,7 @@ class NotificationSystem {
const favsContainer = document.querySelector('#favs');
if (!favsContainer) return;
- console.log("[NotificationSystem] Live Favorite Update for item:", data.item_id);
+ window.f0ckDebug("[NotificationSystem] Live Favorite Update for item:", data.item_id);
// Sync the heart icon for the current user
const currentUser = window.f0ckSession?.user?.toLowerCase();
@@ -6384,7 +6553,7 @@ class NotificationSystem {
// 3. Handle Activity Feed
const activityContainer = document.getElementById('activity-container');
if (activityContainer) {
- console.log("[NotificationSystem] New Activity:", data);
+ window.f0ckDebug("[NotificationSystem] New Activity:", data);
const html = this.renderActivityItem(data);
const temp = document.createElement('div');
temp.innerHTML = Sanitizer.clean(html);
@@ -6965,6 +7134,7 @@ document.addEventListener('DOMContentLoaded', () => {
const intervals = [
{ unit: 'year', seconds: 31536000 },
{ unit: 'month', seconds: 2592000 },
+ { unit: 'week', seconds: 604800 },
{ unit: 'day', seconds: 86400 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
@@ -6975,13 +7145,15 @@ document.addEventListener('DOMContentLoaded', () => {
const count = Math.floor(seconds / interval.seconds);
if (count >= 1) {
try {
+ // Force fallback for custom/unrecognized locales like 'zange'
+ if (lang === 'zange') throw new Error('Force fallback');
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' });
return rtf.format(-count, interval.unit);
} catch (e) {
// Intl not available — fall back to i18n strings
const key = count === 1 ? `timeago_${interval.unit}` : `timeago_${interval.unit}s`;
const tpl = i18n[key] || `{n} ${interval.unit}${count !== 1 ? 's' : ''}`;
- const timeStr = tpl.replace('{n}', count);
+ const timeStr = tpl.replace('{n}', count).replace('{s}', count !== 1 ? 's' : '');
const agoTpl = i18n.timeago_ago || '{t} ago';
return agoTpl.replace('{t}', timeStr);
}
@@ -7307,6 +7479,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportReason.value = '';
reportError.textContent = '';
reportModal.style.display = 'flex';
+ document.body.classList.add('modal-open');
}
const commentBtn = e.target.closest('.report-comment-btn');
@@ -7318,6 +7491,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportReason.value = '';
reportError.textContent = '';
reportModal.style.display = 'flex';
+ document.body.classList.add('modal-open');
}
const userBtn = e.target.closest('.report-user-btn'); // for future
@@ -7329,11 +7503,13 @@ document.addEventListener('DOMContentLoaded', () => {
reportReason.value = '';
reportError.textContent = '';
reportModal.style.display = 'flex';
+ document.body.classList.add('modal-open');
}
// Close logic
if (e.target.matches('#report-cancel')) {
reportModal.style.display = 'none';
+ document.body.classList.remove('modal-open');
}
// Submit logic
@@ -7392,6 +7568,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('warning-reason').textContent = warning.reason;
document.getElementById('warning-id').value = warning.id;
warningModal.style.display = 'flex';
+ document.body.classList.add('modal-open');
}
}
} catch (e) { console.error('Error fetching warnings', e); }
@@ -7414,6 +7591,7 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await res.json();
if (data.success) {
document.getElementById('warning-modal').style.display = 'none';
+ document.body.classList.remove('modal-open');
// Check for more warnings
checkWarnings();
} else {
@@ -8128,6 +8306,7 @@ if (navigator.vibrate) {
const noResults = document.getElementById('metadata-no-results');
modal.style.display = 'flex';
+ document.body.classList.add('modal-open');
loading.style.display = 'block';
resultsCont.style.display = 'none';
error.style.display = 'none';
@@ -8136,6 +8315,7 @@ if (navigator.vibrate) {
const close = () => {
modal.style.display = 'none';
+ document.body.classList.remove('modal-open');
document.removeEventListener('keydown', onEsc);
window.removeEventListener('pjax:start', onNav);
document.removeEventListener('f0ck:contentLoaded', onContentLoaded);
@@ -8365,4 +8545,9 @@ if (navigator.vibrate) {
}
}
});
+ // Ensure any navigation event restores the scroll state
+ window.addEventListener('pjax:start', () => {
+ if (window.resetGlobalScrollState) window.resetGlobalScrollState();
+ if (window.hideAllModals) window.hideAllModals();
+ });
})();
diff --git a/public/s/js/flash_yank.js b/public/s/js/flash_yank.js
index 68ab8ef..e7121eb 100644
--- a/public/s/js/flash_yank.js
+++ b/public/s/js/flash_yank.js
@@ -692,7 +692,7 @@
// firefox mobile check
const isFirefoxMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent);
if (isFirefoxMobile) {
- console.log("Firefox Mobile detected, disabling Flash Yank script.");
+ window.f0ckDebug("Firefox Mobile detected, disabling Flash Yank script.");
return;
}
diff --git a/public/s/js/globalchat.js b/public/s/js/globalchat.js
index 3432c98..6929129 100644
--- a/public/s/js/globalchat.js
+++ b/public/s/js/globalchat.js
@@ -15,7 +15,7 @@
const MAX_VISIBLE_MSGS = 100;
const RATE_LIMIT_MS = 800;
- let isMinimized = localStorage.getItem('f0ck_chat_minimized') === '1';
+ let isMinimized = localStorage.getItem('f0ck_chat_minimized') !== '0';
let isClosed = localStorage.getItem('f0ck_chat_closed') === '1';
let lastSent = 0;
let customEmojis = null; // name → url
@@ -224,6 +224,13 @@
`` +
``;
});
+
+ // 6d.1 Vocaroo URLs → iframe embed
+ const vocarooRegex = /https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^\s]*/gi;
+ html = html.replace(vocarooRegex, (match, vocarooId) => {
+ if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
+ return ``;
+ });
// 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.
@@ -1133,8 +1140,18 @@
panel.style.top = newY + 'px';
};
const onEnd = () => {
+ const curY = parseInt(panel.style.top);
localStorage.setItem('f0ck_chat_float_x', parseInt(panel.style.left));
- localStorage.setItem('f0ck_chat_float_y', parseInt(panel.style.top));
+ localStorage.setItem('f0ck_chat_float_y', curY);
+
+ if (isMinimized) {
+ const topBound = getTopBound();
+ const bottomBound = window.innerHeight;
+ const distToTop = Math.abs(curY - topBound);
+ const distToBottom = Math.abs(bottomBound - (curY + 42));
+ localStorage.setItem('f0ck_chat_anchor_top', distToTop < distToBottom ? '1' : '0');
+ }
+
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseEnd);
document.removeEventListener('touchmove', touchMove);
@@ -1169,28 +1186,59 @@
// Minimize toggle — in float mode anchor to bottom edge in both directions
function toggleMinimized() {
const willExpand = isMinimized; // about to expand
- if (!willExpand && isFloating) {
- // About to minimize: capture bottom edge BEFORE shrinking
+ if (isFloating) {
const r = panel.getBoundingClientRect();
- const curBottom = r.top + r.height;
- setMinimized(true);
- // After CSS applies 42px height, shift top so bottom edge is preserved
- requestAnimationFrame(() => {
- const newTop = Math.min(window.innerHeight - 42, Math.max(getTopBound(), curBottom - 42));
- panel.style.top = newTop + 'px';
- localStorage.setItem('f0ck_chat_float_y', newTop);
- });
- } else {
- setMinimized(!isMinimized);
- if (willExpand && isFloating) {
- // Expanding: shift top UP to maintain bottom edge
+ const topBound = getTopBound();
+ const bottomBound = window.innerHeight;
+
+ if (!willExpand) {
+ // About to minimize: decide anchor based on proximity
+ const distToTop = Math.abs(r.top - topBound);
+ const distToBottom = Math.abs(bottomBound - (r.top + r.height));
+ const anchorTop = distToTop < distToBottom;
+ localStorage.setItem('f0ck_chat_anchor_top', anchorTop ? '1' : '0');
+
+ const curTop = r.top;
+ const curBottom = r.top + r.height;
+ setMinimized(true);
+
requestAnimationFrame(() => {
- const fullH = panel.getBoundingClientRect().height;
- const curTop = parseInt(panel.style.top) || 0;
- const newTop = Math.max(getTopBound(), curTop - (fullH - 42));
+ let newTop;
+ if (anchorTop) {
+ // Anchor to top: keep current top
+ newTop = Math.max(topBound, curTop);
+ } else {
+ // Anchor to bottom: keep current bottom (existing behavior)
+ newTop = Math.min(bottomBound - 42, Math.max(topBound, curBottom - 42));
+ }
panel.style.top = newTop + 'px';
localStorage.setItem('f0ck_chat_float_y', newTop);
});
+ } else {
+ // Expanding: use saved anchor or default to bottom if not set
+ const wasAnchorTop = localStorage.getItem('f0ck_chat_anchor_top') === '1';
+ setMinimized(false);
+
+ requestAnimationFrame(() => {
+ const fullH = panel.getBoundingClientRect().height;
+ const curTop = parseInt(panel.style.top) || 0;
+ let newTop;
+ if (wasAnchorTop) {
+ // Expanding from top anchor: top stays same
+ newTop = Math.max(topBound, curTop);
+ } else {
+ // Expanding from bottom anchor: shift top UP (existing behavior)
+ newTop = Math.max(topBound, curTop - (fullH - 42));
+ }
+ panel.style.top = newTop + 'px';
+ localStorage.setItem('f0ck_chat_float_y', newTop);
+ });
+ }
+ } else {
+ setMinimized(!isMinimized);
+ if (!isMinimized) {
+ // Normal docked expand
+ loadHistory();
}
}
}
@@ -1341,10 +1389,10 @@
});
// ── Online users bar ─────────────────────────────────────────────────
- function renderOnline(users) {
+ function renderOnline(users, guestCount = 0) {
const el = document.getElementById('gchat-online');
if (!el) return;
- if (!users || users.length === 0) {
+ if ((!users || users.length === 0) && guestCount === 0) {
el.innerHTML = '';
el.style.display = 'none';
return;
@@ -1361,11 +1409,16 @@
return `
`;
}).join('');
const extraPill = extra > 0 ? `` : '';
- const countLabel = `${users.length} online`;
+
+ let countText = `${users.length} online`;
+ if (guestCount > 0 && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
+ countText += ` (${guestCount} guests)`;
+ }
+ const countLabel = `${countText}`;
el.innerHTML = `${countLabel}
${avatarHTML}${extraPill}
`;
}
document.addEventListener('f0ck:global_chat_presence', (e) => {
- renderOnline(e.detail?.users || []);
+ renderOnline(e.detail?.users || [], e.detail?.guestCount || 0);
});
// Event delegation: reply + admin delete buttons inside #gchat-messages
diff --git a/public/s/js/messages.js b/public/s/js/messages.js
index d126702..b1a8c49 100644
--- a/public/s/js/messages.js
+++ b/public/s/js/messages.js
@@ -328,6 +328,13 @@ if (window.__dmLoaded) {
currentOtherId = parseInt(thread.dataset.otherId, 10);
myId = parseInt(thread.dataset.myId, 10);
+ // Reset state for a fresh load (essential if key just changed or multiple inits ran)
+ renderedIds.clear();
+ threadMessages = [];
+ latestMsgId = 0;
+ oldestMsgId = null;
+ threadHasMore = false;
+
// Update page title to reflect the conversation
const otherName = thread.dataset.otherName || '';
document.title = otherName ? `DM with ${otherName}` : 'Messages';
@@ -601,7 +608,7 @@ if (window.__dmLoaded) {
// 6. Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
- if (trimmed.startsWith('>')) {
+ if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
return `>${quoteContent}`;
}
@@ -659,6 +666,13 @@ if (window.__dmLoaded) {
html = html.replace(ytEmbedRegex, (match, videoId) => {
return ``;
});
+
+ // 7.5 Vocaroo embed logic
+ const vocarooEmbedRegex = /(?:)?\s*]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>\s*(?:<\/p>)?/gi;
+ html = html.replace(vocarooEmbedRegex, (match, vocarooId) => {
+ if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
+ return ``;
+ });
// 8. Same-site video embed logic
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -1298,6 +1312,8 @@ if (window.__dmLoaded) {
localStorage.removeItem(DM_KEY_VERSION);
_privateKey = null; _publicKeyJwk = null;
await showRecoveryModal();
+ // REFRESH UI after recovery
+ if (typeof initMessagesPage === 'function') await initMessagesPage();
resolve();
};
@@ -1476,6 +1492,10 @@ if (window.__dmLoaded) {
modal.querySelector('#dm-recover-phrase-btn').onclick = async () => {
modal.style.display = 'none';
await showRecoveryModal();
+
+ // REFRESH UI after recovery
+ if (typeof initMessagesPage === 'function') await initMessagesPage();
+
// Refresh status label on re-open
const statusEl = modal.querySelector('#dm-key-status');
if (statusEl) statusEl.textContent = hasKey() ? '✅ Key loaded and backed up.' : '❌ No key found.';
@@ -1494,6 +1514,8 @@ if (window.__dmLoaded) {
const status = await loadOrCreateKeyPair();
uploadPublicKey().catch(() => {});
if (status === 'new') await showSeedSetupModal();
+ // REFRESH UI after regen/setup
+ if (typeof initMessagesPage === 'function') await initMessagesPage();
} catch (e) {
setMsg(msg, '❌ Error: ' + e.message, 'err');
}
@@ -1595,7 +1617,7 @@ if (window.__dmLoaded) {
const thread = document.getElementById('dm-thread');
if (thread && currentOtherId && parseInt(thread.dataset.otherId) === currentOtherId) {
- console.log('[DM] Tab active: catching up on conversation...');
+ window.f0ckDebug('[DM] Tab active: catching up on conversation...');
appendNewMessages(thread).then(() => {
dmFetch('POST', `/api/dm/read/${currentOtherId}`)
.then(() => refreshDmBadge()) // refreshDmBadge clears title via updateDmBadge(0)
@@ -1644,7 +1666,7 @@ if (window.__dmLoaded) {
const thread = document.getElementById('dm-thread');
if (!thread || !threadMessages.length) return;
- console.log('[DM] Emojis ready, re-rendering thread...');
+ window.f0ckDebug('[DM] Emojis ready, re-rendering thread...');
const isAtBottom = (thread.scrollHeight - thread.scrollTop - thread.clientHeight) < 50;
// Clear and re-render from cache
diff --git a/public/s/js/sanitizer.js b/public/s/js/sanitizer.js
index 91d7112..a4abe74 100644
--- a/public/s/js/sanitizer.js
+++ b/public/s/js/sanitizer.js
@@ -3,7 +3,10 @@
* Protects against XSS by stripping disallowed tags and attributes.
*/
class Sanitizer {
- static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'textarea', 'button', 'input', 'label', 'select', 'option', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
+ // F-009 Security: Removed form elements (textarea, button, input, label, select, option)
+ // to prevent phishing via user-generated content (comments, DMs, chat).
+ // Style attribute is kept for admin-authored MOTD content.
+ static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
static ALLOWED_ATTRS = ['class', 'style', 'src', 'href', 'alt', 'title', 'target', 'width', 'height', 'placeholder', 'readonly', 'disabled', 'value', 'name', 'id', 'type', 'data-parent', 'data-id', 'data-username', 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'points', 'x1', 'y1', 'x2', 'y2', 'd', 'transform', 'rx', 'ry', 'x', 'y', 'offset', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule', 'cx', 'cy', 'r', 'fill-opacity', 'stroke-opacity', 'preserveaspectratio', 'vector-effect', 'pointer-events', 'allowfullscreen', 'frameborder', 'allow', 'referrerpolicy', 'rel', 'controls', 'loop', 'muted', 'playsinline', 'preload', 'tooltip', 'flow'];
static DISALLOWED_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
@@ -56,9 +59,11 @@ class Sanitizer {
if (this.DISALLOWED_URL_SCHEMES.some(scheme => val.startsWith(scheme))) {
node.removeAttribute(attr.name);
}
- // Iframes: only allow YouTube embed URLs
+ // Iframes: allow YouTube and Vocaroo embed URLs
if (attrName === 'src' && tagName === 'iframe') {
- if (!val.startsWith('https://www.youtube.com/embed/')) {
+ const isYouTube = val.startsWith('https://www.youtube.com/embed/');
+ const isVocaroo = val.startsWith('https://vocaroo.com/embed/');
+ if (!isYouTube && !isVocaroo) {
node.removeAttribute(attr.name);
}
}
@@ -71,7 +76,14 @@ class Sanitizer {
const styleParts = attr.value.split(';').filter(p => p.trim().length > 0);
const cleanStyles = styleParts.filter(part => {
const prop = part.split(':')[0].trim().toLowerCase();
- return safeStyles.includes(prop);
+ if (!safeStyles.includes(prop)) return false;
+ // F-009 Security: Strip url() from background/background-image
+ // to prevent CSS-based tracking (e.g. background-image: url(https://evil.com/track))
+ const val = part.split(':').slice(1).join(':').trim().toLowerCase();
+ if ((prop === 'background-image' || prop === 'background') && /url\s*\(/i.test(val)) {
+ return false;
+ }
+ return true;
});
if (cleanStyles.length > 0) {
node.setAttribute(attr.name, cleanStyles.join('; '));
diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js
index a1332b8..0655a60 100644
--- a/public/s/js/scroller.js
+++ b/public/s/js/scroller.js
@@ -335,6 +335,7 @@
// Close popup if click outside
document.addEventListener('click', e => {
+ if (!document.body.classList.contains('scroller-active')) return;
if (!volumePopup.contains(e.target) && e.target !== muteBtn && !muteBtn.contains(e.target)) {
volumePopup.classList.remove('open');
}
@@ -369,6 +370,11 @@
closePanel(filterPanel, filterBackdrop);
closePanel(commentsPanel, commentsBackdrop);
closePanel(settingsPanel, settingsBackdrop);
+ if (typeof closeTagBar === 'function') closeTagBar();
+ if (typeof closeSharePanel === 'function') closeSharePanel();
+ if (typeof closeChanPanel === 'function') closeChanPanel();
+ const active = document.activeElement;
+ if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) active.blur();
}
function addSwipeClose(panel, backdrop) {
let startY = 0;
@@ -690,10 +696,22 @@
});
}
- // Spoiler/blur reveal — delegated click on the comments list
+ // Spoiler/blur reveal & context links — delegated click on the comments list
commentsList.addEventListener('click', e => {
const sp = e.target.closest('.scroller-spoiler, .scroller-blur');
if (sp) sp.classList.toggle('revealed');
+
+ const contextLink = e.target.closest('.comment-context-link');
+ if (contextLink) {
+ e.preventDefault();
+ const id = contextLink.dataset.id;
+ const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
+ if (target) {
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ target.classList.add('highlight-comment');
+ setTimeout(() => target.classList.remove('highlight-comment'), 2000);
+ }
+ }
});
// ── Slide activation ──────────────────────────────────────────────────────
@@ -1593,6 +1611,7 @@
is_video: isVideo,
is_image: isImage,
is_audio: false,
+ comment_count: p.replies || 0,
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
};
@@ -1618,6 +1637,7 @@
item.username = m.username;
item.display_name = m.display_name;
item.avatar = m.avatar;
+ if (m.comment_count != null) item.comment_count = m.comment_count;
if (m.rating_class) { item.rating_class = m.rating_class; item.rating_label = m.rating_label; }
}
@@ -1931,10 +1951,38 @@
indicator.style.display = 'flex';
}
if (commentInput) {
+ const quote = `>>${commentId} `;
+ const start = commentInput.selectionStart;
+ const end = commentInput.selectionEnd;
+ const val = commentInput.value;
+ commentInput.value = val.substring(0, start) + quote + val.substring(end);
commentInput.focus();
+ commentInput.selectionStart = commentInput.selectionEnd = start + quote.length;
+ commentInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
+ function quoteComment(id, username) {
+ if (!commentInput) return;
+ const commentEl = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
+ if (!commentEl) return;
+ const contentEl = commentEl.querySelector('.comment-content');
+ if (!contentEl) return;
+
+ const raw = (contentEl.dataset.raw || '').replace(/
/gi, '\n').trim();
+ const lines = raw.split('\n');
+ const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
+
+ const start = commentInput.selectionStart;
+ const end = commentInput.selectionEnd;
+ const val = commentInput.value;
+
+ commentInput.value = val.substring(0, start) + quote + val.substring(end);
+ commentInput.focus();
+ commentInput.selectionStart = commentInput.selectionEnd = start + quote.length;
+ commentInput.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+
function clearReply() {
replyToCommentId = null;
replyToUsername = null;
@@ -1956,19 +2004,28 @@
-
+
`;
- // Wire reply button
+ // Wire buttons
const replyBtn = el.querySelector('.comment-reply-btn');
if (replyBtn) {
replyBtn.addEventListener('click', () => {
setReplyTo(replyBtn.dataset.id, replyBtn.dataset.user);
});
}
+ const quoteBtn = el.querySelector('.comment-quote-btn');
+ if (quoteBtn) {
+ quoteBtn.addEventListener('click', () => {
+ quoteComment(quoteBtn.dataset.id, quoteBtn.dataset.user);
+ });
+ }
return el;
}
@@ -2009,6 +2066,7 @@
const tagBarClose = document.getElementById('tag-bar-close-btn');
function openTagBar(itemId) {
+ closeAllPanels();
tagBarItemId = itemId;
if (!tagBar) return;
tagBar.classList.add('open');
@@ -2022,11 +2080,9 @@
closeSugg();
tagBarItemId = null;
const inp = document.getElementById('scroll-tag-input');
- if (inp) inp.value = '';
+ if (inp) { inp.value = ''; inp.blur(); }
}
if (tagBarClose) tagBarClose.addEventListener('click', closeTagBar);
- // Close on Escape key
- document.addEventListener('keydown', e => { if (e.key === 'Escape' && tagBar?.classList.contains('open')) closeTagBar(); });
// ── Share panel ────────────────────────────────────────────────────────────
const sharePanel = document.getElementById('share-panel');
@@ -2263,11 +2319,13 @@
);
// Escape HTML first, then process line by line
- const escaped = esc(text);
+ const normalized = text.replace(/
/gi, '\n');
+ const escaped = esc(normalized);
const lines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
// Greentext / quote: lines starting with >
- if (trimmed.startsWith('>')) {
+ // Exclude only the numeric context links (>>ID) so they can be handled as interactive links.
+ if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const after = line.substring(line.indexOf('>') + 4);
const withEmoji = after.replace(/:([a-z0-9_]+):/g, (m, name) => {
const url = customEmojis[name];
@@ -2289,9 +2347,19 @@
const url = customEmojis[name];
return url ? `
` : m;
});
+
+ // 3. Replace >>ID patterns with context links
+ out = out.replace(/(? {
+ return ``;
+ });
+
return out;
});
- let html = lines.join('
');
+ let html = lines.map((line, i) => {
+ if (i === lines.length - 1) return line;
+ if (line.includes('scroller-greentext') || line.includes('display:block')) return line;
+ return line + '
';
+ }).join('');
// [spoiler]…[/spoiler] — iterative to handle nesting
const spoilerRe = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
let prev, n = 0;
@@ -2783,12 +2851,12 @@
chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`)
.then(r => r.json())
.then(data => {
- console.log('[CHAN] Find result:', data);
+ window.f0ckDebug('[CHAN] Find result:', data);
if (data.success && data.tid) {
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
applied.order = 'oldest';
unlock4chan();
- console.log('[CHAN] Set externalUrl:', applied.externalUrl);
+ window.f0ckDebug('[CHAN] Set externalUrl:', applied.externalUrl);
}
})
.catch(err => { console.error('[CHAN] Find error:', err); });
@@ -2887,11 +2955,13 @@
// ── Keyboard ──────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
+ if (!document.body.classList.contains('scroller-active')) return;
const active = document.activeElement;
const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
// 'c' toggles comments — only when NOT typing (so it never fires inside the comment input)
if ((e.key === 'c' || e.key === 'C') && !isTyping && !tagBar?.classList.contains('open')) {
+ e.preventDefault();
if (commentsPanel.classList.contains('open')) closeAllPanels();
else if (currentSlide) openComments(currentSlide.dataset.localId || currentSlide.dataset.id);
return;
@@ -2915,10 +2985,10 @@
const idx = currentSlide ? slides.indexOf(currentSlide) : 0;
if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); const n = slides[idx + 1]; if (n) n.scrollIntoView({ behavior: 'smooth' }); }
else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); const p = slides[idx - 1]; if (p) p.scrollIntoView({ behavior: 'smooth' }); }
- else if (e.key === 'm') { isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }
+ else if (e.key === 'm') { e.preventDefault(); isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }
else if (e.key === ' ') { e.preventDefault(); if (currentMedia) currentMedia.paused ? currentMedia.play().catch(() => {}) : currentMedia.pause(); }
- else if (e.key === 'f' || e.key === 'F') { pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }
- else if (e.key === 'g' || e.key === 'G') { if (applied.externalUrl && chanGalleryBtn) toggleGallery(); }
+ else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }
+ else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); if (applied.externalUrl && chanGalleryBtn) toggleGallery(); }
else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
e.stopImmediatePropagation(); // prevent f0ckm.js global 'r' random shortcut from firing
@@ -2982,7 +3052,7 @@
})
.catch(() => {});
}
- else if (e.key === 'l' || e.key === 'L') { if (currentSlide) toggleFav(currentSlide); }
+ else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress upload modal shortcut in abyss
else if (e.key === 'Escape') { window.location.href = '/'; }
@@ -3000,6 +3070,7 @@
let fourCount = 0;
let fourTimer = null;
document.addEventListener('keydown', (e) => {
+ if (!document.body.classList.contains('scroller-active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === '4') {
fourCount++;
@@ -3407,6 +3478,7 @@
// Close on outside click
document.addEventListener('click', (e) => {
+ if (!document.body.classList.contains('scroller-active')) return;
if (sNotifOpen && !sNotifDropdown.contains(e.target) && !sNotifBtn.contains(e.target)) {
sNotifOpen = false;
sNotifDropdown.classList.remove('visible');
diff --git a/public/s/js/settings.js b/public/s/js/settings.js
index 043663b..8d7b589 100644
--- a/public/s/js/settings.js
+++ b/public/s/js/settings.js
@@ -612,6 +612,97 @@
});
}
+ // Comment Display Mode Toggle
+ const commentDisplayModeSelect = document.getElementById('comment_display_mode_select');
+ if (commentDisplayModeSelect) {
+ commentDisplayModeSelect.addEventListener('change', async () => {
+ const mode = parseInt(commentDisplayModeSelect.value, 10);
+ try {
+ const res = await fetch('/api/v2/settings/comment_display_mode', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': window.f0ckSession?.csrf_token
+ },
+ body: JSON.stringify({ mode })
+ });
+ const data = await res.json();
+ if (data.success) {
+ showStatus('Comment display mode updated!', 'success');
+ if (window.f0ckSession) window.f0ckSession.comment_display_mode = mode;
+ } else {
+ alert(data.msg || 'Error saving preference');
+ }
+ } catch (err) {
+ console.error(err);
+ alert('Failed to save preference');
+ }
+ });
+ }
+
+ // Alternative Infobox Toggle (legacy layout only)
+ const alternativeInfoboxToggle = document.getElementById('alternative_infobox_toggle');
+ if (alternativeInfoboxToggle) {
+ alternativeInfoboxToggle.addEventListener('change', async () => {
+ const use_alternative_infobox = alternativeInfoboxToggle.checked;
+ try {
+ const res = await fetch('/api/v2/settings/alternative_infobox', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': window.f0ckSession?.csrf_token
+ },
+ body: new URLSearchParams({ use_alternative_infobox })
+ });
+ const data = await res.json();
+ if (data.success) {
+ showStatus('Infobox preference updated!', 'success');
+ if (window.f0ckSession) window.f0ckSession.use_alternative_infobox = use_alternative_infobox;
+ } else {
+ alert(data.msg || 'Error saving preference');
+ alternativeInfoboxToggle.checked = !use_alternative_infobox;
+ }
+ } catch (err) {
+ console.error(err);
+ alert('Failed to save infobox preference');
+ alternativeInfoboxToggle.checked = !use_alternative_infobox;
+ }
+ });
+ }
+
+ // Notification Preferences Toggles
+ const setupPreferenceToggle = (id, sessionKey) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener('change', async () => {
+ const enabled = el.checked;
+ try {
+ const res = await fetch('/api/v2/settings/notifications', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': window.f0ckSession?.csrf_token
+ },
+ body: new URLSearchParams({ key: sessionKey, value: enabled })
+ });
+ const data = await res.json();
+ if (data.success) {
+ if (window.f0ckSession) window.f0ckSession[sessionKey] = enabled;
+ } else {
+ alert(data.msg || 'Error saving preference');
+ el.checked = !enabled;
+ }
+ } catch (err) {
+ console.error(err);
+ el.checked = !enabled;
+ }
+ });
+ };
+
+ setupPreferenceToggle('chk-receive-system-notifications', 'receive_system_notifications');
+ setupPreferenceToggle('chk-receive-user-notifications', 'receive_user_notifications');
+ setupPreferenceToggle('chk-do-not-disturb', 'do_not_disturb');
+
const wheelToggle = document.getElementById('wheel_nav_toggle');
if (wheelToggle) {
wheelToggle.checked = localStorage.getItem('wheelNavEnabled') === 'true';
@@ -1255,26 +1346,16 @@
}
// ==== Ruffle (Flash) Settings ====
- const ruffleVolumeInput = document.getElementById('ruffle_volume_input');
- const ruffleVolumeVal = document.getElementById('ruffle_volume_val');
const ruffleBackToggle = document.getElementById('ruffle_background_toggle');
const ruffleSaveBtn = document.getElementById('btn-save-ruffle-settings');
const ruffleStatus = document.getElementById('ruffle-settings-status');
- if (ruffleVolumeInput && ruffleVolumeVal) {
- ruffleVolumeInput.addEventListener('input', () => {
- ruffleVolumeVal.textContent = Math.round(ruffleVolumeInput.value * 100) + '%';
- });
- }
- if (ruffleSaveBtn) {
- ruffleSaveBtn.addEventListener('click', async () => {
- const ruffle_volume = parseFloat(ruffleVolumeInput.value);
+
+ if (ruffleBackToggle) {
+ ruffleBackToggle.addEventListener('change', async () => {
const ruffle_background = ruffleBackToggle.checked;
- ruffleSaveBtn.disabled = true;
- ruffleSaveBtn.textContent = i18n.saving || 'Saving...';
-
try {
const res = await fetch('/api/v2/settings/ruffle', {
method: 'PUT',
@@ -1282,13 +1363,12 @@
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
- body: JSON.stringify({ ruffle_volume, ruffle_background })
+ body: JSON.stringify({ ruffle_background })
});
const data = await res.json();
if (data.success) {
- showAccountStatus(ruffleStatus, 'Flash settings updated correctly!', 'success');
+ showAccountStatus(ruffleStatus, 'Flash settings updated!', 'success');
if (window.f0ckSession) {
- window.f0ckSession.ruffle_volume = ruffle_volume;
window.f0ckSession.ruffle_background = ruffle_background;
// Apply to the active Ruffle player if it exists so user doesn't need to refresh
@@ -1296,7 +1376,6 @@
if (ruffleContainer) {
const player = ruffleContainer.querySelector('ruffle-player') || ruffleContainer.querySelector('ruffle-object');
if (player) {
- player.volume = ruffle_volume;
// Ruffle doesn't dynamically toggle pageVisibility well without recreation,
// but we can update the config for subsequent initializations
if (window.RufflePlayer && window.RufflePlayer.config) {
@@ -1308,13 +1387,12 @@
}
} else {
showAccountStatus(ruffleStatus, data.msg || 'Failed to update Flash settings', 'error');
+ ruffleBackToggle.checked = !ruffle_background; // Revert on failure
}
} catch (err) {
console.error(err);
showAccountStatus(ruffleStatus, 'Request failed', 'error');
- } finally {
- ruffleSaveBtn.disabled = false;
- ruffleSaveBtn.textContent = 'Save Flash Settings';
+ ruffleBackToggle.checked = !ruffle_background; // Revert on error
}
});
}
diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js
index f48fcda..f7c6f72 100644
--- a/public/s/js/sidebar-activity.js
+++ b/public/s/js/sidebar-activity.js
@@ -37,7 +37,56 @@
return div.innerHTML;
};
- const renderCommentContent = (content) => {
+ const ytOembedCache = new Map(); // videoId -> meta object
+ const ytOembedPending = new Map(); // videoId -> Promise
+
+ const fetchSidebarYoutubeTitles = async (container) => {
+ const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
+ if (links.length === 0) return;
+
+ for (const link of links) {
+ const videoId = link.dataset.ytId;
+ if (!videoId) continue;
+
+ const titleSpan = link.querySelector('.yt-title');
+ if (!titleSpan || titleSpan.dataset.loaded === 'true') continue;
+
+ let meta = ytOembedCache.get(videoId);
+ if (!meta) {
+ if (ytOembedPending.has(videoId)) {
+ meta = await ytOembedPending.get(videoId);
+ } else {
+ const promise = (async () => {
+ try {
+ const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
+ const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
+ if (r.ok) {
+ const data = await r.json();
+ if (data.success && data.meta) {
+ ytOembedCache.set(videoId, data.meta);
+ return data.meta;
+ }
+ }
+ } catch (e) {}
+ return null;
+ })();
+ ytOembedPending.set(videoId, promise);
+ meta = await promise;
+ ytOembedPending.delete(videoId);
+ }
+ }
+
+ if (meta && meta.title) {
+ titleSpan.textContent = meta.title;
+ } else {
+ // If title fails, just leave it blank or use a generic label
+ titleSpan.textContent = 'YouTube Video';
+ }
+ titleSpan.dataset.loaded = 'true';
+ }
+ };
+
+ const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
// Anti-recursion / Performance safeguard for extremely long comments
@@ -149,7 +198,7 @@
// Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
- if (trimmed.startsWith('>')) {
+ if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
// Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
@@ -166,10 +215,19 @@
// Perform replacements on the single line
let processedLine = line;
+
+ // Handle Mentions
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
const user = g1 || g2;
return `@${user}`;
});
+
+ // Handle Comment Context Links (>>ID)
+ processedLine = processedLine.replace(/(?>(\d+)/g, (match, id) => {
+ const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
+ return ``;
+ });
+
processedLine = processedLine.replace(imageRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
@@ -196,10 +254,25 @@
// YouTube label replacement: show icon + labeled link
md = md.replace(
/]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
- (match) => {
+ (match, videoId) => {
const hrefMatch = match.match(/href="([^"]+)"/i);
- const href = hrefMatch ? hrefMatch[1] : '#';
- return ``;
+ const ytHref = hrefMatch ? hrefMatch[1] : '#';
+ const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : ytHref);
+ const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
+ return ``;
+ }
+ );
+
+ // Vocaroo label replacement
+ md = md.replace(
+ /]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
+ (match, vocarooId) => {
+ if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
+ const hrefMatch = match.match(/href="([^"]+)"/i);
+ const vocaHref = hrefMatch ? hrefMatch[1] : '#';
+ const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : vocaHref);
+ const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
+ return ``;
}
);
@@ -258,6 +331,10 @@
return codeBlocks[index] || '';
});
+ if (window.Sanitizer && typeof window.Sanitizer.clean === 'function') {
+ md = window.Sanitizer.clean(md);
+ }
+
return md;
} catch (e) {
return content;
@@ -269,7 +346,7 @@
const renderActivityItem = (c) => {
const rawContent = c.content || c.body || '';
- const displayContent = renderCommentContent(rawContent);
+ const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
// Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png';
@@ -277,6 +354,7 @@
avatarSrc = `/a/${c.avatar_file}`;
} else if (c.avatar) {
avatarSrc = `/t/${c.avatar}.webp`;
+ if (window.applyThumbCacheBust) avatarSrc = window.applyThumbCacheBust(avatarSrc);
}
const timeStr = c.created_at
@@ -287,7 +365,9 @@
let itemPreview = '';
if (c.item_id) {
let mediaHtml = '';
- mediaHtml = `
`;
+ let thumbUrl = `/t/${c.item_id}.webp`;
+ if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
+ mediaHtml = `
`;
itemPreview = `
@@ -308,7 +388,7 @@
-
+
${itemPreview}
`;
@@ -361,16 +441,13 @@
html += renderActivityItem(c);
});
- if (window.Sanitizer) {
- container.innerHTML = window.Sanitizer.clean(html);
- } else {
- container.innerHTML = html;
- }
+ container.innerHTML = html;
// Re-append IO sentinel so the scroll observer keeps working after re-renders
if (ioSentinel) {
container.appendChild(ioSentinel);
}
checkOverflow();
+ fetchSidebarYoutubeTitles(container);
return true;
};
@@ -462,17 +539,14 @@
newComments.forEach(c => { html += renderActivityItem(c); });
if (html) {
const temp = document.createElement('div');
- if (window.Sanitizer) {
- temp.innerHTML = window.Sanitizer.clean(html);
- } else {
- temp.innerHTML = html;
- }
+ temp.innerHTML = html;
while (temp.firstElementChild) {
container.appendChild(temp.firstElementChild);
}
// Keep the IO sentinel at the very end so it triggers on the next scroll
if (ioSentinel) container.appendChild(ioSentinel);
checkOverflow();
+ fetchSidebarYoutubeTitles(container);
}
} else {
hasMore = false;
@@ -496,7 +570,7 @@
// 1. Deduplicate: check if this comment ID is already in the cache
if (window._sidebarActivityCache.some(c => parseInt(c.id) === parseInt(data.id))) {
- console.log("Sidebar Activity: Duplicate comment ignored", data.id);
+ window.f0ckDebug("Sidebar Activity: Duplicate comment ignored", data.id);
return;
}
@@ -512,16 +586,13 @@
if (container) {
const html = renderActivityItem(newItem);
const temp = document.createElement('div');
- if (window.Sanitizer) {
- temp.innerHTML = window.Sanitizer.clean(html);
- } else {
temp.innerHTML = html;
- }
const node = temp.firstElementChild;
if (node) {
node.classList.add('new-item-fade');
container.prepend(node);
checkOverflow();
+ fetchSidebarYoutubeTitles(container);
}
}
};
@@ -533,7 +604,7 @@
// Listen for live activity from f0ckm.js
document.addEventListener('f0ck:activityReceived', (e) => {
- console.log("Sidebar Activity: Live update received", e.detail);
+ window.f0ckDebug("Sidebar Activity: Live update received", e.detail);
handleNewActivity(e.detail);
});
@@ -555,18 +626,20 @@
if (el) {
const inner = el.querySelector('.comment-content-inner');
if (inner) {
- inner.innerHTML = renderCommentContent(data.content);
+ const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
+ inner.innerHTML = renderCommentContent(data.content, data.comment_id, comment ? comment.item_id : null);
el.classList.remove('new-item-fade');
void el.offsetWidth;
el.classList.add('new-item-fade');
checkOverflow();
+ fetchSidebarYoutubeTitles(el);
}
}
}
};
window.addEventListener('f0ck:comment_edited', (e) => {
- console.log("Sidebar Activity: Live edit received", e.detail);
+ window.f0ckDebug("Sidebar Activity: Live edit received", e.detail);
handleLiveEdit(e.detail);
});
@@ -578,7 +651,7 @@
const modeChanged = lastBoundMode !== null && lastBoundMode !== currentMode;
lastBoundMode = currentMode;
- console.log("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
+ window.f0ckDebug("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
if (modeChanged) {
window._sidebarActivityCache = [];
@@ -600,7 +673,7 @@
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
document.addEventListener('f0ck:modeChanged', (e) => {
- console.log("Sidebar Activity: Mode change detected", e.detail.mode);
+ window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
lastBoundMode = e.detail.mode;
window._sidebarActivityCache = [];
currentPage = 1;
@@ -610,7 +683,7 @@
// When the current user posts a comment, silently refresh sidebar to show it
document.addEventListener('f0ck:commentPosted', () => {
- console.log("Sidebar Activity: Own comment posted, refreshing...");
+ window.f0ckDebug("Sidebar Activity: Own comment posted, refreshing...");
loadActivity(true);
});
diff --git a/public/s/js/upload-common.js b/public/s/js/upload-common.js
index 27a5d27..9f0508f 100644
--- a/public/s/js/upload-common.js
+++ b/public/s/js/upload-common.js
@@ -122,6 +122,17 @@ window.F0ckUpload = class {
try {
const res = JSON.parse(xhr.responseText);
if (res.success) {
+ if (res.itemid) {
+ try {
+ const ts = Date.now();
+ const bustedStr = localStorage.getItem('bustedThumbs');
+ const busted = bustedStr ? JSON.parse(bustedStr) : {};
+ busted[res.itemid] = ts;
+ const keys = Object.keys(busted);
+ if (keys.length > 50) delete busted[keys[0]];
+ localStorage.setItem('bustedThumbs', JSON.stringify(busted));
+ } catch(e) {}
+ }
this.onComplete(res);
resolve(res);
} else {
diff --git a/public/s/js/upload.js b/public/s/js/upload.js
index f628dc2..3324aca 100644
--- a/public/s/js/upload.js
+++ b/public/s/js/upload.js
@@ -40,6 +40,7 @@ window.initUploadForm = (selector) => {
const dragModal = form.closest('#upload-drag-modal');
if (dragModal) {
dragModal.classList.remove('show');
+ document.body.classList.remove('modal-open');
if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') {
form._f0ckUploader.reset();
}
@@ -59,8 +60,7 @@ window.initUploadForm = (selector) => {
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
// Dynamically get min tags requirement from DOM
- const minTagsRaw = tagCount?.textContent.match(/\/(\d+)/);
- const minTags = minTagsRaw ? parseInt(minTagsRaw[1]) : 3;
+ const minTags = parseInt(form.getAttribute('data-min-tags') || '3');
let tags = [];
let autoTags = []; // Track tags suggested from metadata
@@ -465,8 +465,12 @@ window.initUploadForm = (selector) => {
}
if (tagCount) {
- tagCount.textContent = '(' + tags.length + '/' + minTags + ' minimum)';
- tagCount.classList.toggle('valid', tags.length >= minTags);
+ if (minTags > 0) {
+ tagCount.textContent = '(' + tags.length + '/' + minTags + ' minimum)';
+ tagCount.classList.toggle('valid', tags.length >= minTags);
+ } else {
+ tagCount.style.display = 'none';
+ }
}
};
@@ -567,6 +571,10 @@ window.initUploadForm = (selector) => {
previewElem = document.createElement('div');
previewElem.className = 'generic-file-icon swf-preview-icon';
previewElem.innerHTML = '⚡
SWF';
+ } else if (file.type === 'application/pdf' || fileExt === 'pdf') {
+ previewElem = document.createElement('div');
+ previewElem.className = 'generic-file-icon pdf-preview-icon';
+ previewElem.innerHTML = '
';
} else {
previewElem = document.createElement('div');
previewElem.className = 'generic-file-icon';
@@ -951,7 +959,7 @@ window.initUploadForm = (selector) => {
`;
}
- console.log('[UPLOAD] Rendering ' + filtered.length + ' suggestions');
+ window.f0ckDebug('[UPLOAD] Rendering ' + filtered.length + ' suggestions');
if (tagSuggestions) {
tagSuggestions.innerHTML = html;
tagSuggestions.style.display = 'block';
@@ -1087,11 +1095,26 @@ window.initUploadForm = (selector) => {
}
if (dragModal) dragModal.classList.remove('show');
+ if (window.resetGlobalScrollState) window.resetGlobalScrollState();
+ if (window.hideAllModals) window.hideAllModals();
form._f0ckUploader.reset();
if (!dragModal) {
statusDiv.innerHTML = '✓ ' + data.msg;
statusDiv.className = 'upload-status success';
}
+
+ if (data.itemid) {
+ try {
+ const ts = Date.now();
+ const bustedStr = localStorage.getItem('bustedThumbs');
+ const busted = bustedStr ? JSON.parse(bustedStr) : {};
+ busted[data.itemid] = ts;
+ const keys = Object.keys(busted);
+ if (keys.length > 50) delete busted[keys[0]];
+ localStorage.setItem('bustedThumbs', JSON.stringify(busted));
+ } catch(e) {}
+ }
+
if (data.manual_approval && typeof window.showFlash === 'function') {
window.showFlash('Upload awaits approval, please be patient', 'info');
}
@@ -1190,6 +1213,19 @@ window.initUploadForm = (selector) => {
statusDiv.innerHTML = '✓ ' + res.msg;
statusDiv.className = 'upload-status success';
}
+
+ if (res.itemid) {
+ try {
+ const ts = Date.now();
+ const bustedStr = localStorage.getItem('bustedThumbs');
+ const busted = bustedStr ? JSON.parse(bustedStr) : {};
+ busted[res.itemid] = ts;
+ const keys = Object.keys(busted);
+ if (keys.length > 50) delete busted[keys[0]];
+ localStorage.setItem('bustedThumbs', JSON.stringify(busted));
+ } catch(e) {}
+ }
+
if (res.manual_approval && typeof window.showFlash === 'function') {
window.showFlash('Upload awaits approval', 'info');
}
diff --git a/public/s/js/user_comments.js b/public/s/js/user_comments.js
index b7110be..2ca187c 100644
--- a/public/s/js/user_comments.js
+++ b/public/s/js/user_comments.js
@@ -28,7 +28,7 @@ class UserCommentSystem {
if (el && this.container.contains(el)) {
const contentEl = el.querySelector('.comment-content');
if (contentEl) {
- contentEl.innerHTML = this.renderCommentContent(data.content);
+ contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
el.classList.remove('new-item-fade');
void el.offsetWidth;
el.classList.add('new-item-fade');
@@ -77,7 +77,7 @@ class UserCommentSystem {
// Check if this instance is still active
if (!document.body.contains(this.container)) return;
- console.log('Mode changed, reloading comments...');
+ window.f0ckDebug('Mode changed, reloading comments...');
this.container.innerHTML = '';
this.page = 1;
this.finished = false;
@@ -108,7 +108,7 @@ class UserCommentSystem {
this.userColor = json.user.username_color;
}
json.comments.forEach(c => {
- console.log('Raw Comment Content (ID ' + c.id + '):', c.content);
+ window.f0ckDebug('Raw Comment Content (ID ' + c.id + '):', c.content);
const html = this.renderComment(c);
this.container.insertAdjacentHTML('beforeend', html);
});
@@ -134,7 +134,7 @@ class UserCommentSystem {
return match;
}
- renderCommentContent(content) {
+ renderCommentContent(content, itemId = null) {
if (!content) return '';
// Anti-recursion / Performance safeguard for extremely long comments
@@ -194,7 +194,7 @@ class UserCommentSystem {
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
- if (trimmed.startsWith('>')) {
+ if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
return `>${quoteContent}`;
}
@@ -204,6 +204,13 @@ class UserCommentSystem {
if (!line.trim()) return ' ';
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
+
+ // Handle Comment Context Links (>>ID)
+ processedLine = processedLine.replace(/(?>(\d+)/g, (match, id) => {
+ const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
+ return ``;
+ });
+
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/|<\/p>/g, '');
@@ -241,6 +248,10 @@ class UserCommentSystem {
iterations++;
} while (html !== prevMd && iterations < 10);
+ if (window.Sanitizer && typeof Sanitizer.clean === 'function') {
+ html = Sanitizer.clean(html);
+ }
+
return html;
} catch (e) {
console.error('UserCommentSystem Markdown Render Error:', e);
@@ -251,7 +262,7 @@ class UserCommentSystem {
renderComment(c) {
const timeAgo = this.timeAgo(c.created_at);
const fullDate = new Date(c.created_at).toISOString();
- const content = this.renderCommentContent(c.content);
+ const content = this.renderCommentContent(c.content, c.item_id);
// Replicating the structure of comments.js but adapting for the list view
// We add a header indicating which item this comment belongs to
diff --git a/public/s/js/v0ck.js b/public/s/js/v0ck.js
index be409f2..2e9c97c 100644
--- a/public/s/js/v0ck.js
+++ b/public/s/js/v0ck.js
@@ -96,7 +96,7 @@ class v0ck {
if (["video", "audio"].includes(tagName)) {
const parent = elem.parentElement;
if (parent.querySelector('.v0ck_player_controls')) {
- console.log("[v0ck] Player controls already exist, skipping injection and init");
+ window.f0ckDebug("[v0ck] Player controls already exist, skipping injection and init");
return elem; // Return the video element as the constructor result
} else {
parent.classList.add("v0ck", "paused");
@@ -123,7 +123,7 @@ class v0ck {
// Use absolute path for reliable asset loading
const size = elem.getAttribute('data-size');
elem.insertAdjacentHTML("afterend", tpl_player(`/s/img/v0ck.svg`, size));
- console.log("[v0ck] Player initialized for", tagName);
+ window.f0ckDebug("[v0ck] Player initialized for", tagName);
}
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
@@ -430,8 +430,12 @@ class v0ck {
});
}
- // Initialize switch state (defaults to ON if not explicitly 'false')
- if (toggleBgSwitch && localStorage.getItem('background') !== 'false') {
+ // Initialize switch state
+ const bgEnabled = (window.f0ckSession && window.f0ckSession.show_background !== undefined)
+ ? !!window.f0ckSession.show_background
+ : (localStorage.getItem('background') !== 'false');
+
+ if (toggleBgSwitch && bgEnabled) {
toggleBgSwitch.classList.add('active');
}
diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs
index 73fcec3..c512498 100644
--- a/src/inc/lib.mjs
+++ b/src/inc/lib.mjs
@@ -85,9 +85,9 @@ export default new class {
};
genLink(env) {
const link = [];
- if (env.tag) link.push("tag", env.tag);
- if (env.hall) link.push("h", env.hall);
- if (env.user) link.push("user", env.user, env.type ?? 'uploads');
+ if (env.tag) link.push("tag", encodeURIComponent(env.tag));
+ if (env.hall) link.push("h", encodeURIComponent(env.hall));
+ if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
let tmp = link.length === 0 ? '/' : link.join('/');
if (!tmp.endsWith('/'))
diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json
index 23a45f5..9552ee2 100644
--- a/src/inc/locales/de.json
+++ b/src/inc/locales/de.json
@@ -10,7 +10,7 @@
"my_halls": "Meine Hallen",
"favs": "Favoriten",
"admin": "Admin",
- "mod": "mod",
+ "mod": "Mod",
"settings": "Einstellungen",
"logout": "Abmelden",
"notifications": "Nuttis",
@@ -29,7 +29,8 @@
"overview": "Übersicht",
"prev": "zurück",
"next": "weiter",
- "random_nav": "Zufall"
+ "random_nav": "Zufall",
+ "abyss": "Abgrund"
},
"upload": {
"title": "Hochladen",
@@ -124,6 +125,7 @@
"clear": "Löschen",
"preferences": "Einstellungen",
"ui_section": "Benutzeroberfläche",
+ "appearance_section": "Erscheinungsbild",
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
"modern_layout": "Modernes Layout",
"modern_layout_hint": "3-Spalten-Layout",
@@ -141,6 +143,11 @@
"embed_yt_hint": "YouTube-Videos durch eingebettete Videoplayer ersetzen",
"hide_koepfe": "Köpfe ausblenden",
"hide_koepfe_hint": "Die Köpfe deaktivieren",
+ "comment_display_mode": "Kommentar-Anzeigemodus",
+ "comment_display_tree": "Antwort-Baum (Standard)",
+ "comment_display_linear": "Linear / Flach (4chan-Stil)",
+ "comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
+ "forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
"language": "Sprache",
"language_hint": "Seitensprache ändern. Lädt die Seite zur Übernahme neu.",
"language_default": "Standard (Seite)",
@@ -158,10 +165,17 @@
"font_default": "Standard",
"theme": "Thema",
"flash_section": "Flash",
- "flash_volume": "Standard-Flash-Lautstärke",
+
"flash_bg": "Flash im Hintergrund abspielen",
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Tab verlassen wird",
- "save_flash": "Flash-Einstellungen speichern",
+
+ "notifications_section": "Benachrichtigungseinstellungen",
+ "receive_system_notifications": "Systembenachrichtigungen erhalten",
+ "receive_system_notifications_hint": "Benachrichtigungen über Erfolg/Fehler bei Hintergrund-Uploads umschalten. Kritische Moderationsaktionen werden immer angezeigt.",
+ "receive_user_notifications": "Benutzerbenachrichtigungen erhalten",
+ "receive_user_notifications_hint": "Benachrichtigungen für Kommentarantworten, Erwähnungen und Thread-Aktivitäten umschalten.",
+ "do_not_disturb": "Bitte nicht stören",
+ "do_not_disturb_hint": "Unterdrückt einfach alle Benachrichtigungen und haptisches Feedback.",
"content_filters": "Inhaltsfilter",
"min_xd_score": "Mindest-xD-Score",
"min_xd_score_hint": "Nur Beiträge mit mindestens diesem xD-Score anzeigen. Auf 0 setzen zum Deaktivieren.",
diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json
index 890986f..d557c55 100644
--- a/src/inc/locales/en.json
+++ b/src/inc/locales/en.json
@@ -1,18 +1,18 @@
{
"nav": {
- "upload": "upload",
- "meme": "meme",
+ "upload": "Upload",
+ "meme": "Meme",
"halls": "Halls",
"tags": "Tags",
"search": "Search",
"random": "Random",
- "profile": "profile",
+ "profile": "Profile",
"my_halls": "My Halls",
- "favs": "favs",
+ "favs": "Favs",
"admin": "Admin",
- "mod": "mod",
- "settings": "settings",
- "logout": "logout",
+ "mod": "Mod",
+ "settings": "Settings",
+ "logout": "Logout",
"notifications": "Notifications",
"mark_all_read": "Mark all read",
"no_notifications": "No new notifications",
@@ -29,7 +29,8 @@
"overview": "Overview",
"prev": "prev",
"next": "next",
- "random_nav": "random"
+ "random_nav": "random",
+ "abyss": "Abyss"
},
"upload": {
"title": "Upload Content",
@@ -124,6 +125,7 @@
"clear": "Clear",
"preferences": "Preferences",
"ui_section": "User Interface",
+ "appearance_section": "Appearance",
"show_motd": "Show Message of the Day (MOTD)",
"modern_layout": "Modern layout",
"modern_layout_hint": "3 Column Layout",
@@ -141,6 +143,11 @@
"embed_yt_hint": "Replace YouTube links with inline video players",
"hide_koepfe": "Hide Köpfe",
"hide_koepfe_hint": "Disable the Köpfe",
+ "comment_display_mode": "Comment Display Mode",
+ "comment_display_tree": "Reply Tree (Default)",
+ "comment_display_linear": "Linear / Flat (4chan style)",
+ "comment_display_mode_hint": "Choose how you want comments to be displayed.",
+ "forced_mode_notice": "This setting is managed by an administrator.",
"language": "Language",
"language_hint": "Change the site language. Reloads the page to apply.",
"language_default": "Default (site)",
@@ -158,10 +165,17 @@
"font_default": "Default",
"theme": "Theme",
"flash_section": "Flash",
- "flash_volume": "Default Flash Volume",
+
"flash_bg": "Keep Flash Playing in Background",
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
- "save_flash": "Save Flash Settings",
+
+ "notifications_section": "Notification Preferences",
+ "receive_system_notifications": "Receive System Notifications",
+ "receive_system_notifications_hint": "Toggle background upload success/error notifications. Critical mod actions will always be shown.",
+ "receive_user_notifications": "Receive User Notifications",
+ "receive_user_notifications_hint": "Toggle notifications for comment replies, mentions, and thread activity.",
+ "do_not_disturb": "Do Not Disturb",
+ "do_not_disturb_hint": "Simply suppresses all notifications and haptic feedback.",
"content_filters": "Content Filters",
"min_xd_score": "Minimum xD Score",
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
@@ -418,14 +432,14 @@
},
"messages": {
"page_title": "MESSAGES",
- "manage_keys": "🔑 Manage Keys",
+ "manage_keys": "Manage Keys",
"loading": "Loading conversations…",
"decrypting": "Decrypting messages…",
"input_placeholder": "Write a message…",
"send": "Send"
},
"profile": {
- "message_btn": "✉ Message",
+ "message_btn": "Message",
"legacy_record": "Legacy Record – First Upload:",
"joined": "Joined:",
"age_days": "{n} days",
diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json
index 3d12676..cd0038e 100644
--- a/src/inc/locales/nl.json
+++ b/src/inc/locales/nl.json
@@ -29,7 +29,8 @@
"overview": "Overzicht",
"prev": "vorige",
"next": "volgende",
- "random_nav": "willekeurig"
+ "random_nav": "willekeurig",
+ "abyss": "Afgrond"
},
"upload": {
"title": "Inhoud Uploaden",
@@ -124,6 +125,7 @@
"clear": "Wissen",
"preferences": "Voorkeuren",
"ui_section": "Gebruikersinterface",
+ "appearance_section": "Uiterlijk",
"show_motd": "Toon Bericht van de Dag (MOTD)",
"modern_layout": "Moderne layout",
"modern_layout_hint": "Indeling met 3 kolommen",
@@ -141,6 +143,11 @@
"embed_yt_hint": "YouTube-links vervangen door inline videospelers",
"hide_koepfe": "Köpfe verbergen",
"hide_koepfe_hint": "De Köpfe uitschakelen",
+ "comment_display_mode": "Reactie-weergavemodus",
+ "comment_display_tree": "Antwoordboom (Standaard)",
+ "comment_display_linear": "Lineair / Vlak (4chan-stijl)",
+ "comment_display_mode_hint": "Kies hoe je reacties wilt laten weergeven.",
+ "forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
"language": "Taal",
"language_hint": "Wijzig de sitetaal. Pagina wordt herladen om toe te passen.",
"language_default": "Standaard (site)",
@@ -158,10 +165,17 @@
"font_default": "Standaard",
"theme": "Thema",
"flash_section": "Flash",
- "flash_volume": "Standaard Flash Volume",
+
"flash_bg": "Flash in de achtergrond blijven afspelen",
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
- "save_flash": "Flash Instellingen Opslaan",
+
+ "notifications_section": "Meldingvoorkeuren",
+ "receive_system_notifications": "Systeemmeldingen ontvangen",
+ "receive_system_notifications_hint": "Schakel meldingen over succes/fout bij achtergronduploads in/uit. Kritieke moderatie-acties worden altijd getoond.",
+ "receive_user_notifications": "Gebruikersmeldingen ontvangen",
+ "receive_user_notifications_hint": "Schakel meldingen voor reacties, vermeldingen en thread-activiteit in/uit.",
+ "do_not_disturb": "Niet storen",
+ "do_not_disturb_hint": "Onderdrukt gewoon alle meldingen en haptische feedback.",
"content_filters": "Inhoudsfilters",
"min_xd_score": "Minimale xD-score",
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json
index 546a06e..b571acb 100644
--- a/src/inc/locales/zange.json
+++ b/src/inc/locales/zange.json
@@ -9,8 +9,8 @@
"profile": "Profil",
"my_halls": "Meine Hallen",
"favs": "Favoriten",
- "admin": "Administrator",
- "mod": "Moderator",
+ "admin": "Administration",
+ "mod": "Moderation",
"settings": "Einstellungen",
"logout": "Abmeldung",
"notifications": "Hinweise",
@@ -29,7 +29,8 @@
"overview": "Überblick",
"prev": "zurück",
"next": "weiter",
- "random_nav": "Zufall"
+ "random_nav": "Zufall",
+ "abyss": "Abgrund"
},
"upload": {
"title": "Aufladieren",
@@ -124,6 +125,7 @@
"clear": "Leeren",
"preferences": "Präferenzen",
"ui_section": "Benutzeroberfläche",
+ "appearance_section": "Erscheinungsbild",
"show_motd": "Nachricht des Tages (NdT) anzeigen",
"modern_layout": "Modernes Layout",
"modern_layout_hint": "3-Spalten-Layout",
@@ -137,10 +139,15 @@
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
"render_emojis": "Emojis in Zitatantworten darstellen",
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
- "embed_yt": "DuRöhre-Verknüpfungen in Kommentaren einbetten",
- "embed_yt_hint": "Ersetzen Sie DuRöhre-Verknüpfungen durch integrierte Videospieler",
+ "embed_yt": "Röhrenelfen in Kommentaren einbetten",
+ "embed_yt_hint": "Röhrenelfen durch integrierten Röhrenspieler",
"hide_koepfe": "Köpfe verbergen",
"hide_koepfe_hint": "Die Köpfe deaktivieren",
+ "comment_display_mode": "Kommentar-Anzeigemodus",
+ "comment_display_tree": "Antwort-Baum (Standard)",
+ "comment_display_linear": "Linear / Flach (Vierkanal-Stil)",
+ "comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
+ "forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
"language": "Sprache",
"language_hint": "Ändern Sie die Seitensprache. Lädt die Seite neu, um die Änderungen anzuwenden.",
"language_default": "Standard (Seite)",
@@ -150,24 +157,30 @@
"language_zange": "Zangendeutsch",
"scroll_nav": "Mausrad-Navigation auf Elementen",
"scroll_nav_hint": "Navigieren Sie zum nächsten/vorherigen Element durch Scrollen auf dem Medienbereich",
- "username_color": "Benutzerdefinierte Benutzername-Farbe",
+ "username_color": "Namensfarbe",
"username_color_hint": "Wählen Sie eine Farbe oder geben Sie einen Hex-Code für Ihren Benutzernamen auf Elementen und Kommentaren ein.",
"save_color": "Farbe speichern",
"reset": "Zurücksetzen",
"website_font": "Schriftart der Weltnetzpräsenz",
"font_default": "Standard",
"theme": "Thema",
- "flash_section": "Flash",
- "flash_volume": "Standard-Flash-Lautstärke",
- "flash_bg": "Flash im Hintergrund weiterlaufen lassen",
- "flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Reiter verlassen wird",
- "save_flash": "Flash-Einstellungen speichern",
+ "flash_section": "Blitz",
+ "flash_bg": "Blitz im Hintergrund weiterlaufen lassen",
+ "flash_bg_hint": "Verhindert, dass ein Blitz pausiert, wenn der Reiter verlassen wird",
+
+ "notifications_section": "Hinweis-Präferenzen",
+ "receive_system_notifications": "Systemhinweise erhalten",
+ "receive_system_notifications_hint": "Hinweise über Erfolg/Abbruch bei Hintergrund-Aufladierungen umschalten. Kritische Moderations-Eingriffe werden stets kundgetan.",
+ "receive_user_notifications": "Benutzerhinweise erhalten",
+ "receive_user_notifications_hint": "Hinweise für Kommentarantworten, Erwähnungen und Faden-Aktivitäten umschalten.",
+ "do_not_disturb": "Ich wünsche keine Störung",
+ "do_not_disturb_hint": "Unterdrückt jegliche Hinweise und haptisches Beben.",
"content_filters": "Inhaltsfilter",
"min_xd_score": "Minimaler xD-Punktestand",
"min_xd_score_hint": "Zeige nur Pfosten mit mindestens diesem xD-Punktestand an. Auf 0 setzen zum Deaktivieren.",
"save": "Speichern",
"account": "Konto",
- "user_id": "Benutzeridentifikation",
+ "user_id": "ID",
"username": "Benutzername",
"display_name": "Anzeigename",
"display_name_placeholder": "Anzeigename",
@@ -651,4 +664,4 @@
"replying_to": "Antwort an {user}",
"reply": "Antworten"
}
-}
+}
\ No newline at end of file
diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs
index cba45a1..9c099ad 100644
--- a/src/inc/queue.mjs
+++ b/src/inc/queue.mjs
@@ -4,6 +4,7 @@ import fs from "fs";
import db from "./sql.mjs";
import cfg from "./config.mjs";
import path from "path";
+import os from "os";
export default new class queue {
@@ -62,7 +63,12 @@ export default new class queue {
});
}
+ /** @deprecated Use queue.spawn() instead — exec() invokes a shell and is vulnerable to injection if passed user input. */
exec(cmd, options = {}) {
+ if (!this._execDeprecated) {
+ console.warn('[DEPRECATED] queue.exec() is deprecated — use queue.spawn() to avoid shell injection risks');
+ this._execDeprecated = true;
+ }
return new Promise((resolve, reject) => {
_exec(cmd, { maxBuffer: 5e3 * 1024, ...options }, (err, stdout, stderr) => {
if (err) {
@@ -84,6 +90,11 @@ export default new class queue {
// 3. Generate dHash for each.
// 4. Return combined hash "hash1_hash2_hash3".
+ // Skip ffprobe for PDFs (which would fail with "Invalid data")
+ if (source.toLowerCase().endsWith('.pdf')) {
+ return null;
+ }
+
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
const duration = parseFloat(durationStr);
if (isNaN(duration) || duration <= 0) return null;
@@ -239,10 +250,25 @@ export default new class queue {
const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
- const tmpFile = path.join(cfg.paths.tmp, itemid + '.png');
- const tmpJpg = path.join(cfg.paths.tmp, itemid + '.jpg');
+ const tmpFile = path.join(os.tmpdir(), itemid + '.png');
+ const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg');
- if (mime.startsWith('video/') || mime == 'image/gif') {
+ if (mime === 'video/youtube') {
+ const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
+ if (videoId) {
+ const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
+ try {
+ const curlArgs = ['-s', '-L', thumbUrl, '-o', tmpFile];
+ if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
+ curlArgs.push('--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`);
+ }
+ await this.spawn('curl', curlArgs);
+ } catch (err) {
+ console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}:`, err);
+ }
+ }
+ }
+ else if (mime.startsWith('video/') || mime == 'image/gif') {
const seeks = ['20%', '40%', '60%', '80%'];
for (const seek of seeks) {
await this.spawn('ffmpegthumbnailer', ['-i', path.join(bDir, filename), '-s', '1024', '-t', seek, '-o', tmpFile]);
@@ -257,11 +283,14 @@ export default new class queue {
else if (mime.startsWith('audio/')) {
let coverExtracted = false;
if (link.match(/soundcloud/)) {
- let cover = (await this.spawn('yt-dlp', ['-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim();
+ const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : [];
+ let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
if (!cover.match(/default_avatar/)) {
cover = cover.replace(/-(large|original)\./, '-t500x500.');
try {
- await this.spawn('wget', [cover, '-O', tmpJpg]);
+ const curlArgs = ['-s', '-L', cover, '-o', tmpJpg];
+ if (proxyArgs.length > 0) curlArgs.push(...proxyArgs);
+ await this.spawn('curl', curlArgs);
const size = (await fs.promises.stat(tmpJpg)).size;
if (size >= 0) {
await this.spawn('magick', [tmpJpg, tmpFile]);
@@ -306,7 +335,6 @@ export default new class queue {
}
else if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
let customThumb = cfg.websrv.swf_thumb;
- // Resolve web paths (/s/...) to the filesystem (public/s/...)
if (customThumb && customThumb.startsWith('/')) {
customThumb = path.join(path.resolve(), 'public', customThumb);
}
@@ -331,6 +359,32 @@ export default new class queue {
]).catch(() => {});
}
}
+ else if (mime === 'application/pdf') {
+ try {
+ await this.spawn('gs', [
+ '-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=png16m',
+ '-r150',
+ '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
+ '-dLastPage=1',
+ '-sOutputFile=' + tmpFile,
+ path.join(bDir, filename)
+ ]);
+ } catch (err) {
+ console.warn(`[QUEUE] PDF extraction failed for ${itemid}, using fallback icon.`);
+ const pdfFallback = path.join(cfg.paths.s, 'img', 'pdf.webp');
+ await fs.promises.copyFile(pdfFallback, tmpFile).catch(async () => {
+ // If the asset is missing, generate a red PDF-style placeholder matching the user's reference
+ await this.spawn('magick', [
+ '-size', '256x256', 'xc:#d32f2f', // Professional PDF Red
+ '-gravity', 'center',
+ '-fill', 'white',
+ '-pointsize', '60',
+ '-annotate', '0', 'PDF',
+ tmpFile
+ ]).catch(() => {});
+ });
+ }
+ }
await this.spawn('magick', [tmpFile, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, itemid + '.webp')]);
await fs.promises.unlink(tmpFile).catch(_ => { });
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index 601339a..b082848 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -124,13 +124,13 @@ export default {
const actPage = +(page ?? 1);
// Support multiple MIME types (comma separated)
- const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
+ const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
- : db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
+ : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const eps = limit ?? cfg.websrv.eps;
const excludedTags = session && exclude ? (exclude || []) : [];
@@ -155,16 +155,15 @@ export default {
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
- where lower(t.normalized) = ANY(${strictParams}::text[])
+ where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
- having count(distinct lower(t.normalized)) = ${strictParams.length}
+ having count(distinct t.normalized) = ${terms.length}
)`;
} else {
// Non-strict intersection Logic (AND for partials)
// For each term, ensure there is AT LEAST one matching tag assigned to the item
const conditions = terms.map(term => {
- const q = '%' + lib.slugify(term) + '%';
- return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
+ return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
@@ -317,13 +316,13 @@ export default {
}
const mime = (rawMime ?? "");
const itemid = rawItemid ? +rawItemid : null;
- const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
+ const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
- : db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
+ : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const excludedTags = exclude || [];
@@ -351,14 +350,13 @@ export default {
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
- where lower(t.normalized) = ANY(${strictParams}::text[])
+ where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
- having count(distinct lower(t.normalized)) = ${strictParams.length}
+ having count(distinct t.normalized) = ${terms.length}
)`;
} else {
const conditions = terms.map(term => {
- const q = '%' + lib.slugify(term) + '%';
- return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
+ return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
@@ -665,13 +663,13 @@ export default {
}
// Support multiple MIME types (comma separated)
- const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
+ const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
- : db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
+ : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const excludedTags = session && exclude ? (exclude || []) : [];
@@ -714,14 +712,13 @@ export default {
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
- where lower(t.normalized) = ANY(${strictParams}::text[])
+ where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
- having count(distinct lower(t.normalized)) = ${strictParams.length}
+ having count(distinct t.normalized) = ${terms.length}
)`;
} else {
const conditions = terms.map(term => {
- const q = '%' + lib.slugify(term) + '%';
- return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
+ return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
@@ -850,6 +847,28 @@ export default {
return [];
}
},
+ getComment: async (id, process = true) => {
+ if (!id) return null;
+ try {
+ const comment = await db`
+ SELECT
+ c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
+ COALESCE(c.is_pinned, false) as is_pinned,
+ c.video_time,
+ u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
+ FROM comments c
+ JOIN "user" u ON c.user_id = u.id
+ LEFT JOIN user_options uo ON uo.user_id = u.id
+ WHERE c.id = ${id} AND c.is_deleted = false
+ LIMIT 1
+ `;
+ if (!comment.length) return null;
+ return process ? (await processMentions(comment))[0] : comment[0];
+ } catch (e) {
+ console.error('[F0CKLIB] Error fetching comment:', e);
+ return null;
+ }
+ },
getSubscriptionStatus: async (userId, itemId) => {
if (!userId || !itemId) return false;
const tStart = Date.now();
diff --git a/src/inc/routeinc/search.mjs b/src/inc/routeinc/search.mjs
index db9e79a..2b7dbe6 100644
--- a/src/inc/routeinc/search.mjs
+++ b/src/inc/routeinc/search.mjs
@@ -4,27 +4,29 @@ export default (obj, word) => {
return obj.map(tmp => {
let rscore = 0
, startat = 0
- , string = tmp.tag
, cscore
, score;
- for(let i = 0; i < word.length; i++) {
- const idxOf = string.toLowerCase().indexOf(word.toLowerCase()[i], startat);
+ const stringNorm = (tmp.tag || "").normalize('NFC').toLowerCase();
+ const wordNorm = (word || "").normalize('NFC').toLowerCase();
+
+ for(let i = 0; i < wordNorm.length; i++) {
+ const idxOf = stringNorm.indexOf(wordNorm[i], startat);
if(-1 === idxOf)
return { score: 0 };
if(startat === idxOf)
cscore = 0.7;
else {
cscore = 0.1;
- if(string[idxOf - 1] === ' ')
+ if(stringNorm[idxOf - 1] === ' ')
cscore += 0.8;
}
- if(string[idxOf] === word[i])
+ if(stringNorm[idxOf] === wordNorm[i])
cscore += 0.1;
rscore += cscore;
startat = idxOf + 1;
}
- score = 0.5 * (rscore / string.length + rscore / word.length);
- if(word.toLowerCase()[0] === string.toLowerCase()[0] && score < 0.85)
+ score = 0.5 * (rscore / stringNorm.length + rscore / wordNorm.length);
+ if(wordNorm[0] === stringNorm[0] && score < 0.85)
score += 0.15;
return {
diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs
index 97b1d3b..a281ded 100644
--- a/src/inc/routes/admin.mjs
+++ b/src/inc/routes/admin.mjs
@@ -11,7 +11,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
-import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads } from "../settings.mjs";
+import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -84,9 +84,10 @@ export default (router, tpl) => {
const stamp = ~~(Date.now() / 1e3);
+ // F-015: Clean up stale non-KMSI sessions unused for 7 days (on login)
await db`
delete from user_sessions
- where last_action <= ${(Date.now() - 6048e5)}
+ where last_used <= ${stamp - 604800}
and kmsi = 0
`;
@@ -578,7 +579,7 @@ export default (router, tpl) => {
router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => {
const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false';
const registration_open = req.post.registration_open === 'on' ? 'true' : 'false';
- const min_tags = parseInt(req.post.min_tags) || 3;
+ const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags));
const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3);
await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
@@ -622,7 +623,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
WITH filtered_users AS (
SELECT
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
- uo.avatar_file, uo.display_name,
+ uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
@@ -632,7 +633,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
- NULL::text as avatar_file, NULL::varchar as display_name, 'Legacy' as reg_method
+ NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
@@ -761,6 +762,40 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
}
});
+ router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
+ try {
+ const { user_id, mode, lock } = req.post;
+ if (!user_id) throw new Error('Missing user_id');
+
+ const isLocked = lock === true || lock === 'true' || lock === 1;
+ const targetMode = parseInt(mode, 10);
+
+ const updateData = { force_comment_display_mode: isLocked ? 1 : 0 };
+ if (!isNaN(targetMode)) updateData.comment_display_mode = targetMode;
+
+ const result = await db`
+ UPDATE user_options
+ SET ${db(updateData)}
+ WHERE user_id = ${+user_id}
+ RETURNING user_id
+ `;
+
+ if (!result.length) throw new Error('User options not found');
+
+ // Log it in audit
+ await audit.log(req.session.id, isLocked ? 'lock_user_layout' : 'unlock_user_layout', 'user', +user_id, { mode: targetMode });
+
+ return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
+ success: true,
+ msg: 'User layout ' + (isLocked ? 'locked' : 'unlocked') + '.',
+ force_comment_display_mode: isLocked ? 1 : 0,
+ comment_display_mode: targetMode
+ }));
+ } catch (err) {
+ return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
+ }
+ });
+
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
try {
const { user_id } = req.post;
@@ -815,8 +850,11 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
ghostSlugs.add(finalSlug);
if (hall.custom_image) {
- const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${hall.slug}.webp`);
- const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${finalSlug}.webp`);
+ // F-004 Security: Sanitize slugs before constructing file paths
+ const safeSlug = path.basename(hall.slug);
+ const safeFinalSlug = path.basename(finalSlug);
+ const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${safeSlug}.webp`);
+ const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${safeFinalSlug}.webp`);
try {
await fs.rename(oldPath, newPath);
} catch (e) {
@@ -1192,5 +1230,16 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
});
});
+ // Chat Manager
+ router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
+ res.reply({
+ body: tpl.render('admin/chat', {
+ session: req.session,
+ totals: await lib.countf0cks(),
+ tmp: null
+ }, req)
+ });
+ });
+
return router;
-};
+}
diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs
index be68df5..aa50ab2 100644
--- a/src/inc/routes/ajax.mjs
+++ b/src/inc/routes/ajax.mjs
@@ -28,7 +28,7 @@ export default (router, tpl) => {
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
}
- console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
+ if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
@@ -145,7 +145,7 @@ export default (router, tpl) => {
const paginationHtml = tpl.render('snippets/pagination', data, req);
const tAjaxRender = Date.now();
- console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
+ if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
- getf0ck: ${tAjaxFetch - tAjaxStart}ms
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
- Render: ${tAjaxRender - tAjaxAux}ms`);
diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs
index ea7e65c..f8bd87d 100644
--- a/src/inc/routes/apiv2/index.mjs
+++ b/src/inc/routes/apiv2/index.mjs
@@ -11,8 +11,17 @@ import { parseMultipart, collectBody } from '../../multipart.mjs';
const allowedMimes = ["audio", "image", "video", "%"];
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
+const metaCache = new Map();
+const MAX_META_CACHE = 2000;
export default router => {
+ // Ensure cache table exists
+ db`CREATE TABLE IF NOT EXISTS meta_cache (
+ url TEXT PRIMARY KEY,
+ data JSONB,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )`.catch(err => console.error('[META-CACHE] Table creation failed:', err));
+
router.group(/^\/api\/v2/, group => {
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
@@ -282,6 +291,8 @@ export default router => {
}
});
+ // F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
+ // Guests use cached entries from DB (populated by authenticated user requests).
group.get(/\/meta\/fetch$/, lib.loggedin, async (req, res) => {
if (!cfg.websrv.web_meta_extraction) {
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
@@ -290,6 +301,38 @@ export default router => {
const url = req.url.qs.url;
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
+ if (metaCache.has(url)) {
+ return res.json({ success: true, meta: metaCache.get(url) });
+ }
+
+ // Check DB cache for persistence across restarts
+ try {
+ const cached = await db`SELECT data FROM meta_cache WHERE url = ${url} LIMIT 1`;
+ if (cached.length > 0) {
+ const meta = cached[0].data;
+ metaCache.set(url, meta); // update in-memory cache
+ return res.json({ success: true, meta });
+ }
+ } catch (err) {
+ console.error('[META-CACHE] DB lookup failed:', err);
+ }
+
+ const setCache = async (u, m) => {
+ if (!m || !m.title) return;
+ metaCache.set(u, m);
+ if (metaCache.size > MAX_META_CACHE) {
+ const first = metaCache.keys().next().value;
+ metaCache.delete(first);
+ }
+ // Persist to DB
+ try {
+ await db`INSERT INTO meta_cache (url, data) VALUES (${u}, ${m})
+ ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`;
+ } catch (err) {
+ console.error('[META-CACHE] DB save failed:', err);
+ }
+ };
+
if (/\.(mp4|webm|mp3|ogg|opus|flac|m4a|mkv|jpg|jpeg|png|gif|webp|swf)$/i.test(url)) {
return res.json({ success: false, msg: 'Metadata extraction skipped for direct media URLs' }, 400);
}
@@ -314,13 +357,15 @@ export default router => {
if (oembedOut && oembedOut.trim()) {
const data = JSON.parse(oembedOut);
if (data.title) {
+ const meta = {
+ title: data.title,
+ site_name: 'youtube.com',
+ author: data.author_name || 'Unknown'
+ };
+ await setCache(url, meta);
return res.json({
success: true,
- meta: {
- title: data.title,
- site_name: 'youtube.com',
- author: data.author_name || 'Unknown'
- }
+ meta
});
}
}
@@ -354,13 +399,15 @@ export default router => {
}
if (title) {
+ const meta = {
+ title: title,
+ site_name: lines[2] ? lines[2].trim() : 'Media Site',
+ author: lines[1] ? lines[1].trim() : 'Unknown'
+ };
+ await setCache(url, meta);
return res.json({
success: true,
- meta: {
- title: title,
- site_name: lines[2] ? lines[2].trim() : 'Media Site',
- author: lines[1] ? lines[1].trim() : 'Unknown'
- }
+ meta
});
}
} catch (err) {
@@ -402,6 +449,7 @@ export default router => {
return res.json({ success: false, msg: 'Reddit bot protection encountered' }, 403);
}
+ await setCache(url, meta);
return res.json({ success: true, meta });
}
} catch (err) {
@@ -663,7 +711,7 @@ export default router => {
reply.success = true;
reply.suggestions = search(q, searchString);
} catch (err) {
- reply.error = err.msg;
+ reply.error = 'Tag suggestion error';
}
return res.json(reply);
@@ -688,7 +736,7 @@ export default router => {
`;
return res.json({ success: true, suggestions: users });
} catch (err) {
- return res.json({ success: false, error: err.message, suggestions: [] });
+ return res.json({ success: false, error: 'User suggestion error', suggestions: [] });
}
});
diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs
index 51742fa..aa8e02b 100644
--- a/src/inc/routes/apiv2/settings.mjs
+++ b/src/inc/routes/apiv2/settings.mjs
@@ -1,6 +1,8 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
+import fs from 'fs/promises';
+import path from 'path';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -443,6 +445,20 @@ export default router => {
group.put(/\/font/, lib.loggedin, async (req, res) => {
const { font } = req.post;
+ // F-023 Security: Validate font against actual files on disk
+ // The font value is rendered into CSS url() in header.html, so it must be a real filename
+ if (font) {
+ const fontsDir = path.join(path.resolve(), 'public/s/fonts');
+ try {
+ const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
+ if (!available.includes(font)) {
+ return res.json({ success: false, msg: 'Invalid font selection' }, 400);
+ }
+ } catch {
+ return res.json({ success: false, msg: 'Font directory unavailable' }, 500);
+ }
+ }
+
try {
await db`
update user_options
@@ -517,23 +533,25 @@ export default router => {
// Update Ruffle (Flash) preferences
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
- const ruffle_volume = parseFloat(req.post.ruffle_volume);
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
+ const ruffle_volume = req.post.ruffle_volume !== undefined ? parseFloat(req.post.ruffle_volume) : undefined;
- if (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1) {
+ if (ruffle_volume !== undefined && (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1)) {
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
}
try {
+ const updateData = { ruffle_background };
+ if (ruffle_volume !== undefined) updateData.ruffle_volume = ruffle_volume;
+
await db`
update user_options
- set ruffle_volume = ${ruffle_volume},
- ruffle_background = ${ruffle_background}
+ set ${db(updateData)}
where user_id = ${+req.session.id}
`;
if (req.session) {
- req.session.ruffle_volume = ruffle_volume;
req.session.ruffle_background = ruffle_background;
+ if (ruffle_volume !== undefined) req.session.ruffle_volume = ruffle_volume;
}
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
} catch (e) {
@@ -639,6 +657,62 @@ export default router => {
}
});
+ // Update comment display mode preference
+ group.put(/\/comment_display_mode/, lib.loggedin, async (req, res) => {
+ const mode = parseInt(req.post.mode, 10);
+ if (isNaN(mode) || (mode !== 0 && mode !== 1)) {
+ return res.json({ success: false, msg: 'Invalid mode' }, 400);
+ }
+
+ // Check if mode is forced
+ const forced = (await db`select force_comment_display_mode from user_options where user_id = ${+req.session.id}`)[0]?.force_comment_display_mode;
+ if (forced) {
+ return res.json({ success: false, msg: 'Comment layout is locked for your account.' }, 403);
+ }
+
+ try {
+ await db`
+ update user_options
+ set comment_display_mode = ${mode}
+ where user_id = ${+req.session.id}
+ `;
+ if (req.session) req.session.comment_display_mode = mode;
+ return res.json({ success: true, mode }, 200);
+ } catch (e) {
+ console.error('Update comment_display_mode error:', e);
+ return res.json({ success: false, msg: 'Error updating preference' }, 500);
+ }
+ });
+
+ // Update notification preferences (Consolidated Endpoint)
+ group.post('/notifications', lib.loggedin, async (req, res) => {
+ const { key, value } = req.post;
+ const allowedKeys = ['receive_system_notifications', 'receive_user_notifications', 'do_not_disturb'];
+
+ if (!allowedKeys.includes(key)) {
+ return res.json({ success: false, msg: 'Invalid preference key' }, 400);
+ }
+
+ const boolValue = value === true || value === 'true';
+
+ try {
+ await db`
+ update user_options
+ set ${db({ [key]: boolValue }, key)}
+ where user_id = ${+req.session.id}
+ `;
+
+ if (req.session) req.session[key] = boolValue;
+
+ await db`SELECT pg_notify('profile_update', ${JSON.stringify({ user_id: req.session.id, [key]: boolValue })})`;
+
+ return res.json({ success: true, [key]: boolValue }, 200);
+ } catch (e) {
+ console.error(`Update notification preference (${key}) error:`, e);
+ return res.json({ success: false, msg: 'Error updating preference' }, 500);
+ }
+ });
+
return group;
});
diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs
index 136aa1c..94c001d 100644
--- a/src/inc/routes/apiv2/tags.mjs
+++ b/src/inc/routes/apiv2/tags.mjs
@@ -79,7 +79,7 @@ export default router => {
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
return res.json({
success: false,
- msg: isDuplicate ? 'Tag already exists' : err.message,
+ msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
tags: await lib.getTags(postid)
});
}
@@ -124,7 +124,7 @@ export default router => {
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
} catch (err) {
- return res.json({ success: false, msg: err.message });
+ return res.json({ success: false, msg: 'Failed to update rating' });
}
});
diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs
index dee59cf..13cc389 100644
--- a/src/inc/routes/apiv2/upload.mjs
+++ b/src/inc/routes/apiv2/upload.mjs
@@ -54,14 +54,13 @@ import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../se
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
- console.log('[UPLOAD DEBUG] collectBody started');
+ if (cfg.main.development) console.log('[UPLOAD DEBUG] collectBody started');
const chunks = [];
req.on('data', chunk => {
- // console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
chunks.push(chunk);
});
req.on('end', () => {
- console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
+ if (cfg.main.development) console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
resolve(Buffer.concat(chunks));
});
req.on('error', err => {
@@ -71,7 +70,7 @@ const collectBody = (req) => {
// Ensure stream is flowing
if (req.isPaused()) {
- console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
+ if (cfg.main.development) console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
req.resume();
}
});
@@ -230,16 +229,11 @@ export default router => {
// Download YouTube thumbnail as our thumbnail
try {
- const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
- const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
- const tmpThumb = path.join(cfg.paths.tmp, `${itemid}_yt.jpg`);
- await queue.spawn('wget', ['-q', thumbUrl, '-O', tmpThumb]);
- await queue.spawn('magick', [tmpThumb, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, `${itemid}.webp`)]);
- await fs.unlink(tmpThumb).catch(() => {});
+ await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, isApprovalRequired);
} catch (err) {
console.error('[UPLOAD-URL] YouTube thumbnail error:', err);
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
- await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
+ await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
}
// Assign rating tag
@@ -317,8 +311,8 @@ export default router => {
// 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;
+ || msg.match(/status code (\d{3})/i)?.[1]
+ || (msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] !== '537' ? 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
@@ -355,7 +349,7 @@ export default router => {
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath',
'--merge-output-format', 'mp4'
- ])).stdout.trim();
+ ])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
if (isInstagram) throw new Error(sanitizeError(err));
@@ -367,9 +361,10 @@ export default router => {
'--max-filesize', `${maxfilesize / 1024}k`,
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath'
- ])).stdout.trim();
+ ])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err2) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
+ console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
let referer = url;
try {
@@ -380,7 +375,7 @@ export default router => {
} catch (e) {}
const curlArgs = [
- '-s', '-f', '-L', url, '-o', fallbackTmp,
+ '-s', '-S', '-f', '-L', url, '-o', fallbackTmp,
'--max-filesize', `${maxfilesize}`,
'--connect-timeout', '30',
'--max-time', '300',
diff --git a/src/inc/routes/chat.mjs b/src/inc/routes/chat.mjs
index c14d7e4..47bac63 100644
--- a/src/inc/routes/chat.mjs
+++ b/src/inc/routes/chat.mjs
@@ -84,6 +84,10 @@ export default (router, tpl) => {
if (!req.session) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
}
+ // F-007 Security: Block banned users from chatting
+ if (req.session.banned) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'You are banned' }) });
+ }
const message = (req.post?.message || '').trim();
if (!message || message.length > MAX_MSG_LEN) {
diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs
index 41a8aeb..11e354c 100644
--- a/src/inc/routes/comments.mjs
+++ b/src/inc/routes/comments.mjs
@@ -61,6 +61,43 @@ export default (router, tpl) => {
}
});
+ // Get a single comment by ID
+ router.get(/\/api\/comment\/(?\d+)/, async (req, res) => {
+ const id = req.params.id;
+
+ // Require login unless comments are public
+ if (!req.session && cfg.main.hide_comments_from_public) {
+ return res.reply({
+ code: 401,
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ body: JSON.stringify({ success: false, message: "Unauthorized" })
+ });
+ }
+
+ try {
+ const comment = await f0cklib.getComment(id);
+ if (!comment) {
+ return res.reply({
+ code: 404,
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ body: JSON.stringify({ success: false, message: "Comment not found" })
+ });
+ }
+
+ return res.reply({
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ body: JSON.stringify({ success: true, comment })
+ });
+ } catch (err) {
+ console.error(err);
+ return res.reply({
+ code: 500,
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ body: JSON.stringify({ success: false, message: "Database error" })
+ });
+ }
+ });
+
// Browse User Comments
router.get(/\/user\/(?[^\/]+)\/comments/, async (req, res) => {
const user = decodeURIComponent(req.params.user);
@@ -207,7 +244,7 @@ export default (router, tpl) => {
}
}
- console.log("DEBUG: POST /api/comments");
+ if (cfg.main.development) console.log("DEBUG: POST /api/comments");
// Use standard framework parsing
const body = req.post || {};
@@ -218,7 +255,7 @@ export default (router, tpl) => {
? parseFloat(body.video_time)
: null;
- console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
+ if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
@@ -444,7 +481,7 @@ export default (router, tpl) => {
router.post(/\/api\/comments\/(?\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const commentId = req.params.id;
- console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
+ if (cfg.main.development) console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
try {
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs
index 3f1191d..b20ff34 100644
--- a/src/inc/routes/emojis.mjs
+++ b/src/inc/routes/emojis.mjs
@@ -32,6 +32,11 @@ export default (router, tpl) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
+ // F-031 Security: CSRF validation for destructive admin action
+ const csrfToken = req.headers['x-csrf-token'];
+ if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
+ }
const id = req.params.id;
try {
diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs
index 0de360a..63e4391 100644
--- a/src/inc/routes/external.mjs
+++ b/src/inc/routes/external.mjs
@@ -11,6 +11,34 @@ import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
*/
export default (router) => {
+ // --- F-001 Security: Per-user rate limiter for proxy routes ---
+ const proxyRateMap = new Map();
+ const PROXY_RATE_LIMIT = 5000; // max requests per window
+ const PROXY_RATE_WINDOW = 600000; // 10 minute window
+ const proxyRateLimit = (req, res) => {
+ if (!req.session) return true; // loggedin middleware handles auth; this is just a guard
+ const key = req.session.id;
+ const now = Date.now();
+ let entry = proxyRateMap.get(key);
+ if (!entry || now - entry.start > PROXY_RATE_WINDOW) {
+ entry = { start: now, count: 0 };
+ proxyRateMap.set(key, entry);
+ }
+ entry.count++;
+ if (entry.count > PROXY_RATE_LIMIT) {
+ res.reply({ code: 429, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Rate limit exceeded' }) });
+ return false;
+ }
+ return true;
+ };
+ // Periodic cleanup to prevent memory leak
+ setInterval(() => {
+ const now = Date.now();
+ for (const [k, v] of proxyRateMap) {
+ if (now - v.start > PROXY_RATE_WINDOW * 2) proxyRateMap.delete(k);
+ }
+ }, PROXY_RATE_WINDOW * 2);
+
/**
* 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.
@@ -39,7 +67,8 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/:tid
// Proxies 4chan thread JSON
- router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/(?\d+)\/?$/, async (req, res) => {
+ router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/(?\d+)\/?$/, lib.loggedin, async (req, res) => {
+ if (!proxyRateLimit(req, res)) return;
const { board, tid } = req.params || {};
if (!board || !tid) {
@@ -84,7 +113,7 @@ export default (router) => {
// 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) => {
+ router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, lib.loggedin, 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: '{}' });
@@ -96,7 +125,8 @@ export default (router) => {
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
+ ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id,
+ (SELECT COUNT(*) FROM comments WHERE comments.item_id = i.id AND comments.is_deleted = false) AS comment_count
FROM items i
LEFT JOIN "user" u ON u."user" = i.username
LEFT JOIN user_options uo ON uo.user_id = u.id
@@ -113,7 +143,8 @@ export default (router) => {
avatar: r.avatar_file ? `/a/${r.avatar_file}` : (r.avatar ? `/t/${r.avatar}.webp` : '/a/default.png'),
stamp: r.stamp,
rating_class,
- rating_label
+ rating_label,
+ comment_count: +r.comment_count || 0
};
});
return res.reply({
@@ -128,7 +159,8 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/catalog
// Proxies 4chan board catalog JSON
- router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/catalog\/?$/, async (req, res) => {
+ router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => {
+ if (!proxyRateLimit(req, res)) return;
const { board } = req.params || {};
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
@@ -165,7 +197,8 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/find/:postno
// Resolves a post number to its parent thread ID
- router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/find\/(?\d+)\/?$/, async (req, res) => {
+ router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/find\/(?\d+)\/?$/, lib.loggedin, async (req, res) => {
+ if (!proxyRateLimit(req, res)) return;
const { board, postno } = req.params || {};
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
@@ -223,11 +256,25 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/media/:file
// Proxies 4chan media — streams directly to client for fast playback start
- router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/media\/(?[^/]+)$/, async (req, res) => {
- const { board, file } = req.params || {};
- const url = `https://i.4cdn.org/${board}/${file}`;
+ // F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy)
+ const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4'];
- const ext = file.split('.').pop();
+ router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?[a-z0-9]+)\/media\/(?[^/]+)$/, lib.loggedin, async (req, res) => {
+ if (!proxyRateLimit(req, res)) return;
+ const { board, file } = req.params || {};
+
+ // Validate file extension against whitelist
+ const ext = (file.split('.').pop() || '').toLowerCase();
+ if (!ALLOWED_MEDIA_EXTS.includes(ext)) {
+ return res.reply({ code: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Disallowed file type' }) });
+ }
+
+ // Validate filename doesn't contain path traversal
+ if (file.includes('..') || file.includes('/') || file.includes('\\')) {
+ return res.reply({ code: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Invalid filename' }) });
+ }
+
+ const url = `https://i.4cdn.org/${board}/${file}`;
const mimes = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp',
@@ -275,6 +322,13 @@ export default (router) => {
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' }) });
+
+ // F-014 Security: Restrict rehost to 4chan media URLs only
+ const is4chanUrl = /^https?:\/\/(i\.4cdn\.org|boards\.4cdn\.org)\//i.test(url)
+ || /\/api\/v2\/scroller\/external\/4chan\/[a-z0-9]+\/media\//i.test(url);
+ if (!is4chanUrl) {
+ return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Only 4chan media URLs are supported for rehosting' }) });
+ }
const board = url.match(/boards\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|| url.match(/i\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
@@ -327,6 +381,11 @@ export default (router) => {
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(finalTmp).catch(() => {});
+ // Auto-subscribe user to the existing item they attempted to rehost
+ try {
+ await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
+ } catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
+
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
@@ -342,6 +401,11 @@ export default (router) => {
const phashMatch = await queue.checkrepostphash(phash);
if (phashMatch) {
await fs.unlink(finalTmp).catch(() => {});
+ // Auto-subscribe user to the existing item they attempted to rehost (visual match)
+ try {
+ await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
+ } catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
+
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
@@ -377,6 +441,11 @@ export default (router) => {
RETURNING id
`;
+ // Automatically subscribe user to the new item
+ try {
+ await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
+ } catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
+
// Process thumbnail
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
@@ -458,7 +527,7 @@ export default (router) => {
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ success: false, msg: err.message })
+ body: JSON.stringify({ success: false, msg: 'Rehost failed' })
});
}
});
diff --git a/src/inc/routes/halls.mjs b/src/inc/routes/halls.mjs
index 0cfffd0..79392d6 100644
--- a/src/inc/routes/halls.mjs
+++ b/src/inc/routes/halls.mjs
@@ -40,7 +40,7 @@ export default (router, tpl) => {
// Hall Thumbnail Route
router.get(/^\/hall_image\/(?.+)$/, async (req, res) => {
- const hallSlug = decodeURIComponent(req.params.hallSlug);
+ const hallSlug = path.basename(decodeURIComponent(req.params.hallSlug));
const mode = +(req.url.qs?.m ?? 0);
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs
index bbcb3ae..b518dff 100644
--- a/src/inc/routes/index.mjs
+++ b/src/inc/routes/index.mjs
@@ -241,8 +241,8 @@ export default (router, tpl) => {
data.total = 0;
data.success = true;
if (!data.link) {
- if (req.params.hall) data.link = { main: '/h/' + req.params.hall + '/', path: 'p/', suffix: '' };
- else if (req.params.tag) data.link = { main: '/tag/' + req.params.tag + '/', path: 'p/', suffix: '' };
+ if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' };
+ else if (req.params.tag) data.link = { main: '/tag/' + encodeURIComponent(req.params.tag) + '/', path: 'p/', suffix: '' };
else data.link = { main: '/', path: 'p/', suffix: '' };
}
data.tmp = data.tmp || {};
diff --git a/src/inc/routes/meme-manager.mjs b/src/inc/routes/meme-manager.mjs
index c2a92de..081d3a7 100644
--- a/src/inc/routes/meme-manager.mjs
+++ b/src/inc/routes/meme-manager.mjs
@@ -35,6 +35,11 @@ export default (router, tpl) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
+ // F-031 Security: CSRF validation for destructive admin action
+ const csrfToken = req.headers['x-csrf-token'];
+ if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
+ }
const id = req.params.id;
try {
diff --git a/src/inc/routes/mod.mjs b/src/inc/routes/mod.mjs
index ef7abd0..10f3238 100644
--- a/src/inc/routes/mod.mjs
+++ b/src/inc/routes/mod.mjs
@@ -38,170 +38,8 @@ export default (router, tpl) => {
});
});
- // Approval Queue (Ported/Shared from Admin)
+ // Approval Queue (View only — GET is safe, no state change)
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
- // Quick Approve Action
- if (req.url.qs?.id) {
- const id = +req.url.qs.id;
- const f0ck = await db`
- select i.dest, i.mime, i.username, i.id, ta.tag_id
- from "items" i
- left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
- where i.id = ${id} and i.active = false
- limit 1
- `;
-
- if (f0ck.length === 0) {
- return res.reply({ body: `f0ck ${id}: f0ck not found` });
- }
-
- // Fetch uploader details for audit log
- let uploaderInfo = {};
- try {
- const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
- if (uploader.length > 0) {
- uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
- }
- } catch (err) { }
-
- // ACTION: Approve
- // We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
- // This prevents duplicate webhooks from double-clicks or race conditions.
- const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
-
- if (result.count === 1) {
- await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
-
- // Notify User (WebSocket/Internal)
- try {
- const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
- if (uploader.length > 0) {
- await db`
- INSERT INTO notifications (user_id, type, reference_id, item_id)
- VALUES (${uploader[0].id}, 'approve', 0, ${id})
- `;
- }
- } catch (err) {
- console.error('[MOD APPROVE] Failed to notify user:', err);
- }
-
- // Push to Discord Webhook (Direct)
- try {
- const discordClient = cfg.clients.find(c => c.type === 'discord');
- if (discordClient && discordClient.webhook_url) {
- const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
- const payload = JSON.stringify({ content: message });
- const url = new URL(discordClient.webhook_url);
- const options = {
- hostname: url.hostname,
- path: url.pathname + url.search,
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(payload)
- }
- };
- const reqDiscord = https.request(options, (resDiscord) => {
- if (resDiscord.statusCode >= 400) {
- console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
- }
- });
- reqDiscord.on('error', (err) => {
- console.error('[MOD APPROVE] Webhook failed:', err);
- });
- reqDiscord.write(payload);
- reqDiscord.end();
- }
- } catch (err) {
- console.error('[MOD APPROVE] Discord Webhook error:', err);
- }
-
- // Push to Matrix Channel
- try {
- const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
- if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
- const clients = await Promise.all(router.self.bot.clients);
- const matrixWrapper = clients.find(c => c.type === 'matrix');
- if (matrixWrapper?.client) {
- const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
- await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
- console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
- }
- }
- } catch (err) {
- console.error('[MOD APPROVE] Matrix notification error:', err);
- }
-
- // Broadcast new_item event for live grid updates
- try {
- await db`SELECT pg_notify('new_item', ${JSON.stringify({
- id: id,
- dest: f0ck[0].dest,
- mime: f0ck[0].mime,
- username: f0ck[0].username,
- tag_id: f0ck[0].tag_id,
- is_oc: !!f0ck[0].is_oc
- })})`;
- } catch (err) {
- console.error('[MOD APPROVE] new_item notify failed:', err);
- }
- }
-
- // Move files to public location
- const movePaths = [
- { b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
- { b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
- ];
-
- for (const p of movePaths) {
- try {
- await fs.access(p.b);
- console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
-
- const moveSafe = async (src, dst) => {
- try {
- const lstat = await fs.lstat(src);
- if (lstat.isSymbolicLink()) {
- const target = await fs.readlink(src);
- const absTarget = path.resolve(path.dirname(src), target);
- const relTarget = path.relative(path.dirname(dst), absTarget);
- await fs.symlink(relTarget, dst);
- await fs.unlink(src).catch(() => {});
- } else {
- await fs.copyFile(src, dst);
- await fs.unlink(src).catch(() => {});
- }
- } catch (e) {
- console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
- }
- };
-
- const bDst = path.join(cfg.paths.b, f0ck[0].dest);
- const tDst = path.join(cfg.paths.t, `${id}.webp`);
- const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
- const caDst = path.join(cfg.paths.ca, `${id}.webp`);
-
- await moveSafe(p.b, bDst);
- await moveSafe(p.t, tDst);
-
- const blurSrc = p.t.replace('.webp', '_blur.webp');
- await moveSafe(blurSrc, blurDst);
-
- if (f0ck[0].mime.startsWith('audio')) {
- await moveSafe(p.ca, caDst);
- }
- break;
- } catch (e) { }
- }
-
- if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
- const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
- return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
- }
-
- return res.writeHead(302, { "Location": `/${id}` }).end();
- }
-
// View Queue
const page = +req.url.qs.page || 1;
const limit = 20;
@@ -267,10 +105,190 @@ export default (router, tpl) => {
});
});
- // Deny / Delete Item
- router.get(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
- if (!req.url.qs?.id) return res.reply({ success: false, msg: "No ID provided" });
- const id = +req.url.qs.id;
+ // F-005 Security: Approve action — POST with CSRF protection
+ router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
+ const id = +(req.post?.id || 0);
+ if (!id) {
+ const body = JSON.stringify({ success: false, msg: 'No ID provided' });
+ return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
+ }
+
+ const f0ck = await db`
+ select i.dest, i.mime, i.username, i.id, ta.tag_id
+ from "items" i
+ left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
+ where i.id = ${id} and i.active = false
+ limit 1
+ `;
+
+ if (f0ck.length === 0) {
+ const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
+ return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
+ }
+
+ // Fetch uploader details for audit log
+ let uploaderInfo = {};
+ try {
+ const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
+ if (uploader.length > 0) {
+ uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
+ }
+ } catch (err) { }
+
+ // ACTION: Approve
+ // We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
+ // This prevents duplicate webhooks from double-clicks or race conditions.
+ const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
+
+ if (result.count === 1) {
+ await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
+
+ // Notify User (WebSocket/Internal)
+ try {
+ const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
+ if (uploader.length > 0) {
+ await db`
+ INSERT INTO notifications (user_id, type, reference_id, item_id)
+ VALUES (${uploader[0].id}, 'approve', 0, ${id})
+ `;
+ }
+ } catch (err) {
+ console.error('[MOD APPROVE] Failed to notify user:', err);
+ }
+
+ // Push to Discord Webhook (Direct)
+ try {
+ const discordClient = cfg.clients.find(c => c.type === 'discord');
+ if (discordClient && discordClient.webhook_url) {
+ const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
+ const payload = JSON.stringify({ content: message });
+ const url = new URL(discordClient.webhook_url);
+ const options = {
+ hostname: url.hostname,
+ path: url.pathname + url.search,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(payload)
+ }
+ };
+ const reqDiscord = https.request(options, (resDiscord) => {
+ if (resDiscord.statusCode >= 400) {
+ console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
+ }
+ });
+ reqDiscord.on('error', (err) => {
+ console.error('[MOD APPROVE] Webhook failed:', err);
+ });
+ reqDiscord.write(payload);
+ reqDiscord.end();
+ }
+ } catch (err) {
+ console.error('[MOD APPROVE] Discord Webhook error:', err);
+ }
+
+ // Push to Matrix Channel
+ try {
+ const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
+ if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
+ const clients = await Promise.all(router.self.bot.clients);
+ const matrixWrapper = clients.find(c => c.type === 'matrix');
+ if (matrixWrapper?.client) {
+ const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
+ await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
+ console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
+ }
+ }
+ } catch (err) {
+ console.error('[MOD APPROVE] Matrix notification error:', err);
+ }
+
+ // Broadcast new_item event for live grid updates
+ try {
+ await db`SELECT pg_notify('new_item', ${JSON.stringify({
+ id: id,
+ dest: f0ck[0].dest,
+ mime: f0ck[0].mime,
+ username: f0ck[0].username,
+ tag_id: f0ck[0].tag_id,
+ is_oc: !!f0ck[0].is_oc
+ })})`;
+ } catch (err) {
+ console.error('[MOD APPROVE] new_item notify failed:', err);
+ }
+ }
+
+ // Move files to public location
+ const movePaths = [
+ { b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
+ { b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
+ ];
+
+ const isYouTube = f0ck[0].mime === 'video/youtube';
+ for (const p of movePaths) {
+ try {
+ if (isYouTube) {
+ await fs.access(p.t);
+ } else {
+ await fs.access(p.b);
+ }
+ console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
+
+ const moveSafe = async (src, dst) => {
+ try {
+ const lstat = await fs.lstat(src);
+ if (lstat.isSymbolicLink()) {
+ const target = await fs.readlink(src);
+ const absTarget = path.resolve(path.dirname(src), target);
+ const relTarget = path.relative(path.dirname(dst), absTarget);
+ await fs.symlink(relTarget, dst);
+ await fs.unlink(src).catch(() => {});
+ } else {
+ await fs.copyFile(src, dst);
+ await fs.unlink(src).catch(() => {});
+ }
+ } catch (e) {
+ if (e.code !== 'ENOENT') {
+ console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
+ }
+ }
+ };
+
+ const bDst = path.join(cfg.paths.b, f0ck[0].dest);
+ const tDst = path.join(cfg.paths.t, `${id}.webp`);
+ const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
+ const caDst = path.join(cfg.paths.ca, `${id}.webp`);
+
+ if (!isYouTube) {
+ await moveSafe(p.b, bDst);
+ }
+ await moveSafe(p.t, tDst);
+
+ const blurSrc = p.t.replace('.webp', '_blur.webp');
+ await moveSafe(blurSrc, blurDst);
+
+ if (f0ck[0].mime.startsWith('audio')) {
+ await moveSafe(p.ca, caDst);
+ }
+ break;
+ } catch (e) { }
+ }
+
+ if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
+ const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
+ return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
+ }
+
+ return res.writeHead(302, { "Location": `/${id}` }).end();
+ });
+
+ // F-005 Security: Deny action — POST with CSRF protection
+ router.post(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
+ const id = +(req.post?.id || 0);
+ if (!id) {
+ const body = JSON.stringify({ success: false, msg: 'No ID provided' });
+ return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
+ }
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${id} limit 1`;
if (f0ck.length > 0) {
@@ -339,7 +357,7 @@ export default (router, tpl) => {
} catch (e) { }
}
- const reason = req.url.qs?.reason || "Denied by moderator";
+ const reason = req.post?.reason || "Denied by moderator";
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
@@ -541,8 +559,14 @@ export default (router, tpl) => {
// Supports /mod/pending/b/filename.ext (Binaries)
// Supports /mod/pending/t/id.webp (Thumbnails)
router.get(/^\/mod\/pending\/(?[btca])\/(?.+)/, lib.modAuth, async (req, res) => {
- const { type, file } = req.params;
- const filePath = path.join(cfg.paths.pending, type, file);
+ const { type } = req.params;
+ // F-003 Security: Sanitize file parameter to prevent path traversal
+ const file = path.basename(req.params.file);
+ const baseDir = path.resolve(cfg.paths.pending, type);
+ const filePath = path.resolve(baseDir, file);
+ if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
+ return res.writeHead(403).end('Forbidden');
+ }
try {
const stats = await fs.stat(filePath);
@@ -552,7 +576,8 @@ export default (router, tpl) => {
const mimeType = {
'mp4': 'video/mp4', 'webm': 'video/webm',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
- 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
+ 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp',
+ 'pdf': 'application/pdf'
}[ext] || 'application/octet-stream';
if (range) {
@@ -577,7 +602,7 @@ export default (router, tpl) => {
(await import('fs')).createReadStream(filePath).pipe(res);
}
} catch (err) {
- console.error(err);
+ if (err.code !== 'ENOENT') console.error(err);
res.writeHead(404).end('File not found');
}
});
@@ -586,10 +611,15 @@ export default (router, tpl) => {
// Supports /mod/deleted/b/filename.ext (Binaries)
// Supports /mod/deleted/t/id.webp (Thumbnails)
router.get(/^\/mod\/deleted\/(?[bt])\/(?.+)/, lib.modAuth, async (req, res) => {
- const file = decodeURIComponent(req.params.file);
+ // F-003 Security: Sanitize file parameter to prevent path traversal
+ const file = path.basename(decodeURIComponent(req.params.file));
const type = req.params.type; // 'b' or 't'
console.log(`[MOD_STREAM] Request: type=${type}, file=${file}, range=${req.headers.range || 'none'}`);
- const filePath = path.join(cfg.paths.deleted, type, file);
+ const baseDir = path.resolve(cfg.paths.deleted, type);
+ const filePath = path.resolve(baseDir, file);
+ if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
+ return res.writeHead(403).end('Forbidden');
+ }
try {
const stat = await fs.stat(filePath);
@@ -599,7 +629,8 @@ export default (router, tpl) => {
const mimeType = {
'mp4': 'video/mp4', 'webm': 'video/webm',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
- 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
+ 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp',
+ 'pdf': 'application/pdf'
}[ext] || 'application/octet-stream';
if (range) {
@@ -624,7 +655,7 @@ export default (router, tpl) => {
(await import('fs')).createReadStream(filePath).pipe(res);
}
} catch (err) {
- console.error(err);
+ if (err.code !== 'ENOENT') console.error(err);
res.writeHead(404).end('File not found');
}
});
@@ -656,7 +687,7 @@ export default (router, tpl) => {
const body = JSON.stringify({ success: true, count });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
} catch (err) {
- const body = JSON.stringify({ success: false, msg: err.message });
+ const body = JSON.stringify({ success: false, msg: 'Purge failed' });
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
});
diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs
index ce5e1c2..601ae2c 100644
--- a/src/inc/routes/notifications.mjs
+++ b/src/inc/routes/notifications.mjs
@@ -10,20 +10,26 @@ const activeTabs = new Map(); // sessionId -> tabId
function broadcastChatPresence() {
const seen = new Set();
const users = [];
+ const guestIps = new Set();
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
- });
+ if (client.userId) {
+ if (!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
+ });
+ }
+ } else if (client.ip) {
+ guestIps.add(client.ip);
}
}
+ const guestCount = guestIps.size;
for (const client of clients) {
- client.send({ type: 'global_chat_presence', data: { users } });
+ client.send({ type: 'global_chat_presence', data: { users, guestCount } });
}
}
@@ -41,9 +47,16 @@ db.listen('notifications', (payload) => {
try {
const data = JSON.parse(payload);
const userId = data.user_id;
+ const SYSTEM_TYPES = ['upload_success', 'upload_error'];
+ const USER_TYPES = ['comment', 'comment_reply', 'mention', 'subscription', 'upload_comment'];
for (const client of clients) {
if (client.userId === userId) {
+ // Do Not Disturb takes absolute priority for standard notifications
+ if (client.do_not_disturb === true) continue;
+
+ if (SYSTEM_TYPES.includes(data.type) && client.receive_system_notifications === false) continue;
+ if (USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
client.send({ type: 'notify', data });
}
}
@@ -73,6 +86,11 @@ db.listen('profile_update', (payload) => {
const data = JSON.parse(payload);
for (const client of clients) {
if (client.userId === data.user_id) {
+ // Sync notification preferences to client object for real-time filtering
+ if (data.receive_system_notifications !== undefined) client.receive_system_notifications = data.receive_system_notifications;
+ if (data.receive_user_notifications !== undefined) client.receive_user_notifications = data.receive_user_notifications;
+ if (data.do_not_disturb !== undefined) client.do_not_disturb = data.do_not_disturb;
+
client.send({ type: 'profile_update', data });
}
}
@@ -217,6 +235,9 @@ db.listen('private_message', (payload) => {
// Only send to the recipient — sender already knows they sent it
for (const client of clients) {
if (client.userId === data.recipient_id) {
+ // Silenced by DND
+ if (client.do_not_disturb === true) continue;
+
client.send({ type: 'private_message', data: {
id: data.id,
sender_id: data.sender_id,
@@ -293,6 +314,19 @@ db.listen('global_chat_background', (payload) => {
}
}).catch(err => console.error('DB Listen global_chat_background error:', err));
+// Global listener for rethumb live updates
+db.listen('rethumb', (payload) => {
+ try {
+ const data = JSON.parse(payload);
+ console.log(`[SSE] Broadcasting rethumb (id: ${data.item_id}) to ${clients.size} clients`);
+ for (const client of clients) {
+ client.send({ type: 'rethumb', data });
+ }
+ } catch (e) {
+ console.error('Rethumb broadcast error:', e);
+ }
+}).catch(err => console.error('DB Listen rethumb error:', err));
+
// Global listener for chat topic changes
db.listen('global_chat_topic', (payload) => {
try {
@@ -391,7 +425,15 @@ export default (router, tpl) => {
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 = ${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', 'approve')
+ OR (
+ ${req.session.do_not_disturb !== true} AND (
+ (n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
+ OR (n.type IN ('comment', 'comment_reply', 'mention', 'subscription', 'upload_comment') AND ${req.session.receive_user_notifications !== false})
+ )
+ )
+ )
+ AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report'))
ORDER BY n.created_at DESC
LIMIT 1000
`;
@@ -510,8 +552,12 @@ export default (router, tpl) => {
avatar_file: req.session?.avatar_file || null,
avatar: req.session?.avatar || null,
username_color: req.session?.username_color || null,
+ receive_system_notifications: req.session?.receive_system_notifications !== false,
+ receive_user_notifications: req.session?.receive_user_notifications !== false,
+ do_not_disturb: req.session?.do_not_disturb === true,
sessionId,
tabId,
+ ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
send: (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
diff --git a/src/inc/routes/scroller.mjs b/src/inc/routes/scroller.mjs
index 6611270..8d3f98d 100644
--- a/src/inc/routes/scroller.mjs
+++ b/src/inc/routes/scroller.mjs
@@ -315,7 +315,7 @@ export default (router, tpl) => {
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ success: false, items: [], error: e.message })
+ body: JSON.stringify({ success: false, items: [], error: 'Feed error' })
});
}
});
diff --git a/src/inc/routes/search.mjs b/src/inc/routes/search.mjs
index 49f4949..2d59d8f 100644
--- a/src/inc/routes/search.mjs
+++ b/src/inc/routes/search.mjs
@@ -68,10 +68,10 @@ export default (router, tpl) => {
from "items"
join "tags_assign" on "tags_assign".item_id = "items".id
join "tags" on "tags".id = "tags_assign".tag_id
- where lower("tags".tag) in (${db(lowerTags)})
+ where "tags".normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${tags}::text[]) AS x))
and "items".active = true
group by "items".id
- having count(distinct lower("tags".tag)) = ${lowerTags.length}
+ having count(distinct "tags".normalized) = ${tags.length}
) sub
`;
total = countResult.length > 0 ? countResult[0].total : 0;
@@ -85,10 +85,10 @@ export default (router, tpl) => {
from "items"
join "tags_assign" on "tags_assign".item_id = "items".id
join "tags" on "tags".id = "tags_assign".tag_id
- where lower("tags".tag) in (${db(lowerTags)})
+ where "tags".normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${tags}::text[]) AS x))
and "items".active = true
group by "items".id
- having count(distinct lower("tags".tag)) = ${lowerTags.length}
+ having count(distinct "tags".normalized) = ${tags.length}
order by "items".id desc
offset ${offset}
limit ${_eps}
@@ -119,26 +119,34 @@ export default (router, tpl) => {
}
}
else {
- total = (await db`
- select count(*) as total
- from "tags"
- left join "tags_assign" on "tags_assign".tag_id = "tags".id
- left join "items" on "items".id = "tags_assign".item_id
- where "tags".tag ilike ${'%' + tag + '%'}
- group by "items".id, "tags".tag
- `).length;
+ const q = '%' + tag + '%';
+
+ const countResult = await db`
+ select count(*) as total from (
+ select 1
+ from "items"
+ join "tags_assign" on "tags_assign".item_id = "items".id
+ join "tags" on "tags".id = "tags_assign".tag_id
+ where ("tags".tag ilike ${q} or "tags".normalized like '%' || slugify(${tag}) || '%')
+ and "items".active = true
+ group by "items".id
+ ) sub
+ `;
+ total = countResult.length > 0 ? parseInt(countResult[0].total) : 0;
const pages = +Math.ceil(total / _eps);
const act_page = Math.min(pages, page || 1);
const offset = Math.max(0, (act_page - 1) * _eps);
const rows = await db`
- select "items".id, "items".username, "items".mime, "tags".tag
- from "tags"
- left join "tags_assign" on "tags_assign".tag_id = "tags".id
- left join "items" on "items".id = "tags_assign".item_id
- where "tags".tag ilike ${'%' + tag + '%'} and "items".active = true
- group by "items".id, "tags".tag
+ select "items".id, "items".username, "items".mime, min("tags".tag) as tag
+ from "items"
+ join "tags_assign" on "tags_assign".item_id = "items".id
+ join "tags" on "tags".id = "tags_assign".tag_id
+ where ("tags".tag ilike ${q} or "tags".normalized like '%' || slugify(${tag}) || '%')
+ and "items".active = true
+ group by "items".id
+ order by "items".id desc
offset ${offset}
limit ${_eps}
`;
diff --git a/src/inc/routes/settings.mjs b/src/inc/routes/settings.mjs
index d6490bc..cb7521e 100644
--- a/src/inc/routes/settings.mjs
+++ b/src/inc/routes/settings.mjs
@@ -38,7 +38,7 @@ export default (router, tpl) => {
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
- console.log('Rendering settings. Excluded tags:', excluded_tags);
+
res.reply({
body: tpl.render('settings', {
diff --git a/src/inc/routes/subscriptions.mjs b/src/inc/routes/subscriptions.mjs
index 34cd472..8d0a849 100644
--- a/src/inc/routes/subscriptions.mjs
+++ b/src/inc/routes/subscriptions.mjs
@@ -21,7 +21,7 @@ export default (router, tpl) => {
const offset = (page - 1) * eps;
try {
- console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
+ if (cfg.main.development) console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
const countRes = await db`
SELECT count(*) as total
@@ -41,7 +41,7 @@ export default (router, tpl) => {
ORDER BY s.created_at DESC
LIMIT ${eps} OFFSET ${offset}
`;
- console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
+ if (cfg.main.development) console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
const items = subs.map(i => ({
id: i.id,
diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs
index 4ca9743..03c5aa4 100644
--- a/src/inc/routes/user_halls.mjs
+++ b/src/inc/routes/user_halls.mjs
@@ -157,6 +157,25 @@ export default (router, tpl) => {
data.hidePagination = true;
data.session = req.session ? { ...req.session } : false;
+ // Precompute boolean helpers for template @if() — must match index.mjs pattern
+ if (data.item) {
+ const session = data.session;
+ const item = data.item;
+ data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
+ data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
+ data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
+ data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
+ data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
+ data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
+ data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
+ data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
+ data.item_username_lower = (item.username || '').toLowerCase();
+ data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
+ data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
+ data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
+ data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
+ }
+
// Precompute hall display
if (data.item?.halls?.length) {
data.item.primaryHall = data.item.halls[0];
@@ -168,17 +187,26 @@ export default (router, tpl) => {
if (req.session || !cfg.main.hide_comments_from_public) {
if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
- const useLegacy = req.session
- ? (req.session.use_new_layout === false)
- : (cfg.websrv.default_layout === 'legacy');
- const sort = useLegacy ? 'old' : 'new';
- data.comments = await f0cklib.getComments(req.params.itemid, sort, false);
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
- data.commentsJSON = Buffer.from(JSON.stringify(data.comments || [])).toString('base64');
+
+ // xD Score
+ const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
+ const xdScore = f0cklib.computeXdScore(commentsForScore);
+ const xdMeta = f0cklib.xdScoreMeta(xdScore);
+ data.item.xd_score = xdScore;
+ data.item.xd_tier = xdMeta.tier;
+ data.item.xd_label = xdMeta.label;
+
+ // Comments loaded async by client
+ data.commentsJSON = null;
+ data.comments = [];
} else {
data.comments = [];
data.isSubscribed = false;
- data.commentsJSON = Buffer.from('[]').toString('base64');
+ data.commentsJSON = null;
+ data.item.xd_score = 0;
+ data.item.xd_tier = 0;
+ data.item.xd_label = '';
}
return res.reply({ body: tpl.render('item', data, req) });
@@ -188,11 +216,13 @@ export default (router, tpl) => {
router.get(/^\/user_hall_image\/(?\d+)\/(?.+)$/, async (req, res) => {
const userId = +req.params.userId;
const slug = decodeURIComponent(req.params.slug);
+ // F-016 Security: Sanitize slug to prevent path traversal
+ const safeSlug = path.basename(slug);
const mode = +(req.url.qs?.m ?? 0);
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
- const customPath = path.join(CUSTOM_DIR, `u_${userId}_${slug}.webp`);
+ const customPath = path.join(CUSTOM_DIR, `u_${userId}_${safeSlug}.webp`);
try {
// 1. Serve custom image if present
@@ -207,7 +237,7 @@ export default (router, tpl) => {
} catch (_) { /* no custom image */ }
// 2. Check mosaic cache
- const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
+ const hash = createHash('md5').update(`uh_${userId}_${safeSlug}_${mode}`).digest('hex');
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
try {
await fs.access(cachePath);
@@ -316,8 +346,10 @@ export default (router, tpl) => {
const result = await f0cklib.deleteUserHall(targetUserId, slug);
// Clean up custom image if it exists
+ // F-016 Security: Sanitize slug to prevent path traversal in file deletion
+ const safeSlug = path.basename(slug);
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
- fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${slug}.webp`)).catch(() => {});
+ fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${safeSlug}.webp`)).catch(() => {});
return res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' })
.end(JSON.stringify(result));
@@ -380,12 +412,14 @@ export default (router, tpl) => {
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
}
+ // F-016 Security: Sanitize slug to prevent path traversal in file deletion
+ const safeSlug = path.basename(slug);
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
- await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${slug}.webp`)).catch(() => {});
+ await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${safeSlug}.webp`)).catch(() => {});
// Clear mosaic cache entries for all modes
for (const m of [0, 1, 2]) {
- const h = createHash('md5').update(`uh_${req.session.id}_${slug}_${m}`).digest('hex');
+ const h = createHash('md5').update(`uh_${req.session.id}_${safeSlug}_${m}`).digest('hex');
await fs.unlink(path.join(CACHE_DIR, `${h}.webp`)).catch(() => {});
}
await db`UPDATE user_halls SET custom_image = false WHERE id = ${hall.id}`;
diff --git a/src/inc/security.mjs b/src/inc/security.mjs
index 6f1f842..ef0a34c 100644
--- a/src/inc/security.mjs
+++ b/src/inc/security.mjs
@@ -52,7 +52,8 @@ export default new class {
*/
async recordAttempt(ip, username, type, success) {
const ip_hash = this.hashIP(ip);
- console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
+ if (!success) console.warn(`[SECURITY] Failed ${type} attempt: user=${username}, ip_hash=${ip_hash}`);
+ else if (cfg.main.development) console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
await db`
insert into login_attempts (ip_hash, username, type, success)
values (${ip_hash}, ${username?.toLowerCase() || null}, ${type}, ${success})
@@ -66,7 +67,7 @@ export default new class {
*/
async clearAttempts(ip, username) {
const ip_hash = this.hashIP(ip);
- console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
+ if (cfg.main.development) console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
await db`
delete from login_attempts
where (ip_hash = ${ip_hash} OR username = ${username?.toLowerCase() || ''})
@@ -92,7 +93,7 @@ export default new class {
const windowStart = new Date(Date.now() - windowMinutes * 60000);
- console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
+ if (cfg.main.development) console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
// Check attempts by IP
const ipAttempts = await db`
@@ -105,7 +106,10 @@ export default new class {
`;
const ipCount = +ipAttempts[0].count;
- if (ipCount >= maxAttempts) return true;
+ if (ipCount >= maxAttempts) {
+ console.warn(`[SECURITY] Rate limit hit for ${type}: ip_hash=${ip_hash}, attempts=${ipCount}/${maxAttempts}`);
+ return true;
+ }
// Check attempts by username (if provided)
if (username) {
@@ -118,7 +122,10 @@ export default new class {
and attempted_at > ${windowStart}
`;
const userCount = +userAttempts[0].count;
- if (userCount >= maxAttempts) return true;
+ if (userCount >= maxAttempts) {
+ console.warn(`[SECURITY] Rate limit hit for ${type}: user=${username}, attempts=${userCount}/${maxAttempts}`);
+ return true;
+ }
}
return false;
diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs
index 377d146..3f10a09 100644
--- a/src/inc/settings.mjs
+++ b/src/inc/settings.mjs
@@ -8,12 +8,19 @@ let bypass_duplicate_check = false;
let protect_files = false;
let private_messages = true;
let default_layout = 'modern';
+let enable_pdf = false;
+
+export const getEnablePdf = () => enable_pdf;
+export const setEnablePdf = (val) => enable_pdf = !!val;
export const getManualApproval = () => manual_approval;
export const setManualApproval = (val) => manual_approval = !!val;
export const getMinTags = () => min_tags;
-export const setMinTags = (val) => min_tags = parseInt(val) || 3;
+export const setMinTags = (val) => {
+ const parsed = parseInt(val);
+ min_tags = isNaN(parsed) ? 3 : Math.max(0, parsed);
+};
export const getRegistrationOpen = () => {
if (cfg.websrv.open_registration_web_toggle === false) {
diff --git a/src/inc/trigger/parser.mjs b/src/inc/trigger/parser.mjs
index 705ce7a..4c8be85 100644
--- a/src/inc/trigger/parser.mjs
+++ b/src/inc/trigger/parser.mjs
@@ -16,7 +16,8 @@ const regex = {
imgur: /(?:https?:)?\/\/(\w+\.)?imgur\.com\/\S+/i,
fourchan: /https?:\/\/i\.4cdn\.org\/(\w+)\/(\d+)\.(\w{3,4})/i,
instagram: /(?:https?:\/\/www\.)?instagram\.com\S*?\/(?:p|reel)\/(\w{11})\/?/im,
- ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i
+ ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i,
+ vocaroo: /(?:https?:\/\/)?(?:www\.)?(?:vocaroo\.com|voca\.ro)\/([a-zA-Z0-9_-]+)/i
};
const pcUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
const mediagroupids = new Set();
@@ -444,7 +445,7 @@ export default async bot => {
else if (link.match(regex.ph)) {
try {
// Added referer to fix fragment 404 errors, removed -vU to avoid exit code 100
- source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
+ source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.error('Pornhub dl error:', err);
const errorMsg = `something went wrong lol`.slice(0, 1024);
@@ -455,7 +456,8 @@ export default async bot => {
}
else if (link.match(regex.instagram)) {
try {
- source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
+ source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
+
} catch (err) {
console.error('Instagram dl error:', err);
const errorMsg = `something went wrong lol`.slice(0, 1024);
@@ -466,7 +468,7 @@ export default async bot => {
}
else if (link.match(regex.imgur)) {
try {
- source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
+ source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.warn(`[PARSER] Imgur Stage 1 (yt-dlp) failed: ${err.message}. Retrying with curl...`);
@@ -528,7 +530,7 @@ export default async bot => {
}
else if (link.match(regex.yt)) {
try {
- source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
+ source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.error('YouTube dl error:', err);
const errorMsg = `something went wrong lol`.slice(0, 1024);
@@ -539,7 +541,7 @@ export default async bot => {
}
else if (link.match(regex.fourchan)) {
try {
- source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
+ source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.error('4chan dl error:', err);
const errorMsg = `something went wrong lol`.slice(0, 1024);
@@ -550,7 +552,7 @@ export default async bot => {
}
else {
try {
- source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
+ source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.error('General dl error:', err);
const errorMsg = `something went wrong lol`.slice(0, 1024);
diff --git a/src/index.mjs b/src/index.mjs
index d17aebc..56ffb85 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
-import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout } from "./inc/settings.mjs";
+import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
@@ -234,7 +234,7 @@ process.on('uncaughtException', err => {
if (req.cookies.session) {
const user = await db`
- select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox
+ select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
@@ -352,8 +352,10 @@ process.on('uncaughtException', err => {
embed_youtube_in_comments: user[0].embed_youtube_in_comments ?? (cfg.websrv.embed_youtube_in_comments !== false),
hide_koepfe: user[0].hide_koepfe ?? false,
language: (user[0].language && user[0].language.trim()) ? user[0].language.trim() : null,
- use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false)
- }, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox')
+ use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false),
+ comment_display_mode: user[0].comment_display_mode ?? (cfg.websrv.default_comment_display_mode || 0),
+ force_comment_display_mode: user[0].force_comment_display_mode ?? 0
+ }, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox', 'comment_display_mode', 'force_comment_display_mode')
}
on conflict ("user_id") do update set
theme = excluded.theme,
@@ -370,6 +372,8 @@ process.on('uncaughtException', err => {
hide_koepfe = excluded.hide_koepfe,
language = excluded.language,
use_alternative_infobox = excluded.use_alternative_infobox,
+ comment_display_mode = excluded.comment_display_mode,
+ force_comment_display_mode = excluded.force_comment_display_mode,
user_id = excluded.user_id
`.catch(e => console.error('[MIDDLEWARE] Options sync failed:', e));
}
@@ -635,6 +639,10 @@ process.on('uncaughtException', err => {
console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message);
}
+ // Set enable_pdf from config (pure config setting)
+ setEnablePdf(!!cfg.enable_pdf);
+ console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`);
+
// Load bypass_duplicate_check from config.json (static — not a DB setting)
if (cfg.websrv.bypass_duplicate_check === true) {
setBypassDuplicateCheck(true);
@@ -736,6 +744,7 @@ process.on('uncaughtException', err => {
themes_json: JSON.stringify(cfg.websrv.themes || []),
enable_profile_description: !!cfg.websrv.enable_profile_description,
get private_messages() { return getPrivateMessages(); },
+ get enable_pdf() { return getEnablePdf(); },
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
ts: Date.now(),
get default_layout() { return getDefaultLayout(); },
@@ -751,6 +760,7 @@ process.on('uncaughtException', err => {
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
paths_images: cfg.websrv.paths?.images || '/b',
+ default_comment_display_mode: cfg.websrv.default_comment_display_mode || 0,
get fonts() {
try {
@@ -818,7 +828,12 @@ process.on('uncaughtException', err => {
data = Object.assign({}, globals, data || {}, {
t: perRequestT,
lang: perRequestLang,
- user_alternative_infobox: useAltInfobox
+ user_alternative_infobox: useAltInfobox,
+ comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
+ ? req.session.comment_display_mode
+ : (data && typeof data.comment_display_mode === 'number'
+ ? data.comment_display_mode
+ : (cfg.websrv.default_comment_display_mode || 0))
});
// Random brand image per-render
@@ -853,4 +868,24 @@ process.on('uncaughtException', err => {
app.listen(cfg.websrv.port);
+ // F-015 Security: Periodic session cleanup — purge sessions unused for 30 days
+ const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
+ const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6 hours
+
+ const cleanupStaleSessions = async () => {
+ try {
+ const cutoff = ~~(Date.now() / 1e3) - SESSION_TTL_SECONDS;
+ const result = await db`DELETE FROM user_sessions WHERE last_used <= ${cutoff}`;
+ if (result.count > 0) {
+ console.log(`[SESSION CLEANUP] Purged ${result.count} stale sessions (unused >30 days)`);
+ }
+ } catch (err) {
+ console.error('[SESSION CLEANUP] Failed:', err.message);
+ }
+ };
+
+ // Run once after startup (30s delay to let DB settle), then every 6 hours
+ setTimeout(cleanupStaleSessions, 30_000);
+ setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
+
})();
diff --git a/src/rethumb_handler.mjs b/src/rethumb_handler.mjs
index 3aeff84..6ee191f 100644
--- a/src/rethumb_handler.mjs
+++ b/src/rethumb_handler.mjs
@@ -148,6 +148,12 @@ export const handleRethumbUpload = async (req, res, itemId) => {
await fs.unlink(tmpPath).catch(() => {});
+ try {
+ await db`SELECT pg_notify('rethumb', ${JSON.stringify({ item_id: item.id })})`;
+ } catch (err) {
+ console.error('[RETHUMB HANDLER] SSE notify error:', err);
+ }
+
console.log('[RETHUMB HANDLER] Custom thumbnail applied to item', item.id);
return sendJson(res, {
success: true,
diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs
index 96de442..86c2ce1 100644
--- a/src/upload_handler.mjs
+++ b/src/upload_handler.mjs
@@ -5,7 +5,7 @@ import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import https from "https";
-import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck } from "./inc/settings.mjs";
+import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
// Helper for JSON response
@@ -171,6 +171,11 @@ export const handleUpload = async (req, res, self) => {
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
+ if (actualMime === 'application/pdf' && !getEnablePdf()) {
+ await fs.unlink(tmpPath).catch(() => { });
+ return sendJson(res, { success: false, msg: 'PDF uploads are currently disabled.' }, 403);
+ }
+
// Reclassify audio-only MP4 containers (e.g. .m4a files detected as video/mp4)
if (actualMime === 'video/mp4' || actualMime === 'video/quicktime') {
const origExt = file.filename.split('.').pop().toLowerCase();
@@ -347,6 +352,7 @@ export const handleUpload = async (req, res, self) => {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
} catch (err) {
+ console.warn(`[UPLOAD WARNING] genThumbnail failed for item ${itemid} (falling back to placeholder):`, err.message);
// Fallback to placeholder for thumbnail ONLY if it hasn't been processed yet
if (!thumbProcessed) {
const tPath = !isPending
diff --git a/views/admin.html b/views/admin.html
index 901a78e..6b8699d 100644
--- a/views/admin.html
+++ b/views/admin.html
@@ -22,7 +22,10 @@
MOTD Manager
About Page
Rules Page
+ Global Chat Manager
+
+
@@ -55,7 +58,7 @@
Minimum number of tags required per upload.
-
+
@@ -90,6 +93,7 @@
diff --git a/views/admin/chat.html b/views/admin/chat.html
new file mode 100644
index 0000000..fed356f
--- /dev/null
+++ b/views/admin/chat.html
@@ -0,0 +1,223 @@
+@include(snippets/header)
+
+
+
+
ADMINBEREICH
+
Global Chat Management
+
Back to Dashboard
+
+
+
+
+
Admin commands for the global chat widget.
+
+
+ | Command |
+ Description |
+
+
+ /clear |
+ Deletes all messages and restarts history. |
+
+
+ /setbackground <url> [opts] |
+ Sets a background image. Opts: center / cover no-repeat etc. |
+
+
+ /clearbg |
+ Removes the chat background. |
+
+
+ /settopic <text> |
+ Sets the pinned topic at the top of the chat. |
+
+
+ /cleartopic |
+ Removes the pinned topic. |
+
+
+
+
+
+
+
+
+
+
+@include(snippets/footer)
diff --git a/views/admin/memes.html b/views/admin/memes.html
index 65f1793..b2064f4 100644
--- a/views/admin/memes.html
+++ b/views/admin/memes.html
@@ -39,7 +39,7 @@
- @if(matrix_enabled)
- {{ t('settings.linked_accounts') }}
-
-
{{ t('settings.matrix_link_desc') }}
-
-
-
{{ t('settings.active_links') }}
-
- {{ t('settings.loading') }}
-
-
-
-
-
{{ t('settings.add_new_link') }}
-
- {!! t('settings.matrix_instructions') !!}
-
-
-
-
- {{ t('settings.your_token') }}
-
{{ t('settings.one_time_use') }}
-
-
-
-
-
-
- @endif
-
-
-
@include(snippets/footer)
\ No newline at end of file
diff --git a/views/snippets/footer.html b/views/snippets/footer.html
index f66cbf2..13d0623 100644
--- a/views/snippets/footer.html
+++ b/views/snippets/footer.html
@@ -124,7 +124,8 @@
@endif
@if(private_society && !session)