52 Commits

Author SHA1 Message Date
c30b3535ae smoothies 2026-07-28 22:30:04 +02:00
eed8b993e1 truncate . 2026-07-28 22:06:31 +02:00
9c239769e6 quote truncation 2026-07-28 22:00:58 +02:00
6e07194b27 possible fix for weird ass username display bug 2026-07-28 16:42:18 +02:00
866a8003fe fix comment quoting 2026-07-28 16:26:26 +02:00
5ab1a9614a d 2026-07-28 07:47:49 +02:00
e45df6a769 d 2026-07-28 07:46:06 +02:00
99eb9735a1 show pill 2026-07-28 06:38:20 +02:00
b651621cee foobarbaz 2026-07-28 06:30:50 +02:00
d3ce2e38f0 update readme 2026-07-28 03:34:57 +02:00
096786516e 503 2026-07-28 03:11:16 +02:00
ec895a285b onion rules 2026-07-28 03:08:46 +02:00
20aaf183d8 less is more 2026-07-28 03:01:30 +02:00
25ab4ba949 adding tor switch and update readme 2026-07-27 12:32:08 +02:00
6a52445987 custom location for CF Gate and removing box-shadow 2026-07-27 12:28:06 +02:00
63c39ebe90 deactivate captcha for onion 2026-07-27 10:26:05 +02:00
0a21b6c8f8 positioning is kinda weird for notic-count 2026-07-27 00:44:00 +02:00
a7aabac956 update example gate 2026-07-27 00:22:13 +02:00
9f5510bc04 add custom gate template for private society 2026-07-27 00:12:51 +02:00
54e2e8871b respect config settings 2026-07-26 23:24:28 +02:00
c99f3e11d8 fix invalid csrf token 2026-07-26 23:20:20 +02:00
d14feaf3eb correctly display url in gate 2026-07-26 23:10:41 +02:00
5e35fd48d4 Revert "adding hardcoded meta for onion"
This reverts commit a07c21f22b.
2026-07-26 23:07:27 +02:00
a07c21f22b adding hardcoded meta for onion 2026-07-26 23:04:45 +02:00
65fb96f675 f 2026-07-26 22:38:10 +02:00
20a6ffcb04 break long text in info modal 2026-07-26 22:27:57 +02:00
8518e87c8d fix pagination 2026-07-26 22:09:44 +02:00
a3148848f2 option to advertise onion url 2026-07-26 21:43:39 +02:00
abedf7a789 trying HS 2026-07-26 21:37:07 +02:00
e61e24dfe8 fix scroller tagging invalid csrf token 2026-07-26 21:20:00 +02:00
44af6ae061 mobile default tab selection 2026-07-26 20:45:16 +02:00
0739d72334 remove the fucking animations for new comments 2026-07-25 22:49:27 +02:00
848055446a enabling ipv6 for f0ckm-nginx 2026-07-25 22:38:34 +02:00
88e6b6efb9 persist hide item ratings 2026-07-25 21:52:47 +02:00
eea70ed62e fix quoting 2026-07-25 21:49:11 +02:00
b6c906636c bggg 2026-07-23 21:40:13 +02:00
55b5a0ba9f fuyo 2026-07-23 21:37:22 +02:00
5d60d80fc9 g 2026-07-23 21:35:36 +02:00
4dfb80b149 modern shit 2026-07-23 21:25:38 +02:00
b319f21cd4 favvv 2026-07-23 21:16:25 +02:00
28ed8d985c f0ck95d love 2026-07-23 21:14:32 +02:00
0f5e795793 fuck no 2026-07-23 20:51:06 +02:00
78d14a1b56 daisuki 2026-07-23 20:46:40 +02:00
3f149531da genki 2026-07-23 20:42:49 +02:00
7a4432277d fix tags page 2026-07-19 23:19:47 +02:00
84eee7e13d plastic surgery 2026-07-19 23:05:08 +02:00
2206b3eb86 fix modern layout 2026-07-19 23:01:28 +02:00
5529724982 sunshine daisy 2026-07-19 22:49:13 +02:00
9c6070afff xd 2026-07-19 19:53:37 +02:00
5bfa336773 staleness sucks 2026-07-19 19:44:54 +02:00
517444f9ce ignore svelte for prod 2026-07-19 19:43:21 +02:00
edb92ffa2a vert align top context link 2026-07-19 19:42:32 +02:00
27 changed files with 995 additions and 406 deletions

View File

