From 2f1e42343b13575b189a61ea383c8345ce097593 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 4 May 2026 04:24:18 +0200 Subject: [PATCH] updating from dev --- config_example.json | 26 +- deleted/.gitkeep | 0 pending/.gitkeep | 0 pending/ca/.gitkeep | 0 pending/t/.gitkeep | 0 public/s/css/f0ckm.css | 3993 ++++++++++++++++++----------- public/s/css/upload.css | 19 +- public/s/img/pdf.webp | Bin 0 -> 2018 bytes public/s/img/swf.png | Bin 13070 -> 4731 bytes public/s/js/admin.js | 2 +- public/s/js/comments.js | 755 +++++- public/s/js/f0ck_upload_init.js | 2 + public/s/js/f0ckm.js | 295 ++- public/s/js/flash_yank.js | 2 +- public/s/js/globalchat.js | 99 +- public/s/js/messages.js | 28 +- public/s/js/sanitizer.js | 20 +- public/s/js/scroller.js | 104 +- public/s/js/settings.js | 118 +- public/s/js/sidebar-activity.js | 131 +- public/s/js/upload-common.js | 11 + public/s/js/upload.js | 46 +- public/s/js/user_comments.js | 23 +- public/s/js/v0ck.js | 12 +- src/inc/lib.mjs | 6 +- src/inc/locales/de.json | 22 +- src/inc/locales/en.json | 38 +- src/inc/locales/nl.json | 20 +- src/inc/locales/zange.json | 39 +- src/inc/queue.mjs | 66 +- src/inc/routeinc/f0cklib.mjs | 55 +- src/inc/routeinc/search.mjs | 16 +- src/inc/routes/admin.mjs | 65 +- src/inc/routes/ajax.mjs | 4 +- src/inc/routes/apiv2/index.mjs | 72 +- src/inc/routes/apiv2/settings.mjs | 84 +- src/inc/routes/apiv2/tags.mjs | 4 +- src/inc/routes/apiv2/upload.mjs | 27 +- src/inc/routes/chat.mjs | 4 + src/inc/routes/comments.mjs | 43 +- src/inc/routes/emojis.mjs | 5 + src/inc/routes/external.mjs | 91 +- src/inc/routes/halls.mjs | 2 +- src/inc/routes/index.mjs | 4 +- src/inc/routes/meme-manager.mjs | 5 + src/inc/routes/mod.mjs | 385 +-- src/inc/routes/notifications.mjs | 68 +- src/inc/routes/scroller.mjs | 2 +- src/inc/routes/search.mjs | 44 +- src/inc/routes/settings.mjs | 2 +- src/inc/routes/subscriptions.mjs | 4 +- src/inc/routes/user_halls.mjs | 58 +- src/inc/security.mjs | 17 +- src/inc/settings.mjs | 9 +- src/inc/trigger/parser.mjs | 16 +- src/index.mjs | 45 +- src/rethumb_handler.mjs | 6 + src/upload_handler.mjs | 8 +- views/admin.html | 7 +- views/admin/chat.html | 223 ++ views/admin/memes.html | 6 +- views/admin/tokens.html | 8 +- views/admin/users.html | 51 + views/admin/users_list.html | 1 + views/item-partial-legacy.html | 2 +- views/mod/approve.html | 61 +- views/mod_reports.html | 7 +- views/notifications.html | 4 +- views/scroller.html | 11 +- views/settings.html | 616 +++-- views/snippets/footer.html | 18 +- views/snippets/item-media.html | 4 + views/snippets/items-grid.html | 2 +- views/snippets/navbar.html | 20 +- views/snippets/upload-form.html | 16 +- views/upload.html | 2 +- 76 files changed, 5554 insertions(+), 2527 deletions(-) delete mode 100644 deleted/.gitkeep delete mode 100644 pending/.gitkeep delete mode 100644 pending/ca/.gitkeep delete mode 100644 pending/t/.gitkeep create mode 100644 public/s/img/pdf.webp create mode 100644 views/admin/chat.html 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 0000000000000000000000000000000000000000..bbd32422bcfcbe8fdd2e116274bb51edc1e795e5 GIT binary patch literal 2018 zcmWIYbaT7K&cG1v>J$(bU=hK^z`!8Dz`*bmj2wLeJgze^FfcYm-`^C}ZIj&H&Q`%n8knvCd?*}Cd`R~oX{kwJFU%sNfLD%1({pmHg|NpW2$NN9t|B?U4 z{Gasy-v7ty?d8r@b#HZz>fR!jse37E$ySf-iPx0Y(ni4U*J^(4PY>hkLqK-iJ;{~# zc5m}HA9L+mv-XA-!Bcb68opoPYuL;8cR|&%ADgT9er9|w{WAVf`U>_Rd`tVC z{_EwZ*sITts7mhK>d;s7Esif!Je~J`{VwNg0%uNXxNoViaE!zYj*^Y<-wR9Jox)MF@#EUAcpf3P*I(vr z*)nlm!=9FBk{{fol4jJiZN8KtwQpfUx5o;Rqz!)C*KBURdd2Z5&)tOwXQnXh*VNe> zee8GNX`BC-U(A{E{;<_qfgPVO=I!JWVmw#cvx$w>dHMe){m5CR@yUuGnJqP?ZFNOI z98{3({!o1K5-)S&>O@Yo^66TDK33~(1EO=ZzBj+!V z3BRm^($1;BzdrxN^S5d~`(w5J6IQpJ-ZN{?slDGe>%I2heO&361H-?gG131{pLE@I zA@%U>))e>F&==>NWB;&L{eQ~Z$DqU{bx-x-WB=br6|-yhNu~Xt>38zVtyKaWpVT`$ zKJi|_Q|7uz?zlWtntHRA*XQ0j3mzUj7f|pxUBkq2UeEjKNg<98S&|qS7`{DXZsJ^W zZu#+~dFQ+SzBNC?B2pG&a^VC6<4rq>Z#OuE-FmhhJRU6bM&9y~?$RsOc3O?c3a(mN zH9k4r8F~9^O=@PFQ_ISwyR}05Z_VdO-?Zgc_{kf$1aAJyzpK_2tRFmQdVxw-{-aNk zpSy}%kIek%qr2JDzU0NhCu`^SIxPF~Yh`=z?4U-U(9(7Mms=yHuiV!VvAgGTJ7KlU z(`{lZv9A74Ykcezmb)LTU!8vI&xJo#Ywld>E||i;T72o12TRm>t7dMG>3-34^}NUb7Blkv5%g-^f>%PV3mD|lyOLi)&9CtiQN*xw;j*4>BbBE2$78YZaa6$)Y{um z*nE$!tv&SfMarUAT5hr|FMDFYd1$OtzcRy$CGVOs^WTiXIOXF;Tcgd_h*s4dN<8~X zTt861N%g#`5N|`GW|X|9gHhU*rIH_uA8b#}Grcwa=qja(1hs$pk3U^c->7(2qq5V% zctVNhjw}_6t$qwW%Oae<iB5ZG8FM|PKQ$X(ks$LB70 z|KA9m*-tl4y(k&L&uI5Y>#dK=qUFc-8uj(`MOUqp4w`xNdZO7qzt3m>mFWC=eBC~> zPva_&$29r3hu2D(rY`+#|EDS{F2(I+=R<`IV~f%KO{H}N3&d;gat2^n`6wBQu zNA_fY5MSzVX%TE9FUK~;*J`t%*W=rq-E}H2oOa&HyydQY?Dz@xgI?DPWNmC?lHSeT zp|GV&lzv%8cQOmCJ z{mzRjuGiPJ{|`R0azn!hz3Ex^b()V~$?;NO_Wqk(sN({*Pj1=qyPNbnBE2ADYh;96>AbdDAuMg^4RsghNt>y{bcL?i^9Dgn47+5+MK+Y z&mgQgP-1B$e^>9B5`TRfNukVIZTZaiedgq$XdJ8_D9+N5O)SY0MYz+K4dsm;~yOFMmjItvta&eup2sDHCB;Di$A zj^9?#3Kk|hzM5ls>A@>=v*jKQQ}_+@xn<$^OlJ_8J&H%t+}hcd70Axw<`tJKKo{RI8QH^KXKLPr?W!PpP8T9a$o;f K7dz_B00RJAsQ@Ja literal 0 HcmV?d00001 diff --git a/public/s/img/swf.png b/public/s/img/swf.png index 50bebc8a56cb24c63ced8aeb52f1c265176537f0..25b08a6c89b226621c0d5340376e6c1e1d9a82a9 100644 GIT binary patch literal 4731 zcmeAS@N?(olHy`uVBq!ia0y~yVE6^X9Bd2>3>|NxY#A6B6p}rHd>I(3)EF2VS|IWb zFBlj~4Hy_+B``2p&0t^UO_QmvAUQWHy38H@~!%ybP*bq$O|j0~+zjI9jJv<(cb3=DE# zK3$2TAvZrIGp!Q02AM6fq6`cS8gLs*GILXlOA>PnF!h+2KrD$UNtR?_5RCS8aSW-r z_4aP%1evRe$3MoGpI6M}U%N$H=R$x$2R~0BXAgs;O0s}t`=sVKN)I*}Jk@HKIHk8c z=}-En$6=Oq(*_{dtI0W#g;& zKTpHdUAb5Kyw4id)P`HfU#PCI&v#_esJH5j#^P$2PgdCW^J&=Idp=>kvq(6zVId}V zc=>Xx(DG|1ltcapJ{6nm!OVjNd-<)NGkyH4-@ zUv*j7*i!gg#XMhLV0|9G^yTdWZoR+1%oA9e=l*qT`d)0VA!~JV(cL9t*QGVIIrTfW!t)q4^}PwBr+@Yq#Rf2j9ckp%VMvFC@?%Wl(Z<&7DoG4?T{2eQLE*?h=`)v85T^0uv;_~3y+*uCoYuTP(?|G;?8?w`e*kA*i^dt6R}2!`lm$m+qP_JgtBHlke=9*k@0y-#<80zQ6m@lO=DiE{^=r zBmSy^5A>SIXXyb$j}=uckMXj~f(z;=O6k+PLs{*!^7TnAo`g$9g~ilQOhC zc|r7GY`OfVs_Knj9?MU+@blwAQyCC7Vch0uvbza{DJPfns7k^fHzen;Q zL)H3t^=gUIJ^WJ-=EoVF`r&szVnyX z`X}~0cOS3$SI3jHFZi2*M`4!y;>&B;?Dva4o21$u=chT}{o$F&@Z(`UpZ8u2J$S)Z z$6$jDbG>wFYUIi3<&V$X|Lc4cx`^-Ly4~$-|NIHNeoxkPXZrd#R~PaHRdt`ct~4j^ z$0qNMO0Ujc+fz`-vvd3Xry(=?3%lRBwl!YuuiRSi&Dw_j~!q zH@mW9Bd2MIO}z293uJck3pwEk!v^ntpHi<+n=WVDkkpfY{BPgF>W;+7h|e#!$L?Kb z`)~jLse7ZYu=3n>4zu0BdwShoaN_vOr{?#u_FUH~8 z?`m0C?ePAW|HDoBb%(FMzWG`HkGxTP--$TWS3*mlU$k7${bfyb_la`3(yz5w7#41 z9=P!>J!QR8sibqb&yOzcf}7_1`&7gKySh)4|9{teM$LEIo2xxzk6jQIl#x30U~l|^ z1JBOy*ta?^)Mr`2NBjAc_pVZ5be?{~z*pz@>en0o^xeK#|C&wW;JJIU$^L&i{M3}I zw|X)ib^d-lGI8~CcH8UgnOhpJUS`hD5b$2VN635qZPrmlpnh`h4r<*NR+b=WKt9Oj`eD z;@(wDG?qFqzX>Wk{_Z_)@blg8P3=xx$uHtmStixr=-w#*U&PN!x7uWhMcX^a*5YJ~ z|A*_Hzy0vM>8#kF@Io%gXW55``v0Xj-rvKJczaX3v)rKzwhLzXD1KWVul(({c)+YP zr{(x61)RgD3Y?I$Z+!ADeBv$po}hgS)h#J}@aw{gM^^K!X`}JK|D)YB5w+e~O7gtynP@@&`_Co0XuN^i`yWhD; zCA`4I0aaDLSL&1(9SyK5`6SM`;QKRa!Kj6AHd~6H@l03W@Y{S-yPXs>r`pH~<3@&sd>pKM5P%_5#DGeU)-i!@}Tp4u;MPLOv`^Q|l$Csw;6hRQzT&cw2Gkiqp5 z^Zsd4%H}R#^K_2$^}pYB`(w-GySvW^Ph?)z?l(`FLB%=oNz+&PwYT!BO^f>5@{SAp zedAy6+P`e?(%=tfdH>f-7%TIp)J>?05v%2_Fr$nDHW$^}sWyjtf z)vbCvqvrSJc|9@-FP=Q z?zfzc%&JeHUwrFw>Et`fK0e#W{UA?<|8I#|NgF(1WCymEj0KP_ELZJl7gR#SYRj!l{Ayk%MHL4IZb8FiJ|MfhVUve)JMwm6-TZv6FEp<&X+ zPdQtDZ9X&m|C*YNQ?V-!oV2i>STyPI;fq(#+bwo|((q{EWSgA<9IU6ATn=B6xD~rU zu0F7{Eq%`RyPK}t#U{NdQ+lf6?zW{%CgH^uH@%2XnW+JHdt#?7Y0Xm)@+&F{*$ql< zx@t{|HyXX3tYCSa_GZ$#UAH@@Otz_TI1Q5H1<6gjS@Gg+)f=O}i*Hj-dG^Wlth=`` z{RxBf@~#+zjzyuh+WXp?9_GEdYv;CovZlIamB(w4))j8|_Uyj5^2Y9;Z%nOcmQ7-J zTfDm5W_9Tk2IuSdQf8m9mfS7Cw1_*p#Cm2~5<9<(a*s^ItA)jKdl!E?sL<7R^V`a0 zn~(KPOJACpxAFSC8F%p7Pl{+_`KZUlcYfjuLu*P#j?oX+t1uMeSOz%?7WD$l-t*DrO>jf0DOYWlA5%@sSkO~+!{)ld72lFJ`^>XvCe zKH?VN?^b5`X=l<4yVPhguVs;(TT7pN#J%aREj;n*-7iBoJAa!-S#o?+)ZCKky&^B`(++)xNY6G2TQ7#y~r@xl&!8D&(QWx+4Z!`$qf00 zS{wCKuHW@bpL1j3b;+Mf1|{<~+~a0Ct=@C0xohL}^1ILN_NIY6aiY*|pGQ~wxu08i z7Wk~*q?*5Dua!`B@`ktf*CcMc@qF`ZuvcGjy1ips>iqn)?fG>wmRaG`%~tNM3;vs| zdL{Wd&&F_T-TzyE30(XW`8MuSq{-i#GS!s~#k1o!gWUEb#pDx{a(VO4AFdO0`ZA|k zugcNtEt?-wxn)h=n!?k3jb`z|{TBo>e%yNY)I9g5^uPNVKZ{Q(2Yg-gn`i%*MSVyH*Q}v@xJjVzhJ8` z5f_Y>-Bf#dc!gWY&L2xP51d*aQn&he*!~Z!Gt|DldhXX5>myv9>+rRG#Q}0;FoaHuswEem3WZT?#-f4!}~X`*%p843;*tGb-!anM z1_lKNPZ!6KiaBrRR+h+Io%?=&=-%zq!m>Atou0&)oTH||%0457%}2<;;y}XZ87-E= zO~#5UhB8U>=TxS1yl+jJw5M^+Ja>)+51#x<2Y5VvlJ;z{OsQydP>^7;5Rkc$<{6i= zGc|W@+^T!>Yj5w0jSY>x8+)~M_3OHlS1We!dKYy1ef9MF>sK-GwI6<1U?GEzy4sXb z&Z>3x#*G;~44ZVs=Wd%~lD5RONnuKzmbYhgj+F9k?f!{6r*D?NERp*B`E&ienKLW* zY~Nm99N1cHQrOJU!QIW?ke-v1vn(Q6=W4}Tvno?BQ6_DkT{GUkOjbQ@#NybX;%T}6 z@7%cMA`G6BHvND8{JEi()hxfILACpT-+iz5`z0k({P%z5 ze_d$*C13O5;O7Z1KXMkhKMpg?6JoG$m@tv$sFeNb4RQ9STdJ6!>{e_#IH zix)rsD)-wI+3w(wIL6DQpit~MA+bTPOk#ggO?FxTp_5I^N^>$4KUCPPSiipg_+v*C zslEB%-`)MLp!`c|2gi#?VvGuL4D;``x#|Ba;=QtBVYDiTpQ|^^rI#jq;`Fn!vL1Y_ zc<}vqe#N6s^?CcgUW=YR(eix^he<&q|1-9J zjP9JeuWs{p?Jc^w7gQKH9LhHJn-v9$gjO@J4B=WCqNQGCFxO9cs@GY5Uf$SGKhM|y z6a7%pDD%R2Qtve9WvkyDPGaB{WKj>=yupRRWbdC(r|tF4n5W%I_FD7p?(CWK0~rJA z{FYz7St{4h-lX6l;>yu9VRNq0%rjh#_usdxczP_q{G|TV-SYdf)sK74_c1B3>M=1C zu{KET=686oUHh8M_m;!ou5=x~sAy}=UHCh!VsBi>qomy~RSYjnq?!~IWcbqePgWJ{ z&fWEFR<@kWGIfQ6Pn8_}y9DbiC1zd|dU-R_`qhz#A}M8MVvP<8+DuE#9jbUHsVGYD z9C{tQ=LoB#gNfALZ?EJ3Ta{c54Zqv%wEUvT^{KU{U)4(+jo?kC?tNeK@&k&Rn&p-0?G_#M1tpWSRd-O|eQtp!?{iwRSHVFKlGm zx^-)`eD#}+)*D_h7Ki#fXfi|!E||Nt<{`JYx719@dMmev1wFho=FfLmns`Ex;pzWd zw{Gdby}SGS@`8mN8tn`>UE6rXS>_bVbo3m(CU?TL>S?*>Bm+CUzIE%?&FOS_Izd5# zr|!|mn6>c@ZhtP-?A<*WwtNxWu`rQ?tRXA+9lT4i^KOt-E4go%~Pbs z8kwk|v+zrRXjR>TD_26w4h5_gclgfYv~a>|7l&X5hV*x(D>gGa<+RU->}&Ww zZd9JNfByczdROLcjQP6#$P-PQ{STwgZi%h`f4}fp@Ad3|a<8j}*XQjt{k`|y8sm?R zzqh@*wp}9VvT?<`RsN2CqUy8C7MqK798Jo|&p&Tim#Xg|;<_YAv&Ctl|C^he)eDb` zhS!Km8n3uz!FX4(;ke`PnC)4r(`x^CmHfL~_2boRp5?P^KI`)QeIqXM>*)H;ukYEF za4fISTU>WY)pvsX@sRpYEzTe2Tx$(TZ-4c^C{~v#*IPi|deyJrB}>ALbs6^jxiN3f zwRa&_D|gp!JmWX7Y5hjV&+h%Rr!Akkgg0i|sT~a~S8A4&l|5r+WjqnR{ejY+viJA) z?f_-qZY@5sOM4EwHx=J$IK6HsSN+cqe=Xh`Ul&XL!yr(3KTGHD8TH~%-Td-;GyHO* z7e&hd-?TL2&8sUu(tTH)-@Lit^FF)h)ihgMi#eP3-fU6+I$^Qk?enXzDLG!-v(e-2 zPVP-Q$u%`L$7MZi7^+@pWo50qyD|BAdcaYC@k?(QJtinxI(#^H>ksRkt*OD0l}A!F z)8n7Fy9vyn>AJz~cbZ20ZK1P|m34BpBbF`SEPLrRdq_REiRr6lbEU0#JnYuBjvPDhe9zHwO)rQfh7{2NbHRMfk>JByd^Nj%)fzWHc)pufXr_6IED3CEYp z>)!rxb%J}}gC7SP-kB}@7IyajpVM6B$DL!nP3yVMvbhiZc_TCFaNGQxIHP+^N-645 z*VSxSPT#$E&Q~^#=$ftb7=HR)b6tEf*Sh@OAD&eo7AjdeGi;lF;gjB+Z9hM!$u#fz z%Uf+@anXLo?%EA|LpSF=Oucot-l-yTdD^d8&(hA89A{&jU}ZCF^Ip*?T{r0*`{PyJ zLbqOQtzs!IE^cdMIQ8CcK7Vg-uM5kb6-t5^<}*(?_-50SPx~D&ubR3{mLv7w#)FD? z-@dlGW%@d7v(N6*^UL193-W!Po#BvMJUMNb@ZaVYf_p1po>99XvGuP^wr&ca!_8ab zQhal4Dlc8Q5x-Xa!PlybSFVJV+A_YiWRRS|Vs@Od>3sLK{@NW6%Fh4)6khSxeE-oS zHzbz%9}BoF;J9Qb=kopg#aI9PqV_H&tPh?%m~iUGo)4A3PX7NDbuc&M)$->z<}wF_oL(Vt z=FFJ`ufM*kVmM*d>!4_2YMOg{YxeY-r&Gi2IRx{r7sy@)>*=b(A@}IN9}7MjrF7{`N4lzhVE6-CuXSnAN}7@#up`&cEh0c)S++ zaVz>Wi^859%~`v5bG>uU4OXr>ZS{%8A*binwj^WLS4&&J+;Z&Mdn>)-_v5CHr;N_; z12;C_$(}5;-f42<<;$0M2{*c_v9mflBqu+vt^W4rplEo^!wSI*eas9qav6or{>s}F zTb}P+b3S;-2DL@Ms+Ze0Y|%;1IG%Usul)W4tNPbE&rXVee7Ie3{!GUWZohMrlCq7L zY`=dpQe7vv+5ExzNb?WR9ChmB_a#(XSu4n%F!kEF(y{!0T3ef<7Vm)rm8-s21PJTA z6U<*TuYRqB>#nEa!pud%69V}gqG}j^-nnqW!Q}y?#gDf~ZWS{a?Jj2cC-&CUh+#(k zYk^%gig#^XI3nKhC+6lVUpl{U!uRT59|UFI%3shuetVb7{ygv7pSM+)KF^za#i8Kl zr9EHnt=P8X?D?aD@-ipy%ioRCJ8+=VIG4}i+?$Qpj?ACNvEnF`hWfuN&)Bc1v5Tv* zy|4cB@wh!R`~Gr=@1FaO7I$xV*x>KHWQW4EvUr93*a(vcoEh0?{g<0DR+PwmoH=vP z)vd8}D_{P#dbd59nc0K4f5kgz#W!~8X?hJC^Y-5|Yf@O@_i3f%&U;T3e%`xqp}=01 zsn2=yrOXY|oED~qLFLQO?fDbb(O9!sugo&?fUrQ`d2t3){sVs|CQI1QdB3|~=lv;f z`744AAOA64so3KxZ69#2Fd^4d``auV$r~No6aIv)Xw^^o@#R;BN!5SlejBI#GjAAX zuro1Eko-H_ex*Lc-TG3+s{G&b4cCLiEQ48X7`MJ%TbZfE#ed*WjLgT2fj&G>pI^PP zmuFvwih8YwI>(DA2PUM&awmU}H2$nFziaDtDL#&-hKCQ2eonY@q4x{Vu3fw4ReibW ze!lszQ(Ni1sng%*CAQwEW@g=P`T17%gVk#f9;}vkc=Og-b*gdh0a1xss2cK zPYdVe_o4=mL@z{&XXfYEzq;ry|M$k#@c6%6GD1dgPRwpRR{x)i?Ve3U!SiYPJD9}p z)~lcU^W?aV)^3xZ)I6oS^u|pc7C?8NC^rs z7rF7SZ{D?d5vF@}-z=T2Bko`A8i(9LvE;KoPJ-$s< z-bSAN{XfHdzxMM>dQJYw!n5Mn2hM%1x0@IhRuuXFTiF^`o#*oRCSxi0JO%~Y+~iSQ{P~;p`#r^Xr|CxfO~{hIcaZDR2?qs+44Xos-|z1Cz5f4pciZKb z=vjsT_SZ=At`1-HeqHX2YcHl<_ThO^&BU};_vYh}#g~|8NXfOaNPax5Jj2GWuS2O# z@zUL?zV9XsTE*jb9KCOp|<*`u%?z4uM) z*X1d4cvy3NIC5Y?p6%I`xM?}|`*(F8>YHNx_bDHH!_n)#qK}nA^29k<&b@v6_V3wk zxwmKR-G1xhdFF_B{15%V|F@R=b^pGWRqpLsaeuB}yS*{zuIjGaXGMxwx9Ku6blB-j ziTE>G)m5le`#U;_7%o&|=#_oTp?iey1uLVdkdk@esjZP+1{&(@-i3l}OpV3WRaM)X zE=n*Sx8rZnNoP6qV)tviKl6RflOJ{bGXAzD<3(KC=lylsQU6~#o&EE?jW5~nxNyMj zTWp*T8y3r6T)NY?;qhN)f%LS^m230ZCR@uNkDb4tlYQPl-#~VSOFKp0 z|9`H_VPdx8*<6hWoE;@woOswCyv}H~pYc26-N#193RcD0*Qc~Gv`i~?XzQ8a#;+jQ zpIr80!?Sk=drglEK9wzuYbyTZ!5lf{eC~w`wJc%+~M~ia7iiWAKkMbwm-LL z|M28MfM48Yss2x^UPrNcvK^ArJD~iRd4{~y#k=*!6HnWSPmS1eLAa^tV%FE*FFZDK z{I6cWuHT}-bY=&8X*|y(lUaM~OsZ-s9>4qj@f`R5=D z_AJ7DftpiVT*9LF{X5=Yo_}=ywmUu9|DWqxs{8fMI;JtpuEI_&bl1y`OzRx-y_K^< zV;YQ`ofn+X|MP*PkB{fsQ9YBn*LV$OoYuNYeCRxwc%o^WhP2>o>-GPc+W7W`7FaW6 zOl>e`K5A>dVA2X(eg_fP&_ISW#&xNYOLuWh*NNQp{-pZ+nBz?xA$Mmtas;mDm;d1Q z;T8Y8!ygYT-}rJjUoj?p_3UMtzWn$8{#^g*n*Yi>C!B>dGTrt%G4%2BCg;5{Xu7g% zg2JO6T?{7Wy3W4WgRW%rFeWuS?|OddL}ipD$Ku1;Z#eDc4xUhUd*hmU!K*R6i?Nea zfQ50n-&`jhvEbT+875M$$;pZ9WoL>Td}2{d^*`|byL(i0bp6iU+uQhaZf$w#68B8h z<@wBpP0BHitr3D8k3X#c_UPhL?~f~Q=Uu%U7W3h__Doruo|`K(eHl4T%oaI5WVHAd zXK!O%c%q9t^zAwa296TFR(pw%zqwfn5#M7ZtbB`}DQdKtrT*D-nV%&v`{mbZvesn^ z6Fn5BdMVEIQJU(NI=#edZmf!DlZt1=#w1o1PbUo#r}T97S+i$fymO~#-8#J%r-eCB z7F-i>czv^}NueaZ;$iFl$`=dU?W*2xy*{tO_wCwqG7ZMh8LvdV;(u^K+1jA;@^it= zOLY&@G=kReI{#UDvBf3(hRf$`=U44|=4PUImY3P?nmt3m*LK@w56|GE?=2H=g`GQ$5_T0b7uwJeLmsE!H@syd>Jj?Ht`>jKb5=J{~Q0RXQoR( z2Auca_r>+Q;@7;DJU>U4eYw_Dj8 z@7=qne~?Y^!du1}vI$9I%M~6UzW>i^TI?UiJwNB~KXRd7bf2-orClt!ckllC**E{f zo#(<-v=o2A5uz-1Aj;>^(k5_O*P2{4Y?7O!`?P)_uuoiw&b! zvj6hSmGeDM`~MGrw$`LBGPy^EgDGjF#J>;i_74s;G7GRUrq8c63tJo2I#oOT+1`0` zR=pSJm$A4|ey{Sm?TU@v(-zvZAE=nU@llsI2p48te{8YkyQfUu$J8^u&(E){a8~@1 zw=1rAQm=uf)v}%ETjluOls$!(u@~7gwCr4XSmL$3!}BgOcsUO}{Ll|dga`TSJ~W@7pyX?vVD*BB@y1-9ps?3xwDk|q43BGDxlvi-=Uwj^ zmUS|l9}9b&Iis|zM$$^pMm~4%bCEOU4_c?2eBXKD@xctY`>&6`Ibh(VRrzyrrcCwv z`x_YM$Vh0_$nnfkmwYqteA2`Pc{y3@9E5t5J%uj4EGd)y>BG-D<-*Sk`Lfee-)@8ioax{8OMv5g4LJFx4U z*_vOwzp5^+eSRR@;^QSo7u#3uDOCj#*M%L5znp)6Xuj9;N`{6ropx~#3Ll#UJGXPs zV_*FIWajf=e$)B;FTI$umm&4EzqGC7>&rj87tN5>ny`~8=}**Q1}obM)!BxTZfZzuF}`{jQ=iJx!`vk!>6u-Yhv1ZPnXyI zVNjCmPn-GiqG6Au$(`iC%nb}@_k2;}&J_Cmc}7%)XvD+hMg4c5pSjX%WVbf4;rQc| z#x#I>+n>b zd-wV2Gr1+1F5InIy72N|hNTZz>t0db%fs1tub64-qeW_5C4br3*ghN)_J8o>kZ~#>ltEvm(}~*KZZ>zf&ZA?gJawyY=(b8`QqtYsiZW z{@d(va0K$yHg7HYySw zJfc%xez9R<+3M-B^`5iV)I+EB_xBi|vtZT^UpMFT@4vPoJq!sKIo2JJVVE6$X0`u& z=PlKee%C9*;uRllF1}IteQ(9Pf9xA{<~8Vu)NMR{lJT!OE8~GPD>Z$-^1otxcZQEA z@yXrq1-Ihotet(mkXer5z>UgDym`+>t{nUOnAx|8d;@*g3o3Z}cZ7ow4iNwVplwYf6~v@PELODyZ-#cjlb(!Zk%Sn@RawE_N$q%mE7i? zGXC|hx}(2Oaml9-eoh`=63#R*q{OiVNi4p-p~|ki^$y>fv_Dzz>^{xDK6~e#sqbt| zAFJDPE&aIquDwIRXTAyVy_&^Y6s>&%&c(}E7O}i6vD#Dh^_6|A)55d)dp;g}{d`XG zx#lGj#{Em*2{c?iFtMy&pm+JBA8*UIB^{T&f3R!+KY_3>bGrL3`yak^+247`I=O|n zq&S)yf`x@Udi#?<9bw_y_oG^-=3V&=dC8+j`2x#-8<+2qynBA-!Ni`g{0Kfgbz|*wKmNKJY>DKg*VUm@PgmkZ@R0Aqy_usmm>F^yvk=!{h+ky z;iD3%Y`~Q=0tN*>xw%h14DXhjTSt%H=pv zknf;rapXZK$BIgo8asI_YwQ23mo8oEIW6_)QSo?_zsD9bt!FECTfXt&W7p!n{;d&9SQ5s*^?#E>LQ+!FVvVl*ooy&DpQs>5A zmKg;WQ`=_DyENm)?q3;SdghnO3w;bI`=2Sez+RZ^D;LL-bzjn-{+Q3hI7N7dv9a<0 zHyX^2t?)S7ze&Ye5<*RNjP`{Ru9`87XJxZ8bVpJ5UQZ_WX^q%U8-Z4p?p^+%MF!zso2FCR9{D{ok!%8*seRFbk- z#q(=$aB%G1yLY7-_pm9n8rkmoRnGh=j-lQ0#l`KHCY-34-}CV0+YSkh<9``G9sAD^ zqW+)Z>$N!Tr~qD5Mhz1~kALcuPlnpf&lhWu-+A$(s-(6qgQenio#TuJcY0YY^&8rH zp1$1uCqpn`_DA*GTIzqecK+XT?0=PDlh>4uj9a_|EIB>+Ib8yeJbCh@l7UHq|GnN~ z@dauOLIDR>o_TnK=i^ji=7qeo%yg=F{G{y}u5Pq@`!9I*NBxp`hPR~`q`5R$9V(fH zf3BG$pu*jtU{vPivS7<&kJ@Fu_L|HVv%IFv(r-`JtlMv|K4sdzm+f^e(=Wxk^91Ma z@xQj(mG2cV>x=VQ$JVPeH2kvW%_t1s-N_-z=)vHjq|B7H-dm$1h#`pK8RMBhFOHlz z%W!K#rvMM*hl4k=Z$=*O_POiIaL;$PS*#=n%cmGuKNrdJb6jcHPe1;sAi$v@!nJVS zI=+h;A_o%$4kifL&DXEi%Y3}twLwBOcg~U270;fB82PoTqQK;mvq+J$u}KL*rx9Ao#BPy{!1&SPHv21Tc38OD!Hy| zZ`}6yY>owge7;9aIn0#0K|n0`QnQg%+ERuIatsp$n}b6fefD14!Exw>M7?Z7+fwi8 zx9Tpg4m@eKant{!SDMeX{QmndC%=K`=?f0)gpY1Aw>C^^TcysR!oPIJ1(B%b<~#vX z_IHH?{)K;IT2cEoVdGcxwd@nz(vlpbe%SZAK2G0%GGuvLVkh&2sHi9|wF^?4r!sT9 zrH9F$I=g(zmoIB9{(9)`GIy8e%kB6g({QaK?(~e_ycx`G3=BeBKx+ZEysA3Pu=f3_ z+Qs(SM>#IM-f%-Qft4F^4)c;raFi&BaaORYiW>g`=RTe=8{f2_t z+P!D5Zj{?xEnVg(pmjM@Qhx8+SrQuRELVAg&R@8C#NoC1*4(M3mu~wnJvh0vYaZWS zh1r*wA6Q6ew@H*$vt7BgN^;VzmRa*S7>Fni-^+ZQQ^uX`^y-x%)0y-HOtYwfOU zW?7{MRx%e?wcotn{hGz$Jww>)|ML&bDqk=sZ2yh7+@=4oZZk}paca*C_Q@-9qgVLs z-Lz)Mv>0`UstqY?(he>vKkz1O{}q$gI_+Z-y&diwi&>)X@fts53TWMs@#X20#OVnV zbN_DNC8I5^rpFN46e`{+%)9@}mZd!UU6Mi5E_FrU)qV;Jm3;Q!E;aJ}2$Hs&5**F)486PYpc=o}iZ}JYiZS5OqJ^_KyY(EcdG^oQvPN?M z%;#6WG6nQ*cqLR4#1OdhY2nt!sMq2lw| zqvsh|UKOuQwL1HQQIDbTh^F4bF6n>&{vIekpDNbc7#)_z`BLlUIvo~q2KIoJm0X)o zt(;PEV)Enz@9rL$XM5oD`ROSiZhqpKwsO|Bg~#{{7!&x+k1>eMZr@XF{^7&n1NZi( zP7n9l`E*$#*!*n=nw}rbY}5DmWBPt)vf-bH4>z6Go}Oh;cc7KYfp699KmQr34H?`% zE4Jz1uhuVkHAORZTKDALf4pMU8Cb%2ulhvvFlwmD|D0E?Q2#o*&*V?gHhoiH%N=11 zt3;>u-ph#l^zZM4yj`80>+BXv^~yhBdQcJ3�lMUs)vdNw(bK-nO$_icYs4t!6vZ z!rgFq!!M_e+3OtY{+2Gu-y>*IapB6R(~Vcd?w=`saz(su7n9?Odi^<9pU#-OTyROd zoYSxT|I;KN>pzXKxcGzd9&-Yd^{)CgXGE9m|C8!o{xfpl`2!p=3|cJQaqQFbgeIx0 zPu%y>?SvZJ=PAyrypu#9v&U#Kq#a}2kZ`3np>R^byg4qfKAoQV^3(LDV5kq(CGs^l z*m-LB`zgIlc)Y$XZ2qyWOaZzUPtQ3tNY|^L(h}2S;9ES&R($hmui8(O`%LQ8Z90~O z>|ikPF|A}5nr-G-^{?{s=WUaU`dCk|wc{0MaNAJqcPMLm!l!SyC;sdIZ?Km~d*!pR z`EA?|tcjVAHU{Y(ys|FVqxSdhX;V^Jr{;Y3j|Ki&fByl z#<N`!Yqe5Vi;t2R_h%siI<<$)ARf2ySyKI`y~E(>osm)&c5$n+=)$d zggLc5g@RU2d1$lxC^#t|^wc|eq}O~_%w(lb7lDZVau0Q*AH03(Xf@Z(YOb5tqzQe; zz0Ylvy4w`PAaUfRo@4axe-k+l%{EuwQfd5TmzBz7&n03!`|jMHyL#o-l-Kxp z%nI%Q@<2hztn@GQ>+6g2ewTS{-8#cxO;Kes!>Rk`>YZ+y1&kXc!g}T$TVZDEva7~%OC!}caGXC^;AYN@5jyp#todidFL*>vg=IvhOhj& zcR1dw+jC|n9$FtU$HM=BGNUYyH`}@+Yj%aM`YOJAci`{4@0DVkK3ZS=aN0MTCx(IN zz$QcKqiAhiExG^sysj<(4t{<8 zaPK3o!?E?o-*(@Mu9dpZ>995EWVia(`ud}P{(Zgwd!FLs=WX#)F$^*tcek@0f4^_H zQtGwZr`i*&tV*_Ty#3*{Z+uSF=JU4V4961RUVCumfxoM>gJoLw9q1BQ4ABVfHEPmG=@3L!|TK=Pc>3us37*`nRNLcy?_W$ZSDp3C; z@wEE?O>6gimVZzET)H5=ft|^c=NeB;Z_w)*clPd9^8cy2wtnZseJs1Hd3B`h8o3+T z{0veS&k*|&+;|dX^N(k<6{lA&=6~RGSbzJOq{6ujA(Ioc6OY=d{r)v!O~Y&dX{mF5 z{5T%kE*YegcH{32JL~8$+f^HPuWx(JvOsF(kyhdT-{Vf7W?-0O)q14(eCRSA(K|wn z9uJrv7`&-OTN2}|!Wi8pX1m6!Ji_}EUWt@Q}~P+1@L*yXUk`C7Rc2A?L; z$6<@->^d9rVx9f!hi&Wm#2L7z_3n^3bBo{T=kt^~QrrP~QHyu@F|0azf-~%$|DA)A z>+gK%eQ3p4U^R7*#j@1}pU!Jsy>U0ZnVVtpjl02~Avd4Q4}3Fg;qyiZy<>UF>KDGw zSU2gU{o);GLCI9B<*s@Z_tvV=!%yZ1-Z40Ly_vg#b3>6)Z^Dvw#kOL$A1%WV;i$d8z$>^Dggp?7qMq3B2l9pQuI5=(hN^^L*%%Ic>A^ z&()=!jW~Ozd#X)WBX@)AhJ*)IXY79KJk$EXwXW^;y5@lR^C{W+VRdVs1aieNcr}zh zzSgwv`$helg-WSrId@LXY22XVRu=EQPMBdge}(GBTh}|~|1g~7a`}68O>Fl8SMjNH z<{iCuZ%s|aPQF-mhO1o{xwR}UI)wlKS}%58%~{#>n?76Kk89T&+qfH;HJ>Xxy$DdP@NVXIXx*6nNnNx%@yV=gt+2S& zi@ugOU7S5tUb|AFmAip;gVAB`l%Dzz`Tv$FZT{!5>}!8a`P`+c8?N1&J+)uIvoITTjB`CQHK6Rbab@amiyja)hI);c(Q`gT(`lD0L{^nf!amF2U zoWu{D*?d0fgYf(nKQ7lQzrLpEW0ond8vEM2@OSl1zKWRK~~u={47iVqaWxr%d|K`|9{_yRRPalHT`o$3e|F_61@NA_pc_*!+v0 z{d%!?yg^2}D39Q)mj_o(kKeXOX4+onvU?BJyB@VO$}krQ9WZHF8T9F)ErZ|1j)@#$ zHosP+{@k4NCGAOS`3HVpaR$GOYi?{j{9(=hz&Q_O6Apf;R0t4tnjdoQ{`u>Lw{ur$ zDnDpH#_5*tRtGMz9ahS0dQoD%_tw~)IkPDG?)98oU(4$hJ6H9m^}!dB=T3X=U|8{B%H(jp#&>yh zejZ?65xrY%l8WUihKQWEYfqfdicx1+Gk<{>591LJmec#cnQqCy*;Hc18#!%eQE#YH zec0z!ChLS5%$41gUfkKl`Zju-!ubi}3e3v?zVXEUlLhjdxib z^jLioR9faZy-w0v&aU95lo7LA%r`u?b=m55r?!2Yr1ElF=8`xEH;K5KkFHy`Z-0DZ zqH^k$Ed`Nlo<#P#`5$IIprlp!uwx>}tE%dY8_IN*rpGj$xV3dcs%5(2=6SNRvK6mZ zE-(0cHT?0_@Oa&o=ll$+R)0zn)MHRhy2|P_TgR|~;e)HvikEL&w=ZPex_9r2w{Kh3 zg-#llUApx8>iQW~+qZ6gT9cQZeYo=Z-1cS5md&}(^-zSlwEN=a9f|$L^SI`CsF=*> zI&+A10nh%W(S@IGzKFPY?YOV?35Q6F_eYgDnCi6(G&j_3dKnhOZ5&m;xzKCN{Hc{z zf}mE^fkUisOcrl9ieG++wV^O#SHfJ8m*Jf?s~JH}phU>K*I1iZr5|8rxLRo#S~Y*wQuBvqG3pFg zs~B%?jKA|@Za=8evf;GCs!Wc9Cl9bTB)&0g%93>}5PQgUfMHF{k2??UvWqux9lFID zfA%w*OmYFEf#5a$Jo7WM1zpYD4UTil1jAp?ZImf4T+0x0+4a<+eH#MU{XXvSW0+I7 za8poy*sFt@{MR`ff)majh*-^kTxJKu4W89)N3-@?JUhg?z-dEXz?$p*$-FTPK8G^d zo^1J5w5E}}fpOKrh*gR57SA3>F|J5!Uh%B<@r>@*EDLV+EWY)vJvcH(ox!h7+WL6T z4h91b^Af+M1 z-?@b0$izza2PzF8TT3$w7&SODe_m#o1RA1GgTkX1<$nId!GW$RZ@2F8>l z4crXvsZ)4wm>69zVw#z~`^ipSbIt=c426lYOb2+xdgp`WFP)G-bWW${8B+j*(LaBk zn(5+p)f+SZ)L6|sY?LA~`&d%G*}f0+&oEDEk!ER-vHA7c#;8p{X=C_-hZ}lYjvmu# zb=7x>EM~~d_{owbxY9L1M}}|pi4*5b^_c1UqFnGH9 KxvX`; } @@ -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 = ` -
- ${scrollToBottomBtn} - ${roots.map(c => this.renderComment(c, currentUserId)).join('')} -
- ${inputSection} - `; + let html = ''; + if (isLegacy) { + html = ` +
+ ${scrollToBottomBtn} + ${renderedHtml} +
+ ${inputSection} + `; + } else { + html = ` + ${inputSection} +
+ ${scrollToBottomBtn} + ${renderedHtml} +
+ `; + } + 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 `>>${id}`; + }); // 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 = `
${comment.replies.map(r => this.renderComment(r, currentUserId, true)).join('')}
`; } 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 = `${repliers.map(rid => `>>${rid}`).join(' ')}`; + } + } + return `
@@ -1424,20 +1767,22 @@ class CommentSystem {
${pinnedBadge}${comment.username ? `${this.escapeHtml(comment.display_name || comment.username)}` : 'System'} + ${contextMarker} + ${backlinkHtml}
- ${timeAgo} + ${timeAgo}
${content}
- #${comment.id} + #${comment.id}
${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 `${name}`; }).join(''); const extraPill = extra > 0 ? `+${extra}` : ''; - 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 @@

${esc(uname)}
-
${renderCommentContent(c.content || '')}
+
${renderCommentContent(c.content || '')}
${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')} - ${canReply ? `` : ''} + ${canReply ? ` + + + ` : ''}
`; - // 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 ? `:${esc(name)}:` : m; }); + + // 3. Replace >>ID patterns with context links + out = out.replace(/(? { + return `>>${id}`; + }); + 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 `>>${id}`; + }); + 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 ` Vocaroo Audio`; } ); @@ -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 @@
${timeStr} -
${displayContent}
+
${displayContent}
${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 `>>${id}`; + }); + 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.

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    CommandDescription
    /clearDeletes all messages and restarts history.
    /setbackground <url> [opts]Sets a background image. Opts: center / cover no-repeat etc.
    /clearbgRemoves the chat background.
    /settopic <text>Sets the pinned topic at the top of the chat.
    /cleartopicRemoves the pinned topic.
    +
    + +
    + +

    Craft the perfect background command or apply it instantly.

    +
    + + +
    + + +
    + + +
    + + +
    + +
    + Command: + /setbackground center / cover no-repeat + +
    + +
    + + +
    +
    + +
    +
    + + +
    +
    +@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') }} -
    -
    - - -
    - @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)