@@ -1,9 +1,9 @@
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
POSTGRES_PASSWORD=f0ckm
# --- Nginx & Let's Encrypt Configuration (Optional) ---
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
# COMPOSE_PROFILES=f0ckm-nginx
# --- Nginx & Tor Profiles (Optional) ---
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
# COMPOSE_PROFILES=tor
#
# VIRTUAL_HOST=yourdomain.com
# LETSENCRYPT_HOST=yourdomain.com

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ public/tag_cache
config.json
bundle.css
public/s/fonts/impact.woff
f0ck-svelte/*

View File

@@ -78,3 +78,23 @@ now visit http://localhost:1337 in your browser, you can develop without needing
## NGINX
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
## Tor Hidden Service (.onion)
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
```bash
cat ./f0ckm-data/tor/f0ckm_hs/hostname
```
### Automatic Onion-Location Header
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
```json
"main": {
"onion": "http://yourgeneratedaddress.onion"
}
```

9
config/tor/torrc Normal file
View File

@@ -0,0 +1,9 @@
# Tor configuration for f0ckm
# Enable SOCKS proxy for internal container & external connections
SocksPort 0.0.0.0:9050
# Tor Hidden Service (v3 Onion Service) configuration
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
HiddenServiceDir /var/lib/tor/f0ckm_hs/
HiddenServicePort 80 f0ckm:1337

View File

@@ -5,6 +5,7 @@
"domain": "example.com",
"regex": "example\\.com"
},
"onion": "http://your-onion-address.onion",
"socks": "socks5://127.0.0.1:9050",
"mail": "admin@example.com",
"maxfilesize": 104857600,
@@ -61,6 +62,7 @@
"show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [],
"enable_tor_hs": true,
"enable_global_chat": true,
"enable_danmaku": true,
"private_messages": true,
@@ -132,6 +134,8 @@
"open_registration_require_mail_andor_token": false,
"private_society": false,
"private_society_gate": "cloudflare",
"private_society_gate_location": "Frankfurt",
"private_society_gate_template": "_gate_template",
"public_nsfw": false,
"paths": {
"images": "/b",

View File

@@ -49,9 +49,14 @@ services:
tor:
image: dockurr/tor
container_name: tor
profiles:
- tor
ports:
- "9050:9050"
- "8118:8118"
volumes:
- ./config/tor:/etc/tor:ro
- ./f0ckm-data/tor:/var/lib/tor:Z
networks:
- f0ckm-net
restart: always
@@ -99,6 +104,8 @@ services:
container_name: f0ckm-nginx
profiles:
- f0ckm-nginx
environment:
- ENABLE_IPV6=true
ports:
- "80:80"
- "443:443"

View File

@@ -1237,7 +1237,7 @@ html[theme='95'] .item-main-content .metadata {
.notif-count {
position: absolute;
top: 0px;
top: 0;
right: -5px;
background: var(--badge-nsfw);
color: white;
@@ -1650,6 +1650,10 @@ body.layout-modern .global-sidebar-right {
opacity: 1;
}
body.sidebar-right-hidden #sidebar-drag-zone {
opacity: 0.7;
}
/* Always show the handle on touch devices (no hover state available) */
@media (pointer: coarse) {
#sidebar-drag-zone {
@@ -1984,7 +1988,7 @@ body.layout-modern .global-sidebar-right {
right: 0;
width: 100%;
height: 20px;
background: linear-gradient(to bottom, transparent, rgba(28, 27, 27, 0.8));
background: linear-gradient(to bottom, transparent, var(--bg));
pointer-events: none;
}
@@ -2119,43 +2123,29 @@ body.layout-modern .global-sidebar-right {
font-size: 1.5em;
}
/* Mobile Stacking for Legacy Mode (max-width: 999px) */
/* Mobile sidebar (max-width: 999px): sidebar fixed, capped to viewport width */
@media (max-width: 999px) {
.item-layout-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 5px;
}
.item-layout-container .item-main-content {
order: 1;
/* Main content (video) on top */
/* padding: 10px; */
}
.item-sidebar-left {
order: 2;
/* Comments/Tags on bottom */
/* padding: 10px; */
z-index: 1;
}
/* Mobile Overrides if necessary — most styles now universal */
/* Sidebar itself: cap width to viewport, always right-anchored */
.item-sidebar-right,
.index-sidebar-right,
.global-sidebar-right {
width: 280px !important;
width: min(280px, 80vw) !important;
right: 0 !important;
}
body.sidebar-right-hidden .item-sidebar-right,
body.sidebar-right-hidden .index-sidebar-right,
body.sidebar-right-hidden .global-sidebar-right {
right: -280px !important;
right: calc(-1 * min(280px, 80vw)) !important;
}
/* Mobile: sidebar is an overlay, so pagination always spans full viewport */
.pagination-container-fluid,
/* Mobile: pagination centered in available space (left of sidebar) */
.pagination-container-fluid {
right: min(280px, 80vw) !important;
transition: right 0.3s ease-in-out !important;
}
/* Sidebar hidden: pagination expands to full viewport width */
body.sidebar-right-hidden .pagination-container-fluid {
right: 0 !important;
}
@@ -2166,6 +2156,99 @@ body.layout-modern .global-sidebar-right {
}
}
/* 600999px: sidebar is attached to the grid, same as desktop but with mobile width.
Grid reserves space for the sidebar so content is never hidden behind it. */
@media (min-width: 600px) and (max-width: 999px) {
.index-layout-wrapper {
padding-right: min(280px, 80vw);
}
body.sidebar-right-hidden .index-layout-wrapper {
padding-right: 0;
}
/* Generic pagewrapper (non-index, non-item pages) also reserves space */
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
padding-right: min(280px, 80vw);
}
body.sidebar-right-hidden
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
padding-right: 0;
}
/* Restore layout-modern item stacking at 600999px.
Its CSS grid only activates at ≥1000px; without flex-column here
the layout falls back to broken block flow. */
body.layout-modern .item-layout-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 5px;
}
body.layout-modern .item-layout-container .item-main-content {
order: 1;
}
body.layout-modern .item-sidebar-left {
order: 2;
z-index: 1;
}
/* Legacy layout: 2-column grid with mobile sidebar width. */
body.layout-legacy .item-layout-container {
display: grid;
grid-template-columns: minmax(0, 1fr) min(280px, 80vw);
height: auto !important;
overflow: visible !important;
}
body.layout-legacy.sidebar-right-hidden .item-layout-container {
grid-template-columns: minmax(0, 1fr) 0px;
}
}
/* Very small screens (≤599px): sidebar is always a pure overlay — never reserve space for it.
Wrappers stay at full width regardless of sidebar state; no transitions needed here.
Item view also switches to stacked layout here (not at 999px). */
@media (max-width: 599px) {
/* Item view: stack content above comments/sidebar-left */
.item-layout-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 5px;
}
.item-layout-container .item-main-content {
order: 1;
}
.item-sidebar-left {
order: 2;
z-index: 1;
}
.index-layout-wrapper,
.item-layout-container,
.pagewrapper,
body.sidebar-right-hidden .index-layout-wrapper,
body.sidebar-right-hidden .item-layout-container,
body.sidebar-right-hidden .pagewrapper {
padding-right: 5px !important;
transition: none !important;
}
.pagination-container-fluid,
body.sidebar-right-hidden .pagination-container-fluid {
right: 0 !important;
transition: none !important;
}
}
.item-layout-container .item-main-content>._204863,
.item-layout-container .item-main-content>.item_title,
@@ -2958,7 +3041,8 @@ body.layout-legacy .scroll-nav-wrapper {
right: -45px;
width: 40px;
pointer-events: none;
z-index: 1000;
z-index: 0;
display: none;
}
body.layout-legacy .scroll-to-bottom {
@@ -3064,7 +3148,7 @@ body.layout-legacy .scroll-to-bottom svg {
color: #888;
display: flex;
justify-content: space-between;
align-items: flex-end;
align-items: flex-start;
line-height: normal;
flex-wrap: nowrap;
gap: 2px 10px;
@@ -3072,8 +3156,9 @@ body.layout-legacy .scroll-to-bottom svg {
.comment-header-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
gap: 2px 6px;
min-width: 0;
flex-shrink: 1;
}
@@ -3196,9 +3281,13 @@ body.layout-legacy .scroll-to-bottom svg {
.comment-author {
font-weight: bold;
color: var(--accent);
margin-right: 8px;
margin-right: 2px;
padding-right: 0 !important;
word-break: break-word;
white-space: nowrap;
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-time {
@@ -3256,6 +3345,8 @@ body.layout-legacy .scroll-to-bottom svg {
font-size: 0.9em;
opacity: 1;
cursor: pointer;
vertical-align: top;
white-space: nowrap;
}
.comment-context-link:hover,
@@ -3319,10 +3410,7 @@ body.layout-legacy .scroll-to-bottom svg {
}
.comment-backlinks {
display: inline-block;
font-size: 0.85em;
opacity: 0.8;
margin-left: 10px;
display: contents;
}
.comments-header-actions {
@@ -3619,6 +3707,7 @@ html[theme="f0ck95"] img.avatar {
html[theme="f0ck95"] ._204863 {
content: " ";
background: -webkit-linear-gradient(left, #08216b, #a5cef7);
background: linear-gradient(90deg, #08216b, #a5cef7);
text-align: left;
width: 100%;
color: white;
@@ -3628,6 +3717,31 @@ html[theme="f0ck95"] ._204863 {
border-right: outset 2px silver;
border-bottom: outset 2px silver;
line-height: 1.7;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px;
box-sizing: border-box;
}
html[theme="f0ck95"] ._204863 .location {
display: inline-flex;
align-items: center;
gap: 6px;
}
html[theme="f0ck95"] ._204863 .location::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
background-size: contain;
background-repeat: no-repeat;
background-position: center;
image-rendering: pixelated;
image-rendering: crisp-edges;
flex-shrink: 0;
}
html[theme="f0ck95"] .iconset#a_delete {
@@ -3739,8 +3853,8 @@ html[theme='f0ck95d'] {
html[theme="f0ck95d"] .item_title {
background: black;
border-left: inset 3px silver;
border-right: outset 3px silver;
border-left: 2px solid gray;
border-right: 2px solid gray;
}
html[theme="f0ck95d"] .err {
@@ -3755,15 +3869,35 @@ html[theme="f0ck95d"] ._error_topbar {
html[theme="f0ck95d"] ._204863 {
content: " ";
background: -webkit-linear-gradient(left, #0e111a, #073c71);
background: linear-gradient(90deg, #0e111a, #073c71);
text-align: left;
width: 100%;
color: white;
height: auto;
border-left: inset 2px silver;
border-top: inset 2px silver;
border-right: outset 2px silver;
border-bottom: outset 2px silver;
border: 2px solid gray;
line-height: 1.7;
display: flex;
align-items: center;
padding: 0px 5px;
box-sizing: border-box;
}
html[theme="f0ck95d"] ._204863 .location {
display: inline-flex;
align-items: center;
gap: 6px;
}
html[theme="f0ck95d"] ._204863 .location::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
background-size: contain;
background-repeat: no-repeat;
background-position: center;
flex-shrink: 0;
}
html[theme="f0ck95d"] .embed-responsive-image {
@@ -3772,15 +3906,13 @@ html[theme="f0ck95d"] .embed-responsive-image {
}
html[theme="f0ck95d"] .media-object {
border-left: inset 3px silver;
border-right: outset 3px silver;
border-left: 2px solid gray;
border-right: 2px solid gray;
background: black;
border-bottom: 2px solid gray;
}
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
background: black;
border-left: inset 1px silver;
border-right: outset 1px silver;
}
html[theme="f0ck95d"] .embed-responsive-image {
border: none;
@@ -3896,6 +4028,11 @@ html[theme="f0ck95d"] .admin-search button {
color: black;
}
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
background: black;
}
/* fullscreen */
html[res="fullscreen"] ._204863 {
border-top: none;
@@ -4488,16 +4625,17 @@ span.placeholder {
transition: right 0.3s ease-in-out;
}
/* Sidebar hidden — reclaim the full width */
body.sidebar-right-hidden .pagination-container-fluid {
right: 0;
}
/* Modern layout: wider sidebar needs wider offset */
body.layout-modern .pagination-container-fluid {
right: 400px;
}
/* Sidebar hidden — reclaim the full width */
body.sidebar-right-hidden .pagination-container-fluid,
body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
right: 0;
}
.bottom-pagination.fixed-pagination {
margin: 10px auto;
pointer-events: auto;
@@ -4506,7 +4644,6 @@ body.layout-modern .pagination-container-fluid {
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
padding: 2px;
max-width: fit-content;
}
@@ -4719,7 +4856,7 @@ body.layout-modern .pagination-container-fluid {
min-width: 0;
}
@media (min-width: 1200px) {
@media (min-width: 1000px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
.index-layout-wrapper {
@@ -7196,6 +7333,11 @@ span#favs {
border-top: 1px solid var(--nav-border-color);
}
body.layout-modern span#favs {
border-top: none;
}
span#favs[hidden] {
display: none !important;
}
@@ -7702,6 +7844,7 @@ div.posts>a.thumb > p {
bottom: 5px;
left: 5px;
gap: 2px;
align-items: center;
}
/* Make individual item entry subtler and faster for infinite scroll */
@@ -9495,77 +9638,16 @@ body.layout-legacy .comment-content img.emoji {
max-width: 200px;
}
@keyframes flashGreen {
0% {
background-color: rgba(var(--accent-rgb), 0.3);
}
100% {
background-color: var(--item-bg);
}
}
@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;
}
}
.new-item-fade {
animation: newItemFlash 2.5s ease forwards;
border-left: 3px solid transparent;
box-shadow: inset 3px 0px 0px var(--accent);
}
.sidebar-activity .new-item-fade {
border-left: none;
animation: none;
box-shadow: none;
}
/* 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;
}
}
.comment-entering {
animation: commentEnter 7.5s ease forwards;
border-left: 3px solid transparent;
}
.comment-highlighted {
box-shadow: inset 3px 0px 0px var(--accent) !important;
@@ -9747,6 +9829,20 @@ html[theme="f0ck95d"] .badge-dark {
}
}
/* At 600999px: center the button in the free space left of the sidebar.
Shift left by half the sidebar width so transform:translateX(-50%) lands
at the center of the available area rather than the full viewport. */
@media (min-width: 600px) and (max-width: 999px) {
.mobile-scroll-to-top {
left: calc(50% - min(140px, 40vw));
transition: left 0.3s ease-in-out;
}
body.sidebar-right-hidden .mobile-scroll-to-top {
left: 50%;
}
}
.steuerung.steuerung-icon .fa-solid {
font-size: 0.85em;
}
@@ -9888,14 +9984,6 @@ html[theme="orange"] .image-brand {
}
}
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
border-bottom: outset 2px silver;
}
html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
border-bottom: outset 2px silver;
}
/* Fade in video on page load */
@keyframes fadeIn {
@@ -9923,7 +10011,7 @@ html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
position: relative;
}
@media (min-width: 1200px) {
@media (min-width: 1000px) {
.pagewrapper {
padding-right: 300px;
}
@@ -9941,7 +10029,7 @@ html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
Since Index and Item pages have their own layout wrappers that handle space reservation,
we disable the generic pagewrapper padding on those pages to avoid "double padding" or broken expansion.
*/
@media (min-width: 1200px) {
@media (min-width: 1000px) {
.pagewrapper:has(.index-layout-wrapper),
.pagewrapper:has(.item-layout-container),
@@ -10090,7 +10178,7 @@ ol {
display: flex;
align-items: center;
gap: 20px;
padding: 0 15px;
padding: 0 5px;
flex-shrink: 0;
margin-left: auto;
}
@@ -10207,7 +10295,7 @@ ol {
}
/* ── Mobile: ≤768px ── */
@media (max-width: 768px) {
@media (max-width: 800px) {
/* Show hamburger */
.navbar-toggler {
@@ -11070,8 +11158,7 @@ div.posts>a::after {
.pin-indicator {
position: absolute;
top: 5px;
position: relative;
left: 5px;
font-size: 13px;
fill: var(--accent);
@@ -11393,11 +11480,12 @@ body.layout-legacy .navbar-header {
}
/* layout-modern: restore sidebar right-padding AND block-flow for generic pages
(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) {
(profile, settings, subscriptions, tags, etc.) — identical behaviour to layout-legacy.
Specialized pages (index, item, meme) manage their own layout, so exclude them.
Must be ≥1000px so the gap at 10001199px is covered. */
@media (min-width: 1000px) {
body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
padding-right: 400px;
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)) {
@@ -12966,10 +13054,8 @@ body.layout-modern .tag-ac-input {
Full-height chat layout — conversation page
══════════════════════════════════════════════════════════ */
/* ── DESKTOP (> 768px): same pattern as the sidebar ───────
position:fixed takes it out of flow entirely — no pagewrapper
padding, no body scroll involvement, just viewport-anchored. */
@media (min-width: 769px) {
/* ── UNIVERSAL: position:fixed takes it out of flow entirely ───────
No pagewrapper padding, no body scroll involvement, just viewport-anchored. */
.messages-convo-page {
position: fixed !important;
top: var(--navbar-h, 50px) !important;
@@ -12982,26 +13068,21 @@ body.layout-modern .tag-ac-input {
transition: right 0.3s ease-in-out;
}
@media (min-width: 1200px) {
/* Desktop: 300px reserved space (both layouts) */
@media (min-width: 1000px) {
body:not(.sidebar-right-hidden) .messages-convo-page {
right: 300px !important;
}
body.layout-modern:not(.sidebar-right-hidden) .messages-convo-page {
right: 400px !important;
}
/* Attached Mobile: mobile sidebar width reserved space */
@media (min-width: 600px) and (max-width: 999px) {
body:not(.sidebar-right-hidden) .messages-convo-page {
right: min(280px, 80vw) !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;
}
}
/* ── MOBILE (≤ 768px): position:fixed pagewrapper ─────────
Works on mobile; locks the pagewrapper to the viewport. */
@media (max-width: 768px) {
/* Kill the pagewrapper min-height and overflow so body doesn't scroll behind it */
html:has(.messages-convo-page),
body:has(.messages-convo-page) {
height: 100%;
@@ -13009,16 +13090,9 @@ body.layout-modern .tag-ac-input {
}
body:has(.messages-convo-page) .pagewrapper {
position: fixed;
top: var(--navbar-h, 50px);
left: 0;
right: 0;
bottom: 0;
min-height: 0 !important;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
padding: 0 !important;
}
/* ── Shared: convo pane is a flex column ──────────────────
@@ -13028,7 +13102,8 @@ body.layout-modern .tag-ac-input {
overflow: hidden;
padding: 0;
max-width: 720px;
width: 100%;
/* Removed width: 100% here because it overconstrains the fixed element
against left:0 and right:300px, causing negative margins and overlap. */
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -16060,12 +16135,21 @@ body.scroller-active #gchat-reopen-bubble {
color: #ccc;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.9em;
word-break: break-word;
overflow-wrap: anywhere;
}
.info-table td a {
color: var(--accent, #9f0);
text-decoration: none;
font-weight: 600;
word-break: break-all;
overflow-wrap: anywhere;
}
#info-modal .modal-content .info-table td a {
word-break: break-all;
overflow-wrap: anywhere;
}
.info-table td a:hover {

View File

@@ -13,8 +13,9 @@
/* Meme Creator Styles */
.meme-creator-container {
box-sizing: border-box !important;
padding: 20px;
max-width: 1200px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
position: relative;
@@ -111,6 +112,7 @@
.canvas-wrapper {
flex: 1;
max-width: 650px;
min-width: 0; /* Allow shrinking */
background: #000;
border: 2px solid var(--nav-bg, #2b2b2b);
@@ -238,22 +240,49 @@ canvas#memeCanvas {
color: var(--white, #fff);
}
/* Mobile Stacking - Simple 2-Row Layout */
/* Mobile Stacking - Clean Flexbox Column Layout */
@media (max-width: 950px) {
.meme-editor-layout {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 0.6fr auto !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 20px;
}
.meme-controls {
box-sizing: border-box !important;
width: 100% !important;
grid-row: 2;
max-width: 500px !important;
overflow: visible !important;
}
.canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
grid-row: 1;
max-width: 650px !important;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
}
}
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
This prevents the canvas from shrinking too much when the sidebar takes space. */
@media (max-width: 1250px) {
body:not(.sidebar-right-hidden) .meme-editor-layout {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 20px;
}
body:not(.sidebar-right-hidden) .meme-controls {
box-sizing: border-box !important;
width: 100% !important;
max-width: 500px !important;
overflow: visible !important;
}
body:not(.sidebar-right-hidden) .canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
max-width: 650px !important;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
@@ -294,20 +323,19 @@ canvas#memeCanvas {
/* Sidebar space reservation for meme pages */
.meme-layout-wrapper {
box-sizing: border-box !important;
width: 100%;
transition: padding-right 0.3s ease-in-out;
}
@media (min-width: 1200px) {
@media (min-width: 1000px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
.meme-layout-wrapper {
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
padding-right: 300px;
}
}
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0;
}
padding-right: 0 !important;
}

View File

@@ -54,7 +54,7 @@ class CommentSystem {
document.body.classList.remove('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.remove('sidebar-hidden');
} else if (isHidden) {
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
this.container.classList.add('faded-out');
this.container.style.display = 'none';
document.body.classList.add('sidebar-left-hidden');
@@ -596,7 +596,7 @@ class CommentSystem {
if (cached) cached.content = fullContent;
}
contentEl.dataset.raw = this.escapeHtml(fullContent);
contentEl.dataset.raw = fullContent;
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl);
@@ -622,6 +622,7 @@ class CommentSystem {
const contentEl = el.querySelector('.comment-content');
if (contentEl) {
contentEl.dataset.raw = data.content;
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl);
@@ -857,26 +858,14 @@ class CommentSystem {
* border-right) is fully restored after the effect completes.
*
* @param {Element} el - The .comment element to animate.
* @param {boolean} entering - True to also add 'comment-entering' (own post);
* false for 'new-item-fade' only (live others).
* @param {boolean} entering - Unused (kept for compatibility).
*/
static _animateNewComment(el, entering = false) {
if (!el) return;
const classes = entering
? ['comment-entering', 'new-item-fade']
: ['new-item-fade'];
// Reset in case the element already has a stale animation
classes.forEach(c => el.classList.remove(c));
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
classes.forEach(c => el.classList.add(c));
const cleanup = () => {
classes.forEach(c => el.classList.remove(c));
};
el.addEventListener('animationend', cleanup, { once: true });
el.classList.add('new-item-fade');
setTimeout(() => {
el.classList.remove('new-item-fade');
}, 2500);
}
@@ -892,7 +881,6 @@ class CommentSystem {
if (el) {
// Remove highlight from any previously highlighted or newly-posted comment
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
document.querySelectorAll('.comment-entering').forEach(c => c.classList.remove('comment-entering'));
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
// Always smooth-scroll so there's no jarring jump
@@ -1038,7 +1026,7 @@ class CommentSystem {
preview.dataset.id = targetId;
// Remove temporary animation/highlight classes
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
preview.classList.remove('new-item-fade', 'comment-highlighted');
// Remove input forms or action buttons
const actions = preview.querySelector('.comment-actions');
@@ -1172,12 +1160,13 @@ class CommentSystem {
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
const contentEl = body.querySelector('.comment-content');
if (contentEl) {
const LINE_MAX = 200;
const rawText = (contentEl.dataset.raw || '').trim();
// Preserve all lines but cap any single line exceeding LINE_MAX chars
const lines = rawText.split('\n').map(line =>
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
);
let rawText = (contentEl.dataset.raw || '').trim();
if (rawText.includes('>') || rawText.includes('<') || rawText.includes('&')) {
const txt = document.createElement('textarea');
txt.innerHTML = rawText;
rawText = txt.value;
}
const lines = rawText.split('\n');
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
if (isNew) {
@@ -1632,18 +1621,26 @@ class CommentSystem {
syncLevel(roots, list, false);
}
// Maximum characters to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
// Maximum characters & lines to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 500; }
static get ITEM_VIEW_MAX_LINES() { return 6; }
renderCommentContent(content, commentId = null, bypassTruncation = false) {
if (!content) return '';
// Truncate extremely long comments before any processing
// Truncate long comments before any processing
let truncated = false;
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
if (!bypassTruncation) {
const lines = content.split('\n');
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
truncated = true;
}
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
truncated = true;
}
}
if (typeof marked === 'undefined') {
console.warn('Marked.js not loaded, falling back to plain text');
@@ -1791,9 +1788,10 @@ class CommentSystem {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '>');
const renderedContent = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: quoteContent;
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${renderedContent}</span>`;
}
@@ -2199,7 +2197,7 @@ class CommentSystem {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
renderCommentAttachments(files, content = '') {
@@ -3336,9 +3334,15 @@ class CommentSystem {
params.append('has_poll', '1');
}
const csrfToken = window.f0ckSession?.csrf_token || '';
if (csrfToken) params.append('csrf_token', csrfToken);
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: params
});
@@ -3604,9 +3608,9 @@ class CommentSystem {
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
// optimistic DOM insert to guarantee the button regardless of rendering path.
_ensureTruncationButton(commentEl, rawContent) {
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
console.log('[ensureBtn] below threshold, skipping');
if (!rawContent) return;
const lineCount = rawContent.split('\n').length;
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
return;
}
const contentEl = commentEl.querySelector('.comment-content');
@@ -3808,10 +3812,13 @@ class CommentSystem {
toggleComments() {
if (!this.container) return;
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
if (document.body.classList.contains('layout-modern')) {
if (window.innerWidth < 1000) return;
if (typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
}
}
const layout = this.container.closest('.item-layout-container');
const sidebar = this.container.closest('.item-sidebar-left');
@@ -4457,20 +4464,71 @@ class CommentSystem {
}, { passive: true });
};
// Helper: reliable touch handling for tab buttons on mobile
const addTabTouch = (btn, cb) => {
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastHoldTime = 0;
const addTabTouch = (btn, cb, onHold) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// Build tab buttons — Favorites first
@@ -4482,23 +4540,13 @@ class CommentSystem {
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
favTab.addEventListener('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
// Right-click on fav tab → set/unset as default
favTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
if (Date.now() - lastHoldTime < 1000) return;
toggleFavDefault(favTab);
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {

View File

@@ -2307,9 +2307,49 @@ window.cancelAnimFrame = (function () {
hidden = hiddenValue === 'true';
}
document.body.classList.toggle('sidebar-right-hidden', hidden);
// Always force-hide on very small screens regardless of saved preference
if (window.innerWidth <= 599) {
document.body.classList.add('sidebar-right-hidden');
}
};
window.initSidebarRightToggle();
// Auto-hide sidebar on very small screens (≤599px), restore on wider
(() => {
const mq = window.matchMedia('(max-width: 599px)');
const applyBreakpoint = (e) => {
// Freeze transitions so the class change is instant (no sliding during resize)
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
const wrappers = document.querySelectorAll('.index-layout-wrapper, .item-layout-container, .pagination-container-fluid, .pagewrapper');
if (sidebar) sidebar.style.setProperty('transition', 'none', 'important');
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
if (e.matches) {
// Entering narrow: force-hide without touching localStorage
document.body.classList.add('sidebar-right-hidden');
} else {
// Leaving narrow: restore from saved preference
const saved = localStorage.getItem('sidebarRightHidden');
const hidden = saved === 'true';
document.body.classList.toggle('sidebar-right-hidden', hidden);
}
// Force reflow then restore transitions
if (sidebar) void sidebar.offsetWidth;
requestAnimationFrame(() => {
if (sidebar) sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
mq.addEventListener('change', applyBreakpoint);
applyBreakpoint(mq); // run once on init
})();
const loadPageAjax = async (url, replace = true, options = {}) => {
if (isNavigating) return;
@@ -4427,6 +4467,10 @@ window.cancelAnimFrame = (function () {
if (window.renderTags) {
window.renderTags(res.tags);
}
// Evict the cached item HTML so navigating back fetches fresh content
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
} else {
revert();
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
@@ -6277,8 +6321,10 @@ window.cancelAnimFrame = (function () {
if (settled) return;
settled = true;
// Freeze all transitions (override CSS !important) so the class toggle
// doesn't trigger an additional right-property animation.
// Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger a second padding-right animation.
const wrappers = getSidebarWrappers();
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) {
@@ -6296,6 +6342,11 @@ window.cancelAnimFrame = (function () {
// Restore CSS-controlled transitions on the next frame
requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
swipeRT.isDraggingSidebar = false;
});
};
@@ -6533,6 +6584,10 @@ window.cancelAnimFrame = (function () {
const settle = () => {
if (settled) return;
settled = true;
// Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger a second padding-right animation.
const wrappers = getSidebarWrappers();
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) {
@@ -6545,6 +6600,11 @@ window.cancelAnimFrame = (function () {
sidebar.style.transform = '';
requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
@@ -6612,15 +6672,55 @@ window.cancelAnimFrame = (function () {
});
window.addEventListener('resize', syncEdgeZone, { passive: true });
// Touchend on edgeZone: instant tap-to-toggle (no 300ms synthetic click delay)
// Returns true if (clientX, clientY) is within the pill's hit area.
// The pill is #sidebar-drag-zone::after — 4×40px, centered on pointer:fine,
// right-aligned with padding-right:6px on pointer:coarse.
const isWithinPillArea = (clientX, clientY) => {
const rect = edgeZone.getBoundingClientRect();
const pillH = 40;
const hitPad = 12; // generous padding so it's easy to hit
// Vertical: pill is always centered in the drag zone
const pillCenterY = rect.top + rect.height / 2;
if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false;
// Horizontal: right-aligned on touch, centered on mouse
const isCoarse = window.matchMedia('(pointer: coarse)').matches;
const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2;
return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad;
};
// Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe
edgeZone.addEventListener('touchend', e => {
// Only if it was a clean tap (no significant swipe movement)
if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
e.preventDefault();
edgeZone.dispatchEvent(new Event('click'));
e.preventDefault(); // always prevent 300ms synthetic click
const touch = e.changedTouches[0];
if (touch && isWithinPillArea(touch.clientX, touch.clientY)) {
window.toggleSidebarRight();
}
}
}, { passive: false });
// Helper: all layout wrappers that have padding-right animated by sidebar state
const getSidebarWrappers = () => {
// At ≤999px: sidebar is a pure overlay — only pagination tracks it.
if (window.innerWidth <= 599) {
return document.querySelectorAll('.pagination-container-fluid');
}
// At ≥600px: exclude pagewrapper when a dedicated layout wrapper is present.
// CSS already zeroes pagewrapper.padding-right via :has on those pages,
// so including it here causes double-padding (pagewrapper + index-layout-wrapper both
// get 300px snapped, making the grid 600px too narrow, then jumping back on settle).
const hasDedicatedWrapper = !!(
document.querySelector('.index-layout-wrapper') ||
document.querySelector('.item-layout-container') ||
document.querySelector('.meme-layout-wrapper') ||
document.querySelector('.messages-convo-page')
);
const sel = hasDedicatedWrapper
? '.index-layout-wrapper, .meme-layout-wrapper, .messages-convo-page, .pagination-container-fluid'
: '.pagination-container-fluid, .pagewrapper';
return document.querySelectorAll(sel);
};
// Smoothly toggle sidebar open/closed
window.toggleSidebarRight = () => {
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
@@ -6630,6 +6730,29 @@ window.cancelAnimFrame = (function () {
const sidebarWidth = sidebar.offsetWidth || 300;
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
// At ≤599px the sidebar is a pure overlay — never animate wrapper padding
const animateWrappers = window.innerWidth > 599;
// Grid/pagewrapper: snap instantly in both directions (no animated reflow).
// Pagination always animates — it tracks sidebar position with no reflow cost.
const wrappers = getSidebarWrappers();
if (animateWrappers) {
wrappers.forEach(w => {
const isPagination = w.classList.contains('pagination-container-fluid');
const isMessages = w.classList.contains('messages-convo-page');
const prop = (isPagination || isMessages) ? 'right' : 'padding-right';
const targetVal = isHidden ? sidebarWidth + 'px' : '0px';
// Lightweight layouts (pagination, messages) animate smoothly.
// Heavy/reflow-intensive layouts snap instantly to avoid reflow lag.
const shouldAnimate = (isPagination || isMessages);
w.style.setProperty('transition', shouldAnimate ? `${prop} 0.3s ease-in-out` : 'none', 'important');
w.style.setProperty(prop, targetVal, 'important');
});
void sidebar.offsetWidth; // settle layout before sidebar transform begins
}
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
sidebar.style.transform = `translateX(${targetTranslate}px)`;
@@ -6637,13 +6760,22 @@ window.cancelAnimFrame = (function () {
const settle = () => {
if (settled) return;
settled = true;
// Freeze wrapper transitions so the class commit doesn't re-trigger them
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important');
const toHidden = !isHidden;
document.body.classList.toggle('sidebar-right-hidden', toHidden);
localStorage.setItem('sidebarRightHidden', toHidden);
void sidebar.offsetWidth;
sidebar.style.transform = '';
requestAnimationFrame(() => sidebar.style.removeProperty('transition'));
requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
const onEnd = ev => {
@@ -6655,9 +6787,11 @@ window.cancelAnimFrame = (function () {
setTimeout(settle, 350);
};
// Click on edgeZone: smoothly toggle sidebar open/closed
edgeZone.addEventListener('click', () => {
// Click on edgeZone: only toggle when the click lands on the pill
edgeZone.addEventListener('click', (e) => {
if (isWithinPillArea(e.clientX, e.clientY)) {
window.toggleSidebarRight();
}
});
// </mouse-sidebar-drag>
@@ -6677,7 +6811,9 @@ window.cancelAnimFrame = (function () {
});
const isLSActive = () =>
document.body.classList.contains('layout-modern') && !!document.querySelector('.item-sidebar-left');
document.body.classList.contains('layout-modern') &&
!!document.querySelector('.item-sidebar-left') &&
window.innerWidth >= 1000;
// Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX.
// OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
@@ -6936,6 +7072,7 @@ window.cancelAnimFrame = (function () {
};
window.toggleSidebarLeft = () => {
if (window.innerWidth < 1000) return;
const { sb, layout } = getLS();
if (!sb || !layout) return;
const isHidden = layout.classList.contains('sidebar-hidden');
@@ -7430,30 +7567,49 @@ class NotificationSystem {
// Helper: apply a display name change to all relevant DOM nodes
this.applyDisplayNameUpdate = (display_name, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
// Capture old value before overwriting — needed for thumb matching below
const oldDisplayName = window.f0ckSession?.display_name || null;
if (window.f0ckSession) window.f0ckSession.display_name = display_name || null;
const oldDisplayName = isSelf ? (window.f0ckSession?.display_name || null) : null;
if (isSelf && window.f0ckSession) {
window.f0ckSession.display_name = display_name || null;
}
const userName = user || window.f0ckSession?.user;
// Update ALL nav-display-name instances (handles the profile page having a duplicate ID)
// Update navbar display name only if this update is for the logged-in user
if (isSelf) {
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
// preserve the badges (Admin, Mod)
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
let node = nameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
break;
}
}
});
}
// Update profile header display name if currently viewing this user's profile
const profileBox = document.getElementById('live-profile-preview-box');
if (profileBox && profileBox.dataset.bannerUser && userName && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
const profileNameSpan = document.getElementById('profile-display-name');
if (profileNameSpan) {
for (let i = profileNameSpan.childNodes.length - 1; i >= 0; i--) {
let node = profileNameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
break;
}
}
}
const bracket = document.getElementById('username-bracket');
if (bracket) {
bracket.style.display = display_name ? 'inline' : 'none';
}
}
if (userName) {
const lowerHref = `/user/${userName.toLowerCase()}`;
@@ -7479,6 +7635,7 @@ class NotificationSystem {
// Helper: apply a username color change to relevant DOM nodes
this.applyUsernameColorUpdate = (username_color, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
const userName = user || window.f0ckSession?.user;
if (!userName) return;
@@ -7525,22 +7682,21 @@ class NotificationSystem {
}
});
// Special case for settings page preview block
if (window.f0ckSession && userName === window.f0ckSession.user) {
const previewBlock = document.getElementById('live-profile-preview-box');
if (previewBlock) {
// Profile header display name color (if currently viewing this user's profile)
const profileBox = document.getElementById('live-profile-preview-box');
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
const profileNameSpan = document.getElementById('profile-display-name');
if (profileNameSpan) {
if (username_color) {
previewBlock.style.setProperty('--author-accent', username_color);
previewBlock.style.setProperty('--author-border', username_color);
profileNameSpan.style.color = username_color;
} else {
previewBlock.style.setProperty('--author-accent', 'var(--accent)');
previewBlock.style.setProperty('--author-border', 'var(--accent)');
profileNameSpan.style.removeProperty('color');
}
}
}
// Handle the nav-display-name span elements (profile header and top navbar)
if (window.f0ckSession && userName === window.f0ckSession.user) {
// Navbar display name color (only for current user)
if (isSelf) {
document.querySelectorAll('#nav-display-name').forEach(el => {
if (username_color) {
el.style.color = username_color;
@@ -7553,14 +7709,21 @@ class NotificationSystem {
// Helper: apply an avatar update to relevant DOM nodes
this.applyAvatarUpdate = (avatar_file, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
const userName = user || window.f0ckSession?.user;
if (!userName) return;
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
// Update Nav Avatar & profile header Avatar (only for current user)
if (window.f0ckSession && userName === window.f0ckSession.user) {
document.querySelectorAll('.nav-avatar-img, .profile_head_avatar img').forEach(img => img.src = newSrc);
// Update Nav Avatar only for current user
if (isSelf) {
document.querySelectorAll('.nav-avatar-img').forEach(img => img.src = newSrc);
}
// Update profile header avatar if currently viewing this user's profile
const profileBox = document.getElementById('live-profile-preview-box');
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
document.querySelectorAll('.profile_head_avatar img').forEach(img => img.src = newSrc);
}
// Update globally (comments, sidebar, polls, rank, infoboxes, favs)

View File

@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
dmGridArea = document.createElement('div');
dmGridArea.className = 'emoji-picker-grid';
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastDmHoldTime = 0;
const addDmTabTouch = (btn, cb, onHold) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastDmHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleDmFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// ── Favorites tab ──
const favsTab = document.createElement('button');
favsTab.className = 'ep-tab ep-tab-favs';
@@ -1985,22 +2052,13 @@ if (window.__dmLoaded) {
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
favsTab.addEventListener('mousedown', e => e.preventDefault());
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
// Right-click on fav tab → set/unset as default
favsTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favsTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favsTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
if (Date.now() - lastDmHoldTime < 1000) return;
toggleDmFavDefault(favsTab);
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
}
tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
dmTabBar.appendChild(tab);
});

View File

@@ -1000,9 +1000,10 @@
e.stopPropagation();
const itemId = slide.dataset.id;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
const data = await resp.json();
if (data.success) pill.remove();
@@ -1157,10 +1158,14 @@
if (nowFaved) flashFav(slide);
}
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/togglefav', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `postid=${id}`
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
});
const data = await resp.json();
// Sync count to server truth (handles race conditions)
@@ -1582,9 +1587,10 @@
e.stopPropagation();
const itemId = slide.dataset.id;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
const data = await resp.json();
if (data.success) pill.remove();
@@ -1850,11 +1856,12 @@
else if (item.external_board === 'gif') rating = 'nsfw';
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/scroller/rehost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-csrf-token': window.scrollerCsrf || ''
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: new URLSearchParams({
url: item.external_media_url || item.dest,
@@ -1966,9 +1973,10 @@
showShareToast(info.toast);
}
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
})
.then(r => r.json())
.then(data => {
@@ -2119,7 +2127,12 @@
const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) return;
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
if (raw.includes('&gt;') || raw.includes('&lt;') || raw.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = raw;
raw = txt.value;
}
const lines = raw.split('\n');
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
@@ -2787,11 +2800,16 @@
if (!content || !commentsItemId || commentsPosting) return;
commentsPosting = true; commentSendBtn.disabled = true;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
const resp = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: postBody
});
const data = await resp.json();
@@ -2899,10 +2917,14 @@
if (addTagSendBtn) addTagSendBtn.disabled = true;
closeSugg();
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${targetId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `tagname=${encodeURIComponent(tag)}`
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
});
const data = await resp.json();
if (data.success) {
@@ -3658,7 +3680,11 @@
if (sMarkAll) {
sMarkAll.addEventListener('click', async () => {
try {
await fetch('/api/notifications/read', { method: 'POST' });
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read', {
method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs);
@@ -3673,7 +3699,12 @@
if (!item) return;
const nid = item.dataset.id;
if (nid && item.classList.contains('unread')) {
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/notifications/${nid}/read`, {
method: 'POST',
keepalive: true,
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}).catch(() => {});
item.classList.remove('unread');
// Update cache
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));

View File

@@ -47,7 +47,7 @@
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};
const playSidebarEmojiVideos = (container) => {
@@ -153,12 +153,6 @@
const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
// Truncate extremely long comments before any processing to keep the sidebar
// fast and the DOM lean, regardless of markdown / regex complexity.
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
}
if (typeof marked === 'undefined') {
return escapeHtml(content)
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
@@ -268,9 +262,10 @@
// 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;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: quoteContent;
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
}

View File

@@ -306,7 +306,12 @@ if (!window.UserCommentSystem) {
const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
return `<span class="greentext">&gt;${quoteContent}</span>`;
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
}
// Per-line limit
@@ -450,7 +455,7 @@ if (!window.UserCommentSystem) {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
}
}

View File

@@ -460,4 +460,15 @@ export default new class {
console.error(`[ERROR REF ${errId}] ${context}:`, err);
return `Internal Error. Reference: ${errId}`;
}
isOnionRequest(req) {
if (!req || !req.headers) return false;
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
if (!rawHost) return false;
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
const firstHost = hostStr.split(',')[0].trim();
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
};

View File

@@ -1428,8 +1428,8 @@ export default (router, tpl) => {
const userId = newUser[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {

View File

@@ -25,7 +25,8 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
if (!req.params.postid || !req.post.tagname) {
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
return res.json({
success: false,
msg: 'missing postid or tag'
@@ -33,7 +34,7 @@ export default router => {
}
const postid = +req.params.postid;
const tagname = req.post.tagname?.trim();
const tagname = rawTagname.trim();
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
if (protectedTags.includes(tagname.toLowerCase())) {

View File

@@ -48,7 +48,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() }, req)
});
}
@@ -58,7 +58,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() }, req)
});
};
@@ -67,7 +67,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() }, req)
});
};
@@ -88,8 +88,9 @@ export default (router, tpl) => {
return renderError("Passwords do not match.");
}
// reCAPTCHA verification
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try {
@@ -167,8 +168,8 @@ export default (router, tpl) => {
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);

View File

@@ -95,7 +95,7 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
@@ -128,6 +128,7 @@ export default (router, tpl) => {
phrase,
tmp: null,
hidePagination: true,
user: req.session?.user ? { user: req.session.user } : null,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'Tags',

View File

@@ -49,7 +49,7 @@ const gateOptions = {
},
cloudflare_status: {
status: 'ok',
location: 'Frankfurt',
location: cfg.websrv.private_society_gate_location || 'Frankfurt',
name: 'Cloudflare',
status_text: 'Working',
},
@@ -79,11 +79,13 @@ const nginx502Fallback = `<html>
</html>`;
// Login + Register modal injected before </body>
// This string is built at startup so it can reference cfg.recaptcha values.
// Dynamic function so reCAPTCHA can be bypassed for .onion requests
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
const gateLoginInjection = `
function getGateLoginInjection(req) {
const rcEnabled = _rcEnabled && !lib.isOnionRequest(req);
return `
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
<div id="gate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;align-items:center;justify-content:center;">
@@ -123,7 +125,7 @@ const gateLoginInjection = `
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
${_rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
${rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
@@ -264,8 +266,9 @@ const gateLoginInjection = `
if (_sb.length > 12) _sb = _sb.slice(-12);
});
</script>
${_rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
${rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
`;
}
// Text injected into the "What can I do?" section
@@ -282,11 +285,40 @@ try {
}
// Called on every gated request — produces a fresh page with unique Ray ID + timestamp
// Accepts an optional request object to inject the visitor's real IP into the footer reveal.
// Accepts an optional request object to inject the visitor's real IP into the footer reveal and dynamic host into page text/title.
function buildGatePage(req) {
if (!_cfRender) return nginx502Fallback;
let html = _cfRender({ ...gateOptions, what_can_i_do: gateSignInButton });
let reqHost = null;
if (req && req.headers) {
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'];
if (rawHost) {
const firstHost = rawHost.split(',')[0].trim();
let extracted = '';
if (firstHost.startsWith('[')) {
const closeBracket = firstHost.indexOf(']');
extracted = closeBracket !== -1 ? firstHost.substring(1, closeBracket) : firstHost;
} else {
extracted = firstHost.split(':')[0].trim();
}
const isLocalHost = ['localhost', '127.0.0.1', '::1', 'f0ckm', '0.0.0.0', 'localhost.localdomain'].includes(extracted.toLowerCase());
if (extracted && !isLocalHost) {
reqHost = extracted;
}
}
}
if (!reqHost) reqHost = cfg.main.url.domain;
const currentGateOptions = {
...gateOptions,
host_status: {
...gateOptions.host_status,
location: reqHost,
},
what_can_i_do: gateSignInButton,
};
let html = _cfRender(currentGateOptions);
// Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
if (req) {
@@ -305,9 +337,9 @@ function buildGatePage(req) {
// Patch the <title> to include the domain — matches real Cloudflare behaviour
html = html.replace(
'<title>502: Bad Gateway</title>',
`<title>${cfg.main.url.domain} | 502: Bad gateway</title>`
`<title>${reqHost} | 502: Bad gateway</title>`
);
html = html.replace('</body>', gateLoginInjection + '\n</body>');
html = html.replace('</body>', getGateLoginInjection(req) + '\n</body>');
return html;
}
@@ -316,7 +348,14 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
? null
: nginx502Fallback;
// Custom gate template — resolved once at boot from config
// Set private_society_gate: "custom" and private_society_gate_template: "your-template-name" (no .html)
const _customGateTemplate = (cfg.websrv.private_society && cfg.websrv.private_society_gate === 'custom' && cfg.websrv.private_society_gate_template)
? String(cfg.websrv.private_society_gate_template).replace(/\.html$/i, '')
: null;
if (nginx502 === null) console.log('[BOOT] Private society gate: Cloudflare dynamic mode');
if (_customGateTemplate) console.log(`[BOOT] Private society gate: Custom template → views/${_customGateTemplate}.html (flummpress resolves extension)`);
// ─────────────────────────────────────────────────────────────────────────────
@@ -456,6 +495,22 @@ process.on('uncaughtException', err => {
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
res.setHeader('Connection', 'keep-alive');
// Block incoming requests to .onion if Tor Hidden Service is explicitly disabled in config
if (cfg.websrv?.enable_tor_hs === false && lib.isOnionRequest(req)) {
res.writeHead(503).end();
req.url.pathname = '/tor_hs_disabled_bypass';
return;
}
// Tor Onion-Location header: advertises .onion service counterpart to Tor Browser users when enabled
if (cfg.websrv?.enable_tor_hs !== false && cfg.main?.onion && !lib.isOnionRequest(req)) {
const p = req.url?.pathname || '/';
const s = req.url?.search || '';
const onionHost = cfg.main.onion.replace(/\/+$/, '');
res.setHeader('Onion-Location', `${onionHost}${p}${s}`);
}
if (isSecure) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
@@ -801,18 +856,21 @@ process.on('uncaughtException', err => {
req.url.pathname = '/private_society_bypass';
return;
}
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate)
if (req.url.pathname !== '/') {
// For page requests, serve the gate for all paths (custom template or fallback)
if (_customGateTemplate) {
res.writeHead(200, { 'Content-Type': 'text/html' }).end(tpl.render(_customGateTemplate, {}, req));
} else if (req.url.pathname !== '/') {
// Non-home paths: always show the static/cloudflare gate
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
req.url.pathname = '/private_society_bypass';
return;
}
// Homepage: in cloudflare dynamic mode serve gate directly; otherwise fall through to template
if (nginx502 === null) {
} else if (nginx502 === null) {
// Homepage in cloudflare dynamic mode: serve the CF gate directly
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
req.url.pathname = '/private_society_bypass';
} else {
// Homepage in plain nginx mode: fall through to index.html template
return;
}
req.url.pathname = '/private_society_bypass';
return;
}
}
});
@@ -1292,6 +1350,7 @@ process.on('uncaughtException', err => {
show_mime_picker: cfg.websrv.show_mime_picker !== false,
private_society: cfg.websrv.private_society || false,
private_society_gate: cfg.websrv.private_society_gate || '',
private_society_gate_location: cfg.websrv.private_society_gate_location || 'Frankfurt',
show_content_warning: cfg.websrv.show_content_warning !== false,
web_url_upload: !!cfg.websrv.web_url_upload,
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
@@ -1403,26 +1462,39 @@ process.on('uncaughtException', err => {
}
}
// Resolve per-request recaptcha preference (disabled for .onion requests or when passed false)
const effectiveReq = req || (data && data.req);
const defaultRecaptcha = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
let perRequestRecaptcha = defaultRecaptcha;
if (effectiveReq && lib.isOnionRequest(effectiveReq)) {
perRequestRecaptcha = false;
} else if (data && typeof data.recaptcha_enabled === 'boolean') {
perRequestRecaptcha = data.recaptcha_enabled;
}
// Build data: globals first, then caller-supplied data, then per-request i18n last
// so t/lang always reflect the user's language, not the site default.
// ALSO mutate globals.t and globals.lang: flummpress spreads this.#globals LAST
// ALSO mutate globals.t, globals.lang, globals.recaptcha_enabled: flummpress spreads this.#globals LAST
// inside render(), so globals must carry the per-request values too.
globals.t = perRequestT;
globals.lang = perRequestLang;
globals.recaptcha_enabled = perRequestRecaptcha;
// Resolve per-request infobox preference
// Guests always get false — the alternative infobox is a logged-in user preference only
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
? req.session.use_alternative_infobox
: (req && !req.session
const activeReq = req || effectiveReq;
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
? activeReq.session.use_alternative_infobox
: (activeReq && !activeReq.session
? false
: (data && typeof data.user_alternative_infobox === 'boolean'
? data.user_alternative_infobox
: (cfg.websrv.user_alternative_infobox !== false)));
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean')
? req.session.use_alternative_steuerung
: (req && !req.session
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
? activeReq.session.use_alternative_steuerung
: (activeReq && !activeReq.session
? false
: (data && typeof data.user_alternative_steuerung === 'boolean'
? data.user_alternative_steuerung
@@ -1431,11 +1503,12 @@ process.on('uncaughtException', err => {
data = Object.assign({}, globals, data || {}, {
t: perRequestT,
lang: perRequestLang,
recaptcha_enabled: perRequestRecaptcha,
user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
? req.session.comment_display_mode
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
? activeReq.session.comment_display_mode
: (data && typeof data.comment_display_mode === 'number'
? data.comment_display_mode
: (cfg.websrv.default_comment_display_mode || 0))
@@ -1447,17 +1520,19 @@ process.on('uncaughtException', err => {
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
}
if (req) {
if (req.mode !== undefined) data.mode = req.mode;
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck';
if (!data.url) data.url = req.url;
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false;
data.user_logged_in_bool = !!req.session;
data.csrf_token = req.session?.csrf_token || '';
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
if (activeReq) {
data.recaptcha_enabled = perRequestRecaptcha;
if (activeReq.mode !== undefined) data.mode = activeReq.mode;
data.theme = activeReq.theme || activeReq.cookies?.theme || cfg.websrv.theme || 'f0ck';
if (!data.url) data.url = activeReq.url;
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
data.user_logged_in_bool = !!activeReq.session;
data.csrf_token = activeReq.session?.csrf_token || '';
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
} else {
data.recaptcha_enabled = perRequestRecaptcha;
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
data.user_strict_bool = false;
data.user_logged_in_bool = false;

35
views/_gate_template.html Normal file
View File

@@ -0,0 +1,35 @@
<!doctype html>
<html theme="@if(typeof theme !== 'undefined'){{ theme }}@else{{ default_theme || 'amoled' }}@endif">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ domain }}</title>
<link rel="icon" type="image/gif" href="/s/img/favicon.gif" />
<link rel="stylesheet" id="injected-gate-styles" href="/s/css/f0ckm.css?v={{ ts }}">
</head>
<body class="{{ default_layout === 'legacy' ? 'layout-legacy' : 'layout-modern' }}">
<div class="wrapper" style="text-align: center;">
<h2>Example Gate</h2>
<button type="button" onclick="openLoginGate();">Login</button>
<div id="gate-container" style="display:none;">
@include(snippets/navbar)
@include(snippets/footer)
</div>
</div>
<script>
function openLoginGate() {
document.getElementById('gate-container').style.display = 'block';
const tryOpen = () => {
const m = document.getElementById('login-modal');
if (m) { m.style.display = 'flex'; }
else { setTimeout(tryOpen, 50); }
};
tryOpen();
}
</script>
</body>
</html>

View File

@@ -319,7 +319,7 @@
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/users/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id })
});
var data = await res.json();
@@ -339,7 +339,7 @@
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
var res = await fetch('/api/v2/admin/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
});
var data = await res.json();
@@ -359,7 +359,7 @@
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id })
});
var data = await res.json();
@@ -379,7 +379,7 @@
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
});
var data = await res.json();
@@ -398,7 +398,7 @@
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
});
var data = await res.json();
@@ -422,7 +422,7 @@
ModAction.confirm('Set Display Name', hint, async (newName) => {
var res = await fetch('/api/v2/admin/users/set-display-name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, display_name: newName || '' })
});
var data = await res.json();
@@ -457,7 +457,7 @@
ModAction.confirm('Unlock Layout', 'Unlock comment layout for <strong>' + escHTML(userName) + '</strong>? They will be able to change it again.', async () => {
var res = await fetch('/api/v2/admin/users/lock-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, lock: false })
});
var data = await res.json();
@@ -481,7 +481,7 @@
var mode = document.getElementById('force-mode-select').value;
var res = await fetch('/api/v2/admin/users/lock-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
});
var data = await res.json();
@@ -616,7 +616,7 @@
try {
var res = await fetch('/api/v2/admin/users/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ username, email: email || null, password, role })
});
var data = await res.json();
@@ -662,7 +662,7 @@
var role = selectEl.value;
var res = await fetch('/api/v2/admin/users/set-role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ user_id: id, role })
});
var data = await res.json();

View File

@@ -296,7 +296,7 @@
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
</tr>
@endif
</table>

View File

@@ -238,7 +238,7 @@
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
</tr>
@endif
</table>

View File

@@ -8,7 +8,7 @@
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
@media (max-width: 950px) {
@media (max-width: 1250px) {
/* Target all possible shell triggers */
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
height: auto !important;

View File

@@ -8,6 +8,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
<script>(function(){var h=document.documentElement;if(localStorage.getItem('hideItemRatings')==='true')h.classList.add('hide-item-ratings-active');if(localStorage.getItem('blurNsfw')==='true')h.classList.add('blur-nsfw-active');if(localStorage.getItem('blurNsfl')==='true')h.classList.add('blur-nsfl-active');if(localStorage.getItem('blurSfw')==='true')h.classList.add('blur-sfw-active');if(localStorage.getItem('blurUntagged')==='true')h.classList.add('blur-untagged-active');if(localStorage.getItem('blurDetail')!=='false')h.classList.add('blur-detail-active');if(localStorage.getItem('imageExpandOnClick')!=='false')h.classList.add('image-expand-active');})();</script>
<style>
html { background-color: #000; color: #fff; }
@if(session && session.font)
@@ -22,6 +23,7 @@
}
@endif
:root {
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
@if(session && session.font)
--font: 'CustomUserFont', monospace !important;
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)

View File

@@ -48,7 +48,7 @@
<div class="profile_head_username">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
<div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container">
<span id="nav-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
<span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
@if(session && session.id === user.user_id)
<button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color">
<i class="fa-solid fa-pen"></i>
@@ -509,18 +509,27 @@
nameContainer.style.display = 'flex';
nameEditContainer.style.display = 'none';
const nameSpan = document.getElementById('nav-display-name');
if (nameSpan) {
nameSpan.style.color = color;
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
let node = nameSpan.childNodes[i];
const updateNameNode = (el, text) => {
if (!el) return;
for (let i = el.childNodes.length - 1; i >= 0; i--) {
let node = el.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}');
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + text;
break;
}
}
};
const targetName = displayName ? displayName : '{{ user.user }}';
const profileSpan = document.getElementById('profile-display-name');
if (profileSpan) {
profileSpan.style.color = color;
updateNameNode(profileSpan, targetName);
}
const navSpan = document.getElementById('nav-display-name');
if (navSpan) {
navSpan.style.color = color;
updateNameNode(navSpan, targetName);
}
const bracket = document.getElementById('username-bracket');
if (bracket) {