119 Commits
f0ckv2 ... 98

Author SHA1 Message Date
8357d011e3 Merge pull request 'CSS QoL' (#41) from master into 98
Reviewed-on: #41
2026-08-06 21:56:33 +00:00
c7414e4b21 CSS QoL 2026-08-06 23:55:51 +02:00
9ccebc6d95 Merge pull request 'add option to also hide fav activity from public' (#40) from master into 98
Reviewed-on: #40
2026-08-06 21:01:02 +00:00
1262b15a5b add option to also hide fav activity from public 2026-08-06 23:00:12 +02:00
3927085e1f Merge pull request 'master' (#39) from master into 98
Reviewed-on: #39
2026-08-06 20:46:41 +00:00
2cd8bd91ff add option for users to make their favorites private 2026-08-06 22:40:53 +02:00
7b186dcdb2 remove this since it can break v6 config 2026-08-06 22:24:04 +02:00
d5c294c940 Merge pull request 'fix nginx ipv6 and make tor only available to localhost' (#38) from master into 98
Reviewed-on: #38
2026-08-06 19:49:36 +00:00
973753d231 fix nginx ipv6 and make tor only available to localhost 2026-08-06 21:48:05 +02:00
eee7c899ab Merge pull request 'smoothies' (#37) from master into 98
Reviewed-on: #37
2026-07-28 20:30:21 +00:00
c30b3535ae smoothies 2026-07-28 22:30:04 +02:00
6e8ae2f7cb some 98 css additions 2026-07-28 22:26:56 +02:00
90ef78bbf3 Merge pull request 'truncate .' (#36) from master into 98
Reviewed-on: #36
2026-07-28 20:06:46 +00:00
eed8b993e1 truncate . 2026-07-28 22:06:31 +02:00
2eae7f93e5 Merge pull request 'master' (#35) from master into 98
Reviewed-on: #35
2026-07-28 20:02:13 +00: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
2758f2a72e Merge pull request 'master' (#34) from master into 98
Reviewed-on: #34
2026-07-28 14:26:51 +00: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
c0c32ddfe4 Merge pull request 'adding tor switch and update readme' (#33) from master into 98
Reviewed-on: #33
2026-07-27 10:37:04 +00:00
25ab4ba949 adding tor switch and update readme 2026-07-27 12:32:08 +02:00
a6e2689909 Merge pull request 'master' (#32) from master into 98
Reviewed-on: #32
2026-07-27 10:28:47 +00: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
d1dd088bc6 Merge pull request 'positioning is kinda weird for notic-count' (#31) from master into 98
Reviewed-on: #31
2026-07-26 22:44:49 +00:00
0a21b6c8f8 positioning is kinda weird for notic-count 2026-07-27 00:44:00 +02:00
1bb3dcff21 Merge pull request 'master' (#30) from master into 98
Reviewed-on: #30
2026-07-26 22:23:32 +00: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
fc6749cd11 Merge pull request 'respect config settings' (#29) from master into 98
Reviewed-on: #29
2026-07-26 21:25:01 +00:00
54e2e8871b respect config settings 2026-07-26 23:24:28 +02:00
4d685dff06 Merge pull request 'fix invalid csrf token' (#28) from master into 98
Reviewed-on: #28
2026-07-26 21:20:50 +00:00
c99f3e11d8 fix invalid csrf token 2026-07-26 23:20:20 +02:00
b0053a8de8 Merge pull request 'correctly display url in gate' (#27) from master into 98
Reviewed-on: #27
2026-07-26 21:11:14 +00:00
d14feaf3eb correctly display url in gate 2026-07-26 23:10:41 +02:00
280020c19f Merge pull request 'Revert "adding hardcoded meta for onion"' (#26) from master into 98
Reviewed-on: #26
2026-07-26 21:07:57 +00:00
5e35fd48d4 Revert "adding hardcoded meta for onion"
This reverts commit a07c21f22b.
2026-07-26 23:07:27 +02:00
0d3819c113 Merge pull request 'adding hardcoded meta for onion' (#25) from master into 98
Reviewed-on: #25
2026-07-26 21:05:20 +00:00
a07c21f22b adding hardcoded meta for onion 2026-07-26 23:04:45 +02:00
34d2b62909 Merge pull request 'f' (#24) from master into 98
Reviewed-on: #24
2026-07-26 20:47:21 +00:00
65fb96f675 f 2026-07-26 22:38:10 +02:00
18c898c910 Merge pull request 'break long text in info modal' (#23) from master into 98
Reviewed-on: #23
2026-07-26 20:28:36 +00:00
20a6ffcb04 break long text in info modal 2026-07-26 22:27:57 +02:00
cbfbcb04bf Merge pull request 'master' (#22) from master into 98
Reviewed-on: #22
2026-07-26 20:23:11 +00: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
d2ce6f412d Merge pull request 'fix scroller tagging invalid csrf token' (#21) from master into 98
Reviewed-on: #21
2026-07-26 19:22:10 +00:00
e61e24dfe8 fix scroller tagging invalid csrf token 2026-07-26 21:20:00 +02:00
ec4db7a22b Merge pull request 'mobile default tab selection' (#20) from master into 98
Reviewed-on: #20
2026-07-26 18:45:46 +00:00
44af6ae061 mobile default tab selection 2026-07-26 20:45:16 +02:00
c0d70baf83 Merge pull request 'master' (#19) from master into 98
Reviewed-on: #19
2026-07-25 20:50:24 +00: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
122f57eacc update build script 2026-07-25 22:16:15 +02:00
07de9d4488 add f0ck98 theme 2026-07-25 22:14:54 +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
3344064030 tags 2026-07-19 07:37:13 +02:00
e998ba8fc7 chickendöner 2026-07-19 03:30:52 +02:00
6b087efab3 xd 2026-07-19 02:12:58 +02:00
6c9f019646 350 2026-07-19 02:11:05 +02:00
d35437373f possible fix for 502 2026-07-19 00:15:36 +02:00
6efb67f2f5 fuck this auto tagging 2026-07-18 23:44:03 +02:00
fecb0f0b0c tag depths 2026-07-18 23:33:25 +02:00
d89c610dcb gfd 2026-07-18 23:09:28 +02:00
93c042251a set default font via config 2026-07-18 22:16:56 +02:00
990de4b710 vertalign 2026-07-18 22:03:33 +02:00
2f9ad4d349 gfd 2026-07-18 17:21:04 +02:00
701bee7a9a fdsa 2026-07-18 17:15:35 +02:00
4b7fba11ad smaller font = better 2026-07-18 17:11:40 +02:00
6f8eb6f83f g 2026-07-18 17:07:55 +02:00
d78b31633a bbb 2026-07-18 16:52:01 +02:00
294cf2455a hide sidebar yes no 2026-07-18 16:48:55 +02:00
663ba13d18 starto 2026-07-18 16:30:22 +02:00
84c3f1ccda gfsd 2026-07-18 16:18:22 +02:00
4dc7f10ae2 kibort 2026-07-18 16:15:47 +02:00
3ff1edf8bf I do love my keyboard 2026-07-18 16:11:06 +02:00
9ad6eaefec gfsd 2026-07-18 16:01:44 +02:00
d4eb0e52d8 confusion 2026-07-18 15:58:59 +02:00
ce54ed460d fugg :D 2026-07-18 15:40:12 +02:00
cddc763c44 consistency refe 2026-07-18 15:36:49 +02:00
61036558a0 Revert "awesome!!"
This reverts commit 8f0e0d9b3a.
2026-07-18 15:03:02 +02:00
3354baf09f Revert "ff"
This reverts commit c3c485af79.
2026-07-18 15:01:01 +02:00
c3c485af79 ff 2026-07-18 14:59:46 +02:00
8f0e0d9b3a awesome!! 2026-07-18 14:45:46 +02:00
fefd3b6ca6 gdf 2026-07-18 14:37:40 +02:00
5818a0ff39 dragging!! 2026-07-18 14:34:18 +02:00
036a9d372d im pathetic at this point 2026-07-18 14:27:04 +02:00
bef96160db the most pathetic fix to exist yet 2026-07-18 14:24:21 +02:00
a99c17c16b actually useful fix 2026-07-18 14:08:50 +02:00
469f699dd7 button fligger 2026-07-18 13:59:43 +02:00
d3f8c87317 stripping source identifier for IG 2026-07-18 13:54:04 +02:00
568555eab8 62% 2026-07-18 11:18:10 +02:00
58 changed files with 2481 additions and 569 deletions

View File

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

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ public/tag_cache
config.json config.json
bundle.css bundle.css
public/s/fonts/impact.woff 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 ## NGINX
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt. 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"
}
```

View File

@@ -16,7 +16,7 @@ fi
# Parse arguments # Parse arguments
TARGET="master" TARGET="master"
CACHE_FLAG="" CACHE_FLAG=""
BRANCH_TARGET="" CUSTOM_BRANCH=""
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
@@ -27,8 +27,8 @@ for arg in "$@"; do
CACHE_FLAG="--no-cache" CACHE_FLAG="--no-cache"
;; ;;
*) *)
if [ -z "$BRANCH_TARGET" ]; then if [ -z "$CUSTOM_BRANCH" ]; then
BRANCH_TARGET=$arg CUSTOM_BRANCH=$arg
fi fi
;; ;;
esac esac
@@ -43,7 +43,10 @@ fi
# Set Image name and branch defaults # Set Image name and branch defaults
IMAGE_NAME="f0ckm" IMAGE_NAME="f0ckm"
if [ "$TARGET" == "master" ]; then if [ -n "$CUSTOM_BRANCH" ]; then
BRANCH_TARGET="$CUSTOM_BRANCH"
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
elif [ "$TARGET" == "master" ]; then
echo "--- PRODUCTION DEPLOYMENT (master) ---" echo "--- PRODUCTION DEPLOYMENT (master) ---"
BRANCH_TARGET="master" BRANCH_TARGET="master"
elif [ "$TARGET" == "stg" ]; then elif [ "$TARGET" == "stg" ]; then

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", "domain": "example.com",
"regex": "example\\.com" "regex": "example\\.com"
}, },
"onion": "http://your-onion-address.onion",
"socks": "socks5://127.0.0.1:9050", "socks": "socks5://127.0.0.1:9050",
"mail": "admin@example.com", "mail": "admin@example.com",
"maxfilesize": 104857600, "maxfilesize": 104857600,
@@ -53,12 +54,15 @@
"amoled" "amoled"
], ],
"theme": "amoled", "theme": "amoled",
"default_font": "",
"default_layout": "legacy", "default_layout": "legacy",
"custom_favicon": "/s/img/favicon.gif", "custom_favicon": "/s/img/favicon.gif",
"custom_brand_image": [], "custom_brand_image": [],
"custom_navbar_brand_text": "", "custom_navbar_brand_text": "",
"show_koepfe": false, "show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [], "koepfe": [],
"enable_tor_hs": true,
"enable_global_chat": true, "enable_global_chat": true,
"enable_danmaku": true, "enable_danmaku": true,
"private_messages": true, "private_messages": true,
@@ -130,6 +134,8 @@
"open_registration_require_mail_andor_token": false, "open_registration_require_mail_andor_token": false,
"private_society": false, "private_society": false,
"private_society_gate": "cloudflare", "private_society_gate": "cloudflare",
"private_society_gate_location": "Frankfurt",
"private_society_gate_template": "_gate_template",
"public_nsfw": false, "public_nsfw": false,
"paths": { "paths": {
"images": "/b", "images": "/b",

View File

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

View File

@@ -0,0 +1,5 @@
-- Add favorites_private column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';

View File

@@ -0,0 +1,5 @@
-- Add hide_fav_badge column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';

View File

@@ -1498,7 +1498,9 @@ CREATE TABLE public.user_options (
receive_user_notifications boolean DEFAULT true, receive_user_notifications boolean DEFAULT true,
do_not_disturb boolean DEFAULT false, do_not_disturb boolean DEFAULT false,
comment_display_mode integer DEFAULT 1, comment_display_mode integer DEFAULT 1,
force_comment_display_mode integer DEFAULT 0 force_comment_display_mode integer DEFAULT 0,
favorites_private boolean DEFAULT false,
hide_fav_badge boolean DEFAULT false
); );

357
public/s/css/98.css Normal file
View File

@@ -0,0 +1,357 @@
git pu/* 98.css framework - Scoped exclusively for f0ck98 theme */
@font-face {
font-family: "Pixelated MS Sans Serif";
font-style: normal;
font-weight: 400;
src: url("/s/fonts/ms_sans_serif.woff2") format("woff2");
}
@font-face {
font-family: "Pixelated MS Sans Serif";
font-style: normal;
font-weight: 700;
src: url("/s/fonts/ms_sans_serif_bold.woff2") format("woff2");
}
html[theme="f0ck98"] {
--w98-silver: #c0c0c0;
--w98-teal: #008080;
--w98-navy: #000080;
--w98-navy-gradient: linear-gradient(90deg, navy, #1084d0);
--w98-dark-shadow: #0a0a0a;
--w98-light-highlight: #ffffff;
--w98-mid-gray: grey;
--w98-light-gray: #dfdfdf;
color: #000;
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
font-size: 11px;
background-color: var(--w98-silver) !important;
background: var(--w98-silver) !important;
}
html[theme="f0ck98"] body {
color: #000;
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
font-size: 11px;
}
html[theme="f0ck98"] .title-bar,
html[theme="f0ck98"] .window,
html[theme="f0ck98"] button,
html[theme="f0ck98"] input,
html[theme="f0ck98"] label,
html[theme="f0ck98"] legend,
html[theme="f0ck98"] li[role=tab],
html[theme="f0ck98"] option,
html[theme="f0ck98"] select,
html[theme="f0ck98"] table,
html[theme="f0ck98"] textarea,
html[theme="f0ck98"] ul.tree-view {
-webkit-font-smoothing: none;
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
font-size: 11px;
}
html[theme="f0ck98"] u {
border-bottom: 0.5px solid #222;
text-decoration: none;
}
/* Windows 98 Buttons */
html[theme="f0ck98"] button,
html[theme="f0ck98"] input[type=reset],
html[theme="f0ck98"] input[type=submit],
html[theme="f0ck98"] .btn {
background: silver;
border: none;
border-radius: 0;
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
box-sizing: border-box;
color: #000;
min-height: 23px;
}
html[theme="f0ck98"] button.default,
html[theme="f0ck98"] input[type=reset].default,
html[theme="f0ck98"] input[type=submit].default,
html[theme="f0ck98"] .btn.default {
box-shadow: inset -2px -2px #0a0a0a, inset 1px 1px #0a0a0a, inset 2px 2px #fff, inset -3px -3px grey, inset 3px 3px #dfdfdf;
}
html[theme="f0ck98"] .vertical-bar {
background: silver;
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
height: 20px;
width: 4px;
}
html[theme="f0ck98"] button:not(:disabled):active,
html[theme="f0ck98"] input[type=reset]:not(:disabled):active,
html[theme="f0ck98"] input[type=submit]:not(:disabled):active,
html[theme="f0ck98"] .btn:not(:disabled):active {
box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
}
html[theme="f0ck98"] button.default:not(:disabled):active,
html[theme="f0ck98"] input[type=reset].default:not(:disabled):active,
html[theme="f0ck98"] input[type=submit].default:not(:disabled):active {
box-shadow: inset 2px 2px #0a0a0a, inset -1px -1px #0a0a0a, inset -2px -2px #fff, inset 3px 3px grey, inset -3px -3px #dfdfdf;
}
html[theme="f0ck98"] button:focus,
html[theme="f0ck98"] input[type=reset]:focus,
html[theme="f0ck98"] input[type=submit]:focus,
html[theme="f0ck98"] .btn:focus {
outline: 1px dotted #000;
outline-offset: -4px;
}
html[theme="f0ck98"] :disabled,
html[theme="f0ck98"] :disabled+label,
html[theme="f0ck98"] input[readonly],
html[theme="f0ck98"] input[readonly]+label {
color: grey;
}
html[theme="f0ck98"] :disabled+label,
html[theme="f0ck98"] button:disabled,
html[theme="f0ck98"] input[type=reset]:disabled,
html[theme="f0ck98"] input[type=submit]:disabled,
html[theme="f0ck98"] .btn:disabled {
text-shadow: 1px 1px 0 #fff;
}
/* Window Shell & Title Bars */
html[theme="f0ck98"] .window {
background: silver;
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff;
padding: 3px;
}
html[theme="f0ck98"] .title-bar {
align-items: center;
background: linear-gradient(90deg, navy, #1084d0);
display: flex;
justify-content: space-between;
padding: 3px 2px 3px 3px;
}
html[theme="f0ck98"] .title-bar.inactive {
background: linear-gradient(90deg, grey, #b5b5b5);
}
html[theme="f0ck98"] .title-bar-text {
color: #fff;
font-weight: 700;
letter-spacing: 0;
margin-right: 24px;
}
html[theme="f0ck98"] .title-bar-controls {
display: flex;
}
html[theme="f0ck98"] .title-bar-controls button {
display: block;
min-height: 14px;
min-width: 16px;
padding: 0;
}
html[theme="f0ck98"] .title-bar-controls button:active {
padding: 0;
}
html[theme="f0ck98"] .title-bar-controls button:focus {
outline: none;
}
html[theme="f0ck98"] .title-bar-controls button[aria-label=Minimize],
html[theme="f0ck98"] .title-bar-controls button[aria-label].minimize {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='6' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' d='M0 0h6v2H0z'/%3E%3C/svg%3E");
background-position: bottom 3px left 4px;
background-repeat: no-repeat;
}
html[theme="f0ck98"] .title-bar-controls button[aria-label=Maximize],
html[theme="f0ck98"] .title-bar-controls button[aria-label].maximize {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='9' height='9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 0H0v9h9V0zM8 2H1v6h7V2z' fill='%23000'/%3E%3C/svg%3E");
background-position: top 2px left 3px;
background-repeat: no-repeat;
}
html[theme="f0ck98"] .title-bar-controls button[aria-label=Close],
html[theme="f0ck98"] .title-bar-controls button[aria-label].close {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h2v1h1v1h2V1h1V0h2v1H7v1H6v1H5v1h1v1h1v1h1v1H6V6H5V5H3v1H2v1H0V6h1V5h1V4h1V3H2V2H1V1H0V0z' fill='%23000'/%3E%3C/svg%3E");
background-position: top 3px left 4px;
background-repeat: no-repeat;
margin-left: 2px;
}
/* Status Bar */
html[theme="f0ck98"] .status-bar {
gap: 1px;
display: flex;
margin: 0 1px;
}
html[theme="f0ck98"] .status-bar-field {
box-shadow: inset -1px -1px #dfdfdf, inset 1px 1px grey;
flex-grow: 1;
margin: 0;
padding: 2px 3px;
}
html[theme="f0ck98"] .window-body {
margin: 8px;
}
html[theme="f0ck98"] fieldset {
border-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='5' height='5' fill='gray' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h5v5H0V2h2v1h1V2H0' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h4v4H0V1h1v2h2V1H0'/%3E%3C/svg%3E") 2;
margin: 0;
padding: 10px;
padding-block-start: 8px;
}
html[theme="f0ck98"] legend {
background: silver;
}
html[theme="f0ck98"] .field-row {
align-items: center;
display: flex;
}
html[theme="f0ck98"] [class^=field-row]+[class^=field-row] {
margin-top: 6px;
}
html[theme="f0ck98"] .field-row>*+* {
margin-left: 6px;
}
html[theme="f0ck98"] label {
align-items: center;
display: inline-flex;
user-select: none;
}
/* Form Inputs & Textarea */
html[theme="f0ck98"] input[type=email],
html[theme="f0ck98"] input[type=number],
html[theme="f0ck98"] input[type=password],
html[theme="f0ck98"] input[type=search],
html[theme="f0ck98"] input[type=tel],
html[theme="f0ck98"] input[type=text],
html[theme="f0ck98"] input[type=url],
html[theme="f0ck98"] select,
html[theme="f0ck98"] textarea {
background-color: #fff;
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
box-sizing: border-box;
padding: 3px 4px;
border: none;
border-radius: 0;
color: #000;
}
html[theme="f0ck98"] input[type=email]:disabled,
html[theme="f0ck98"] input[type=number]:disabled,
html[theme="f0ck98"] input[type=password]:disabled,
html[theme="f0ck98"] input[type=search]:disabled,
html[theme="f0ck98"] input[type=tel]:disabled,
html[theme="f0ck98"] input[type=text]:disabled,
html[theme="f0ck98"] input[type=url]:disabled,
html[theme="f0ck98"] textarea:disabled {
background-color: silver;
}
html[theme="f0ck98"] select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E");
background-position: top 2px right 2px;
background-repeat: no-repeat;
border-radius: 0;
padding-right: 32px;
}
html[theme="f0ck98"] input[type=email]:focus,
html[theme="f0ck98"] input[type=number]:focus,
html[theme="f0ck98"] input[type=password]:focus,
html[theme="f0ck98"] input[type=search]:focus,
html[theme="f0ck98"] input[type=tel]:focus,
html[theme="f0ck98"] input[type=text]:focus,
html[theme="f0ck98"] input[type=url]:focus,
html[theme="f0ck98"] select:focus,
html[theme="f0ck98"] textarea:focus {
outline: none;
}
html[theme="f0ck98"] a {
color: #000080;
}
html[theme="f0ck98"] a:focus {
outline: 1px dotted #000080;
}
html[theme="f0ck98"] .sunken-panel {
background-color: #fff;
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
box-sizing: border-box;
overflow: auto;
}
/* Scoped Scrollbars for f0ck98 */
html[theme="f0ck98"] ::-webkit-scrollbar {
width: 16px;
height: 17px;
}
html[theme="f0ck98"] ::-webkit-scrollbar-corner {
background: #dfdfdf;
}
html[theme="f0ck98"] ::-webkit-scrollbar-track {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='2' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 0H0v1h1v1h1V1H1V0z' fill='silver'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 0H1v1H0v1h1V1h1V0z' fill='%23fff'/%3E%3C/svg%3E");
}
html[theme="f0ck98"] ::-webkit-scrollbar-thumb {
background-color: #dfdfdf;
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
}
html[theme="f0ck98"] ::-webkit-scrollbar-button:vertical:start {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 6H7v1H6v1H5v1H4v1h7V9h-1V8H9V7H8V6z' fill='%23000'/%3E%3C/svg%3E");
height: 17px;
display: block;
}
html[theme="f0ck98"] ::-webkit-scrollbar-button:vertical:end {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E");
height: 17px;
display: block;
}
html[theme="f0ck98"] .sidebar-activity .comment-content.has-overflow:not(.expanded) .comment-content-inner::after {
background: linear-gradient(to bottom, transparent, rgb(192, 192, 192));
}
html[theme="f0ck98"] .item-sidebar-right, .index-sidebar-right, .global-sidebar-right {
box-shadow: none!important;
}
html[theme="f0ck98"] .sidebar-activity .read-more-btn, .sidebar-activity .see-less-btn {
padding: 5px;
}
html[theme="f0ck98"] .modal-content p {
color: var(--black);
}
html[theme="f0ck98"] .drop-zone {
border: 2px dashed rgba(0, 0, 0, 0.2);
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,9 @@
/* Meme Creator Styles */ /* Meme Creator Styles */
.meme-creator-container { .meme-creator-container {
box-sizing: border-box !important;
padding: 20px; padding: 20px;
max-width: 1200px; max-width: 1100px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
@@ -111,6 +112,7 @@
.canvas-wrapper { .canvas-wrapper {
flex: 1; flex: 1;
max-width: 650px;
min-width: 0; /* Allow shrinking */ min-width: 0; /* Allow shrinking */
background: #000; background: #000;
border: 2px solid var(--nav-bg, #2b2b2b); border: 2px solid var(--nav-bg, #2b2b2b);
@@ -238,22 +240,49 @@ canvas#memeCanvas {
color: var(--white, #fff); color: var(--white, #fff);
} }
/* Mobile Stacking - Simple 2-Row Layout */ /* Mobile Stacking - Clean Flexbox Column Layout */
@media (max-width: 950px) { @media (max-width: 950px) {
.meme-editor-layout { .meme-editor-layout {
display: grid !important; display: flex !important;
grid-template-columns: 1fr !important; flex-direction: column !important;
grid-template-rows: 0.6fr auto !important; align-items: center !important;
gap: 20px; gap: 20px;
} }
.meme-controls { .meme-controls {
box-sizing: border-box !important;
width: 100% !important; width: 100% !important;
grid-row: 2; max-width: 500px !important;
overflow: visible !important; overflow: visible !important;
} }
.canvas-wrapper { .canvas-wrapper {
box-sizing: border-box !important;
width: 100% !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; margin-bottom: 10px;
overflow: visible !important; overflow: visible !important;
height: auto !important; height: auto !important;
@@ -294,20 +323,19 @@ canvas#memeCanvas {
/* Sidebar space reservation for meme pages */ /* Sidebar space reservation for meme pages */
.meme-layout-wrapper { .meme-layout-wrapper {
box-sizing: border-box !important;
width: 100%; 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 */ /* 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; padding-right: 300px;
} }
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0;
}
} }
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0 !important;
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="#242526"/>
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -155,7 +155,13 @@
postid, postid,
existingTags: tags, existingTags: tags,
anchorEl: anchor, anchorEl: anchor,
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }), onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags renderTags
}); });
}; };

View File

@@ -54,7 +54,7 @@ class CommentSystem {
document.body.classList.remove('sidebar-left-hidden'); document.body.classList.remove('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container'); const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.remove('sidebar-hidden'); 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.classList.add('faded-out');
this.container.style.display = 'none'; this.container.style.display = 'none';
document.body.classList.add('sidebar-left-hidden'); document.body.classList.add('sidebar-left-hidden');
@@ -126,14 +126,43 @@ class CommentSystem {
const container = document.getElementById('comments-container'); const container = document.getElementById('comments-container');
if (!container) return; if (!container) return;
const rect = container.getBoundingClientRect(); const currentlyAtBottom = btn.classList.contains('is-at-bottom');
// Detect if we are at the bottom of the comments section let isAtBottom = currentlyAtBottom;
const isAtBottom = rect.bottom < window.innerHeight + 100;
if (isAtBottom) { // Base the bottom state primarily on whether the input box / end-of-thread marker is visible
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
if (bottomElement) {
const bottomRect = bottomElement.getBoundingClientRect();
if (currentlyAtBottom) {
// We are pointing UP. Switch to DOWN if we scroll significantly UP away from bottomElement
if (bottomRect.top > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
// We are pointing DOWN. Switch to UP if bottomElement enters viewport or we scroll past it
if (bottomRect.top <= window.innerHeight + 50) {
isAtBottom = true;
}
}
} else {
// Fallback to container rect if no bottom element
const rect = container.getBoundingClientRect();
if (currentlyAtBottom) {
if (rect.bottom > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
if (rect.bottom <= window.innerHeight + 50) {
isAtBottom = true;
}
}
}
if (isAtBottom && !currentlyAtBottom) {
btn.classList.add('is-at-bottom'); btn.classList.add('is-at-bottom');
btn.setAttribute('title', 'Scroll to top'); btn.setAttribute('title', 'Scroll to top');
} else { } else if (!isAtBottom && currentlyAtBottom) {
btn.classList.remove('is-at-bottom'); btn.classList.remove('is-at-bottom');
btn.setAttribute('title', 'Scroll to bottom'); btn.setAttribute('title', 'Scroll to bottom');
} }
@@ -567,7 +596,7 @@ class CommentSystem {
if (cached) cached.content = fullContent; if (cached) cached.content = fullContent;
} }
contentEl.dataset.raw = this.escapeHtml(fullContent); contentEl.dataset.raw = fullContent;
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId); contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
CommentSystem.autoplayConvertedGifs(contentEl); CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl); CommentSystem.playEmojiVideos(contentEl);
@@ -593,6 +622,7 @@ class CommentSystem {
const contentEl = el.querySelector('.comment-content'); const contentEl = el.querySelector('.comment-content');
if (contentEl) { if (contentEl) {
contentEl.dataset.raw = data.content;
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id); contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
CommentSystem.autoplayConvertedGifs(contentEl); CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl); CommentSystem.playEmojiVideos(contentEl);
@@ -828,26 +858,14 @@ class CommentSystem {
* border-right) is fully restored after the effect completes. * border-right) is fully restored after the effect completes.
* *
* @param {Element} el - The .comment element to animate. * @param {Element} el - The .comment element to animate.
* @param {boolean} entering - True to also add 'comment-entering' (own post); * @param {boolean} entering - Unused (kept for compatibility).
* false for 'new-item-fade' only (live others).
*/ */
static _animateNewComment(el, entering = false) { static _animateNewComment(el, entering = false) {
if (!el) return; if (!el) return;
el.classList.add('new-item-fade');
const classes = entering setTimeout(() => {
? ['comment-entering', 'new-item-fade'] el.classList.remove('new-item-fade');
: ['new-item-fade']; }, 2500);
// 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 });
} }
@@ -863,7 +881,6 @@ class CommentSystem {
if (el) { if (el) {
// Remove highlight from any previously highlighted or newly-posted comment // Remove highlight from any previously highlighted or newly-posted comment
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted')); 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')); document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
// Always smooth-scroll so there's no jarring jump // Always smooth-scroll so there's no jarring jump
@@ -1009,7 +1026,7 @@ class CommentSystem {
preview.dataset.id = targetId; preview.dataset.id = targetId;
// Remove temporary animation/highlight classes // 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 // Remove input forms or action buttons
const actions = preview.querySelector('.comment-actions'); const actions = preview.querySelector('.comment-actions');
@@ -1143,12 +1160,13 @@ class CommentSystem {
const author = openerEl.dataset.display || openerEl.dataset.username || 'System'; const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
const contentEl = body.querySelector('.comment-content'); const contentEl = body.querySelector('.comment-content');
if (contentEl) { if (contentEl) {
const LINE_MAX = 200; let rawText = (contentEl.dataset.raw || '').trim();
const rawText = (contentEl.dataset.raw || '').trim(); if (rawText.includes('&gt;') || rawText.includes('&lt;') || rawText.includes('&amp;')) {
// Preserve all lines but cap any single line exceeding LINE_MAX chars const txt = document.createElement('textarea');
const lines = rawText.split('\n').map(line => txt.innerHTML = rawText;
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line rawText = txt.value;
); }
const lines = rawText.split('\n');
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`; const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
if (isNew) { if (isNew) {
@@ -1603,18 +1621,26 @@ class CommentSystem {
syncLevel(roots, list, false); syncLevel(roots, list, false);
} }
// Maximum characters to fully render in the item view per comment // Maximum characters & lines to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 2000; } static get ITEM_VIEW_MAX_CHARS() { return 500; }
static get ITEM_VIEW_MAX_LINES() { return 6; }
renderCommentContent(content, commentId = null, bypassTruncation = false) { renderCommentContent(content, commentId = null, bypassTruncation = false) {
if (!content) return ''; if (!content) return '';
// Truncate extremely long comments before any processing // Truncate long comments before any processing
let truncated = false; let truncated = false;
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) { if (!bypassTruncation) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026'; 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; 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') { if (typeof marked === 'undefined') {
console.warn('Marked.js not loaded, falling back to plain text'); console.warn('Marked.js not loaded, falling back to plain text');
@@ -1762,9 +1788,10 @@ class CommentSystem {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) { if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1); const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true; const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const renderedContent = quoteEmojis const renderedContent = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n)) ? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: quoteContent; : escapedQuote;
return `<span class="greentext">&gt;${renderedContent}</span>`; return `<span class="greentext">&gt;${renderedContent}</span>`;
} }
@@ -2170,7 +2197,7 @@ class CommentSystem {
if (!unsafe) return ''; if (!unsafe) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = unsafe; div.textContent = unsafe;
return div.innerHTML; return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
renderCommentAttachments(files, content = '') { renderCommentAttachments(files, content = '') {
@@ -3307,9 +3334,15 @@ class CommentSystem {
params.append('has_poll', '1'); params.append('has_poll', '1');
} }
const csrfToken = window.f0ckSession?.csrf_token || '';
if (csrfToken) params.append('csrf_token', csrfToken);
const res = await fetch('/api/comments', { const res = await fetch('/api/comments', {
method: 'POST', 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 body: params
}); });
@@ -3575,9 +3608,9 @@ class CommentSystem {
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every // element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
// optimistic DOM insert to guarantee the button regardless of rendering path. // optimistic DOM insert to guarantee the button regardless of rendering path.
_ensureTruncationButton(commentEl, rawContent) { _ensureTruncationButton(commentEl, rawContent) {
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS); if (!rawContent) return;
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) { const lineCount = rawContent.split('\n').length;
console.log('[ensureBtn] below threshold, skipping'); if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
return; return;
} }
const contentEl = commentEl.querySelector('.comment-content'); const contentEl = commentEl.querySelector('.comment-content');
@@ -3779,6 +3812,14 @@ class CommentSystem {
toggleComments() { toggleComments() {
if (!this.container) return; if (!this.container) return;
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 layout = this.container.closest('.item-layout-container');
const sidebar = this.container.closest('.item-sidebar-left'); const sidebar = this.container.closest('.item-sidebar-left');
const siblings = sidebar ? [ const siblings = sidebar ? [
@@ -4423,20 +4464,71 @@ class CommentSystem {
}, { passive: true }); }, { passive: true });
}; };
// Helper: reliable touch handling for tab buttons on mobile // Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
const addTabTouch = (btn, cb) => { let lastHoldTime = 0;
const addTabTouch = (btn, cb, onHold) => {
let ts = null; let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => { btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY }; 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(); e.stopPropagation();
}, { passive: true }); }, { 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) => { btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return; if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x); const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y); const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null; ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); } 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 // Build tab buttons — Favorites first
@@ -4448,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.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('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__')); favTab.addEventListener('click', () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__')); addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
// Right-click on fav tab → set/unset as default // Right-click on fav tab → set/unset as default
favTab.addEventListener('contextmenu', (e) => { favTab.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (navigator.maxTouchPoints > 0) return; if (Date.now() - lastHoldTime < 1000) return;
const KEY = 'f0ck_emoji_default_tab'; toggleFavDefault(favTab);
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');
}
}); });
// Mark as default if already set // Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') { if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {

View File

@@ -46,11 +46,19 @@
return; return;
} }
// Mark internal drags to prevent them from triggering the dropzone
window.addEventListener('dragstart', (e) => {
if (e.dataTransfer) {
e.dataTransfer.setData('application/x-f0ck-internal', 'true');
}
});
// Global Drag Events // Global Drag Events
window.addEventListener('dragenter', (e) => { window.addEventListener('dragenter', (e) => {
e.preventDefault(); e.preventDefault();
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
dragCounter++; dragCounter++;
dropOverlay.classList.add('active'); dropOverlay.classList.add('active');
} }
@@ -59,10 +67,17 @@
window.addEventListener('dragover', (e) => { window.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) {
// Change dropEffect to none to indicate this dropzone doesn't accept internal elements
e.dataTransfer.dropEffect = 'none';
}
}); });
window.addEventListener('dragleave', (e) => { window.addEventListener('dragleave', (e) => {
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
// dragleave doesn't always have reliable dataTransfer.types, but if it does, check it
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
dragCounter--; dragCounter--;
if (dragCounter <= 0) { if (dragCounter <= 0) {
dragCounter = 0; dragCounter = 0;
@@ -72,6 +87,8 @@
window.addEventListener('drop', (e) => { window.addEventListener('drop', (e) => {
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
dropOverlay.classList.remove('active'); dropOverlay.classList.remove('active');

View File

@@ -2301,15 +2301,55 @@ window.cancelAnimFrame = (function () {
let hiddenValue = localStorage.getItem('sidebarRightHidden'); let hiddenValue = localStorage.getItem('sidebarRightHidden');
let hidden; let hidden;
if (hiddenValue === null) { if (hiddenValue === null) {
hidden = false; // Default to visible on all devices hidden = (window.f0ckSession && window.f0ckSession.hide_sidebar_default) ? true : false; // Default based on config
localStorage.setItem('sidebarRightHidden', hidden); localStorage.setItem('sidebarRightHidden', hidden);
} else { } else {
hidden = hiddenValue === 'true'; hidden = hiddenValue === 'true';
} }
document.body.classList.toggle('sidebar-right-hidden', hidden); 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(); 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 = {}) => { const loadPageAjax = async (url, replace = true, options = {}) => {
if (isNavigating) return; if (isNavigating) return;
@@ -2603,6 +2643,10 @@ window.cancelAnimFrame = (function () {
if (isFav) ajaxUrl += `&fav=true`; if (isFav) ajaxUrl += `&fav=true`;
if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`; if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`;
// Preserve tagger filter from URL query string
const taggerMatch = urlObj.searchParams.get('tagger');
if (taggerMatch) ajaxUrl += `&tagger=${encodeURIComponent(taggerMatch)}`;
const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1'); const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1');
if (isRandom) ajaxUrl += `&random=1`; if (isRandom) ajaxUrl += `&random=1`;
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
@@ -3144,6 +3188,9 @@ window.cancelAnimFrame = (function () {
window.location.href = url; // Fallback window.location.href = url; // Fallback
} finally { } finally {
isNavigating = false; isNavigating = false;
if (replace && document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
if (navbar) navbar.classList.remove("pbwork"); if (navbar) navbar.classList.remove("pbwork");
if (window.updateStrictLinks && window.f0ckSession) window.updateStrictLinks(window.f0ckSession.strict_mode); if (window.updateStrictLinks && window.f0ckSession) window.updateStrictLinks(window.f0ckSession.strict_mode);
} }
@@ -3324,10 +3371,15 @@ window.cancelAnimFrame = (function () {
const itemid = numericSegments.pop(); const itemid = numericSegments.pop();
// Extract context from Target URL first // Extract context from Target URL first
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null; let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
const tagMatch = url.match(/\/tag\/([^/?]+)/); const tagMatch = url.match(/\/tag\/([^/?]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]); if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
// Tagger filter from query string
const taggerUrlObj = new URL(url, window.location.origin);
const taggerParam = taggerUrlObj.searchParams.get('tagger');
if (taggerParam) tagger = decodeURIComponent(taggerParam);
// User hall: /user/:owner/hall/:slug/:id // User hall: /user/:owner/hall/:slug/:id
const userHallMatch = url.match(/\/user\/([^/]+)\/hall\/([^/]+)/); const userHallMatch = url.match(/\/user\/([^/]+)\/hall\/([^/]+)/);
if (userHallMatch) { if (userHallMatch) {
@@ -3365,6 +3417,10 @@ window.cancelAnimFrame = (function () {
const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/); const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/);
if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]); if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]);
} }
if (!tagger) {
const wTaggerParam = new URL(window.location.href).searchParams.get('tagger');
if (wTaggerParam) tagger = decodeURIComponent(wTaggerParam);
}
if (!user && !userHall) { if (!user && !userHall) {
const wUserMatch = window.location.href.match(/\/user\/([^/?]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/?]+)/);
if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) { if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) {
@@ -3400,6 +3456,7 @@ window.cancelAnimFrame = (function () {
} else if (user) { } else if (user) {
params.append('user', user); params.append('user', user);
} }
if (tagger) params.append('tagger', tagger);
if (isFavs) params.append('fav', 'true'); if (isFavs) params.append('fav', 'true');
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
@@ -3499,6 +3556,7 @@ window.cancelAnimFrame = (function () {
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; } else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`; else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`; else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`); if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
if (_hash) _pushUrl += _hash; if (_hash) _pushUrl += _hash;
@@ -3701,6 +3759,8 @@ window.cancelAnimFrame = (function () {
} }
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`; else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`; else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
// Append tagger filter so item nav stays in tagger context
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) { if (mime) {
// If it already has itemid at the end, insert mime before it // If it already has itemid at the end, insert mime before it
@@ -4407,6 +4467,10 @@ window.cancelAnimFrame = (function () {
if (window.renderTags) { if (window.renderTags) {
window.renderTags(res.tags); window.renderTags(res.tags);
} }
// Evict the cached item HTML so navigating back fetches fresh content
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
} else { } else {
revert(); revert();
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error'); window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
@@ -6257,8 +6321,10 @@ window.cancelAnimFrame = (function () {
if (settled) return; if (settled) return;
settled = true; settled = true;
// Freeze all transitions (override CSS !important) so the class toggle // Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger an additional right-property animation. // 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'); sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) { if (stateChanging) {
@@ -6276,6 +6342,11 @@ window.cancelAnimFrame = (function () {
// Restore CSS-controlled transitions on the next frame // Restore CSS-controlled transitions on the next frame
requestAnimationFrame(() => { requestAnimationFrame(() => {
sidebar.style.removeProperty('transition'); sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
swipeRT.isDraggingSidebar = false; swipeRT.isDraggingSidebar = false;
}); });
}; };
@@ -6402,22 +6473,13 @@ window.cancelAnimFrame = (function () {
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right'); const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
if (!sidebar) return; if (!sidebar) return;
mouseRT.active = true;
mouseRT.isDragging = false;
mouseRT.isCandidate = false;
mouseRT.xDown = e.clientX;
mouseRT.yDown = e.clientY;
mouseRT.xDiff = 0;
mouseRT.yDiff = 0;
mouseRT.timeDown = Date.now();
mouseRT.sidebarEl = sidebar;
mouseRT.sidebarWidth = sidebar.offsetWidth;
const isHidden = document.body.classList.contains('sidebar-right-hidden'); const isHidden = document.body.classList.contains('sidebar-right-hidden');
let candidate = false;
if (isHidden) { if (isHidden) {
// Open zone: 80px from right edge // Open zone: 80px from right edge
if (e.clientX > window.innerWidth - 30) { if (e.clientX > window.innerWidth - 30) {
mouseRT.isCandidate = true; candidate = true;
} }
} else { } else {
// Close: only from the left-edge grab strip (first 16px of sidebar) OR the edgeZone overhang // Close: only from the left-edge grab strip (first 16px of sidebar) OR the edgeZone overhang
@@ -6425,9 +6487,22 @@ window.cancelAnimFrame = (function () {
const sidebarLeft = sidebar.getBoundingClientRect().left; const sidebarLeft = sidebar.getBoundingClientRect().left;
const inGrabStrip = e.clientX >= sidebarLeft && e.clientX <= sidebarLeft + 16; const inGrabStrip = e.clientX >= sidebarLeft && e.clientX <= sidebarLeft + 16;
if (inGrabStrip || e.target === dragZone) { if (inGrabStrip || e.target === dragZone) {
candidate = true;
}
}
if (!candidate) return;
mouseRT.active = true;
mouseRT.isDragging = false;
mouseRT.isCandidate = true; mouseRT.isCandidate = true;
} mouseRT.xDown = e.clientX;
} mouseRT.yDown = e.clientY;
mouseRT.xDiff = 0;
mouseRT.yDiff = 0;
mouseRT.timeDown = Date.now();
mouseRT.sidebarEl = sidebar;
mouseRT.sidebarWidth = sidebar.offsetWidth;
}); });
document.addEventListener('mousemove', e => { document.addEventListener('mousemove', e => {
@@ -6447,6 +6522,8 @@ window.cancelAnimFrame = (function () {
window.getSelection()?.removeAllRanges(); // clear any selection that formed before threshold window.getSelection()?.removeAllRanges(); // clear any selection that formed before threshold
} else if (absY > absX) { } else if (absY > absX) {
mouseRT.isCandidate = false; // Scrolling — bail mouseRT.isCandidate = false; // Scrolling — bail
mouseRT.active = false;
return;
} }
} }
@@ -6507,6 +6584,10 @@ window.cancelAnimFrame = (function () {
const settle = () => { const settle = () => {
if (settled) return; if (settled) return;
settled = true; 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'); sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) { if (stateChanging) {
@@ -6519,6 +6600,11 @@ window.cancelAnimFrame = (function () {
sidebar.style.transform = ''; sidebar.style.transform = '';
requestAnimationFrame(() => { requestAnimationFrame(() => {
sidebar.style.removeProperty('transition'); sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
}); });
}; };
@@ -6586,15 +6672,55 @@ window.cancelAnimFrame = (function () {
}); });
window.addEventListener('resize', syncEdgeZone, { passive: true }); 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 => { 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) { if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
e.preventDefault(); e.preventDefault(); // always prevent 300ms synthetic click
edgeZone.dispatchEvent(new Event('click')); const touch = e.changedTouches[0];
if (touch && isWithinPillArea(touch.clientX, touch.clientY)) {
window.toggleSidebarRight();
}
} }
}, { passive: false }); }, { 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 // Smoothly toggle sidebar open/closed
window.toggleSidebarRight = () => { window.toggleSidebarRight = () => {
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right'); const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
@@ -6604,6 +6730,29 @@ window.cancelAnimFrame = (function () {
const sidebarWidth = sidebar.offsetWidth || 300; const sidebarWidth = sidebar.offsetWidth || 300;
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth; 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.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
sidebar.style.transform = `translateX(${targetTranslate}px)`; sidebar.style.transform = `translateX(${targetTranslate}px)`;
@@ -6611,13 +6760,22 @@ window.cancelAnimFrame = (function () {
const settle = () => { const settle = () => {
if (settled) return; if (settled) return;
settled = true; 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'); sidebar.style.setProperty('transition', 'none', 'important');
const toHidden = !isHidden; const toHidden = !isHidden;
document.body.classList.toggle('sidebar-right-hidden', toHidden); document.body.classList.toggle('sidebar-right-hidden', toHidden);
localStorage.setItem('sidebarRightHidden', toHidden); localStorage.setItem('sidebarRightHidden', toHidden);
void sidebar.offsetWidth; void sidebar.offsetWidth;
sidebar.style.transform = ''; 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 => { const onEnd = ev => {
@@ -6629,9 +6787,11 @@ window.cancelAnimFrame = (function () {
setTimeout(settle, 350); setTimeout(settle, 350);
}; };
// Click on edgeZone: smoothly toggle sidebar open/closed // Click on edgeZone: only toggle when the click lands on the pill
edgeZone.addEventListener('click', () => { edgeZone.addEventListener('click', (e) => {
if (isWithinPillArea(e.clientX, e.clientY)) {
window.toggleSidebarRight(); window.toggleSidebarRight();
}
}); });
// </mouse-sidebar-drag> // </mouse-sidebar-drag>
@@ -6651,7 +6811,9 @@ window.cancelAnimFrame = (function () {
}); });
const isLSActive = () => 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. // 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. // OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
@@ -6887,7 +7049,10 @@ window.cancelAnimFrame = (function () {
// ── Edge zone & sync ──────────────────────────────────────────────────── // ── Edge zone & sync ────────────────────────────────────────────────────
const syncLeftEdgeZone = () => { const syncLeftEdgeZone = () => {
const { sb, layout } = getLS(); const { sb, layout } = getLS();
if (!sb || !layout || !document.body.classList.contains('layout-modern')) { // The left drag zone only applies to the 3-column desktop grid (≥1000px).
// On mobile the sidebar becomes in-flow, so hide the zone to avoid it
// landing next to the right-sidebar drag zone on narrow screens.
if (!sb || !layout || !document.body.classList.contains('layout-modern') || window.innerWidth < 1000) {
leftEdgeZone.style.display = 'none'; leftEdgeZone.style.display = 'none';
leftEdgeZone.style.pointerEvents = 'none'; leftEdgeZone.style.pointerEvents = 'none';
return; return;
@@ -6907,6 +7072,7 @@ window.cancelAnimFrame = (function () {
}; };
window.toggleSidebarLeft = () => { window.toggleSidebarLeft = () => {
if (window.innerWidth < 1000) return;
const { sb, layout } = getLS(); const { sb, layout } = getLS();
if (!sb || !layout) return; if (!sb || !layout) return;
const isHidden = layout.classList.contains('sidebar-hidden'); const isHidden = layout.classList.contains('sidebar-hidden');
@@ -6944,6 +7110,19 @@ window.cancelAnimFrame = (function () {
// Export loaders for external systems (like NotificationSystem) // Export loaders for external systems (like NotificationSystem)
window.loadPageAjax = loadPageAjax; window.loadPageAjax = loadPageAjax;
window.loadItemAjax = loadItemAjax; window.loadItemAjax = loadItemAjax;
// Invalidate all itemCacheMap entries that contain a given item ID.
// Call this after mutating an item (e.g. adding a tag) so the next
// AJAX navigation fetches fresh HTML instead of serving stale cache.
window.invalidateItemCache = (itemId) => {
if (!itemId) return;
const needle = `/${itemId}`;
for (const key of itemCacheMap.keys()) {
if (key.includes(needle)) {
itemCacheMap.delete(key);
}
}
};
// Continued... // Continued...
const sbtForm = document.getElementById('sbtForm'); const sbtForm = document.getElementById('sbtForm');
@@ -7388,30 +7567,49 @@ class NotificationSystem {
// Helper: apply a display name change to all relevant DOM nodes // Helper: apply a display name change to all relevant DOM nodes
this.applyDisplayNameUpdate = (display_name, user) => { 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 // Capture old value before overwriting — needed for thumb matching below
const oldDisplayName = window.f0ckSession?.display_name || null; const oldDisplayName = isSelf ? (window.f0ckSession?.display_name || null) : null;
if (window.f0ckSession) window.f0ckSession.display_name = display_name || null; if (isSelf && window.f0ckSession) {
window.f0ckSession.display_name = display_name || null;
}
const userName = user || window.f0ckSession?.user; 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 => { document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
// preserve the badges (Admin, Mod)
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) { for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
let node = nameSpan.childNodes[i]; let node = nameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) { 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*)(.*)')); const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (display_name || userName || ''); node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
break; 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'); const bracket = document.getElementById('username-bracket');
if (bracket) { if (bracket) {
bracket.style.display = display_name ? 'inline' : 'none'; bracket.style.display = display_name ? 'inline' : 'none';
} }
}
if (userName) { if (userName) {
const lowerHref = `/user/${userName.toLowerCase()}`; const lowerHref = `/user/${userName.toLowerCase()}`;
@@ -7437,6 +7635,7 @@ class NotificationSystem {
// Helper: apply a username color change to relevant DOM nodes // Helper: apply a username color change to relevant DOM nodes
this.applyUsernameColorUpdate = (username_color, user) => { this.applyUsernameColorUpdate = (username_color, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
const userName = user || window.f0ckSession?.user; const userName = user || window.f0ckSession?.user;
if (!userName) return; if (!userName) return;
@@ -7483,22 +7682,21 @@ class NotificationSystem {
} }
}); });
// Special case for settings page preview block // Profile header display name color (if currently viewing this user's profile)
if (window.f0ckSession && userName === window.f0ckSession.user) { const profileBox = document.getElementById('live-profile-preview-box');
const previewBlock = document.getElementById('live-profile-preview-box'); if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
if (previewBlock) { const profileNameSpan = document.getElementById('profile-display-name');
if (profileNameSpan) {
if (username_color) { if (username_color) {
previewBlock.style.setProperty('--author-accent', username_color); profileNameSpan.style.color = username_color;
previewBlock.style.setProperty('--author-border', username_color);
} else { } else {
previewBlock.style.setProperty('--author-accent', 'var(--accent)'); profileNameSpan.style.removeProperty('color');
previewBlock.style.setProperty('--author-border', 'var(--accent)');
} }
} }
} }
// Handle the nav-display-name span elements (profile header and top navbar) // Navbar display name color (only for current user)
if (window.f0ckSession && userName === window.f0ckSession.user) { if (isSelf) {
document.querySelectorAll('#nav-display-name').forEach(el => { document.querySelectorAll('#nav-display-name').forEach(el => {
if (username_color) { if (username_color) {
el.style.color = username_color; el.style.color = username_color;
@@ -7511,14 +7709,21 @@ class NotificationSystem {
// Helper: apply an avatar update to relevant DOM nodes // Helper: apply an avatar update to relevant DOM nodes
this.applyAvatarUpdate = (avatar_file, user) => { this.applyAvatarUpdate = (avatar_file, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
const userName = user || window.f0ckSession?.user; const userName = user || window.f0ckSession?.user;
if (!userName) return; if (!userName) return;
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png'; const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
// Update Nav Avatar & profile header Avatar (only for current user) // Update Nav Avatar only for current user
if (window.f0ckSession && userName === window.f0ckSession.user) { if (isSelf) {
document.querySelectorAll('.nav-avatar-img, .profile_head_avatar img').forEach(img => img.src = newSrc); 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) // Update globally (comments, sidebar, polls, rank, infoboxes, favs)
@@ -8563,6 +8768,21 @@ class NotificationSystem {
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color) // Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
data.favs.forEach(fav => { data.favs.forEach(fav => {
if (fav.hide_fav_badge) {
const a = document.createElement('a');
a.className = 'ghost-fav';
a.setAttribute('tooltip', 'Ghost Fav');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/s/img/ghost_fav.svg';
img.style.height = '32px';
img.style.width = '32px';
a.appendChild(img);
fragment.appendChild(a);
} else {
const avatarUrl = fav.avatar_file const avatarUrl = fav.avatar_file
? `/a/${fav.avatar_file}` ? `/a/${fav.avatar_file}`
: (fav.avatar ? `/t/${fav.avatar}.webp` : '/a/default.png'); : (fav.avatar ? `/t/${fav.avatar}.webp` : '/a/default.png');
@@ -8580,6 +8800,7 @@ class NotificationSystem {
a.appendChild(img); a.appendChild(img);
fragment.appendChild(a); fragment.appendChild(a);
}
}); });
favsContainer.innerHTML = ''; favsContainer.innerHTML = '';
@@ -9382,22 +9603,24 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
window.startHoverPreview = startPreview; window.startHoverPreview = startPreview;
document.addEventListener('mouseover', (e) => { const handleEnter = (e) => {
const thumb = e.target.closest('.thumb'); const thumb = e.target.closest('.thumb');
// If we are hovering a thumb, and it's NOT the active one
if (thumb && thumb !== activeThumb) { if (thumb && thumb !== activeThumb) {
startPreview(thumb, 150); startPreview(thumb, 150);
} }
}); };
document.addEventListener('mouseover', handleEnter);
document.addEventListener('focusin', handleEnter);
document.addEventListener('mouseout', (e) => { const handleLeave = (e) => {
const thumb = e.target.closest('.thumb'); const thumb = e.target.closest('.thumb');
if (thumb) { if (thumb) {
if (e.relatedTarget && thumb.contains(e.relatedTarget)) return; if (e.type === 'mouseout' && e.relatedTarget && thumb.contains(e.relatedTarget)) return;
clearPreview(); clearPreview();
} }
}); };
document.addEventListener('mouseout', handleLeave);
document.addEventListener('focusout', handleLeave);
// Touch handling to support "tap to activate" behavior // Touch handling to support "tap to activate" behavior
// 1. First tap: Activate (visuals + preview) AND prevent navigation // 1. First tap: Activate (visuals + preview) AND prevent navigation
@@ -10188,14 +10411,21 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Only expand if the image is actually being clipped (not fully visible). // Only expand if the image is actually being clipped (not fully visible).
// Compare the image's rendered height to its natural height —
// if rendered >= natural, the image already fits fully in the box.
const img = wrapper.querySelector('img#f0ck-image'); const img = wrapper.querySelector('img#f0ck-image');
if (img && img.complete && img.naturalHeight > 0) { if (img && img.complete && img.naturalHeight > 0 && img.naturalWidth > 0) {
const rendered = img.getBoundingClientRect(); const rendered = img.getBoundingClientRect();
// Allow a 2px tolerance for subpixel rounding
// Calculate the max height the image can take given the container width
const expandedHeight = rendered.width * (img.naturalHeight / img.naturalWidth);
if (rendered.height + 2 >= img.naturalHeight) { if (rendered.height + 2 >= img.naturalHeight) {
// Fully visible — do nothing (no expand needed, no modal) // Image is unscaled or upscaled. No detail to gain by expanding.
return;
}
if (rendered.height + 2 >= expandedHeight) {
// Expanding inline will not increase display size because it is width-constrained.
openImageModal(elfe.href);
return; return;
} }
} }
@@ -11268,3 +11498,90 @@ document.addEventListener('click', (e) => {
} }
}, true); }, true);
})(); })();
// --- Grid Keyboard Accessibility ---
document.addEventListener('keydown', (e) => {
const isTab = e.key === 'Tab';
const isArrow = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key);
if (!isTab && !isArrow) return;
const thumbs = Array.from(document.querySelectorAll('.posts .thumb'));
if (thumbs.length === 0) return;
if (isTab && document.activeElement === document.body) {
e.preventDefault();
thumbs[0].focus();
return;
}
const active = document.activeElement;
if (isArrow && active && active.classList.contains('thumb')) {
const activeRect = active.getBoundingClientRect();
const activeCenter = {
x: activeRect.left + activeRect.width / 2,
y: activeRect.top + activeRect.height / 2
};
let bestMatch = null;
let minDistance = Infinity;
thumbs.forEach(thumb => {
if (thumb === active) return;
const rect = thumb.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const center = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
const dxCenter = center.x - activeCenter.x;
const dyCenter = center.y - activeCenter.y;
const absDx = Math.abs(dxCenter);
const absDy = Math.abs(dyCenter);
let isCandidate = false;
let primaryDist = 0;
let secondaryGap = 0;
if (e.key === 'ArrowRight') {
// Must be to the right, and within a 60 degree cone
isCandidate = dxCenter > 0 && absDx >= absDy * 0.5;
primaryDist = absDx;
// Gap on the Y axis. If they overlap, gap is 0
secondaryGap = Math.max(0, Math.max(activeRect.top, rect.top) - Math.min(activeRect.bottom, rect.bottom));
} else if (e.key === 'ArrowLeft') {
isCandidate = dxCenter < 0 && absDx >= absDy * 0.5;
primaryDist = absDx;
secondaryGap = Math.max(0, Math.max(activeRect.top, rect.top) - Math.min(activeRect.bottom, rect.bottom));
} else if (e.key === 'ArrowDown') {
isCandidate = dyCenter > 0 && absDy >= absDx * 0.5;
primaryDist = absDy;
secondaryGap = Math.max(0, Math.max(activeRect.left, rect.left) - Math.min(activeRect.right, rect.right));
} else if (e.key === 'ArrowUp') {
isCandidate = dyCenter < 0 && absDy >= absDx * 0.5;
primaryDist = absDy;
secondaryGap = Math.max(0, Math.max(activeRect.left, rect.left) - Math.min(activeRect.right, rect.right));
}
if (isCandidate) {
// Massive penalty for gap to force it to pick overlapping items (same row/col)
// Tiny penalty for center misalignment so ties are broken cleanly
const centerMisalignment = (e.key === 'ArrowLeft' || e.key === 'ArrowRight') ? absDy : absDx;
const distance = primaryDist + (secondaryGap * 15) + (centerMisalignment * 0.1);
if (distance < minDistance) {
minDistance = distance;
bestMatch = thumb;
}
}
});
if (bestMatch) {
e.preventDefault();
bestMatch.focus();
bestMatch.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
});

View File

@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
dmGridArea = document.createElement('div'); dmGridArea = document.createElement('div');
dmGridArea.className = 'emoji-picker-grid'; 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 ── // ── Favorites tab ──
const favsTab = document.createElement('button'); const favsTab = document.createElement('button');
favsTab.className = 'ep-tab ep-tab-favs'; 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.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('mousedown', e => e.preventDefault());
favsTab.addEventListener('click', () => showDmTab('__favs__', packs)); favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
// Right-click on fav tab → set/unset as default // Right-click on fav tab → set/unset as default
favsTab.addEventListener('contextmenu', (e) => { favsTab.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (navigator.maxTouchPoints > 0) return; if (Date.now() - lastDmHoldTime < 1000) return;
const KEY = 'f0ck_emoji_default_tab'; toggleDmFavDefault(favsTab);
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');
}
}); });
// Mark as default if already set // Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') { if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
} }
tab.addEventListener('mousedown', e => e.preventDefault()); tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs)); tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
dmTabBar.appendChild(tab); dmTabBar.appendChild(tab);
}); });

View File

@@ -1000,9 +1000,10 @@
e.stopPropagation(); e.stopPropagation();
const itemId = slide.dataset.id; const itemId = slide.dataset.id;
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, { const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) pill.remove(); if (data.success) pill.remove();
@@ -1157,10 +1158,14 @@
if (nowFaved) flashFav(slide); if (nowFaved) flashFav(slide);
} }
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/togglefav', { const resp = await fetch('/api/v2/togglefav', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
body: `postid=${id}` '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(); const data = await resp.json();
// Sync count to server truth (handles race conditions) // Sync count to server truth (handles race conditions)
@@ -1582,9 +1587,10 @@
e.stopPropagation(); e.stopPropagation();
const itemId = slide.dataset.id; const itemId = slide.dataset.id;
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, { const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) pill.remove(); if (data.success) pill.remove();
@@ -1688,7 +1694,7 @@
avatar: '/a/default.png', avatar: '/a/default.png',
stamp: p.time, stamp: p.time,
timeago: timeAgo(p.time * 1000), timeago: timeAgo(p.time * 1000),
tags: `4chan, /${data.board}/`, tags: ``,
is_video: isVideo, is_video: isVideo,
is_image: isImage, is_image: isImage,
is_audio: false, is_audio: false,
@@ -1850,16 +1856,17 @@
else if (item.external_board === 'gif') rating = 'nsfw'; else if (item.external_board === 'gif') rating = 'nsfw';
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/scroller/rehost', { const resp = await fetch('/api/v2/scroller/rehost', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'x-csrf-token': window.scrollerCsrf || '' ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
}, },
body: new URLSearchParams({ body: new URLSearchParams({
url: item.external_media_url || item.dest, url: item.external_media_url || item.dest,
rating: rating, rating: rating,
tags: '4chan', tags: '',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`, comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {}) ...(item.original_filename ? { original_filename: item.original_filename } : {})
}) })
@@ -1966,9 +1973,10 @@
showShareToast(info.toast); showShareToast(info.toast);
} }
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/v2/tags/${id}/cycle-rating`, { fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT', method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -2119,7 +2127,12 @@
const contentEl = commentEl.querySelector('.comment-content'); const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) return; 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 lines = raw.split('\n');
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`; const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
@@ -2787,11 +2800,16 @@
if (!content || !commentsItemId || commentsPosting) return; if (!content || !commentsItemId || commentsPosting) return;
commentsPosting = true; commentSendBtn.disabled = true; commentsPosting = true; commentSendBtn.disabled = true;
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`; let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`; if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
const resp = await fetch('/api/comments', { const resp = await fetch('/api/comments', {
method: 'POST', 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 body: postBody
}); });
const data = await resp.json(); const data = await resp.json();
@@ -2899,10 +2917,14 @@
if (addTagSendBtn) addTagSendBtn.disabled = true; if (addTagSendBtn) addTagSendBtn.disabled = true;
closeSugg(); closeSugg();
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${targetId}`, { const resp = await fetch(`/api/v2/tags/${targetId}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
body: `tagname=${encodeURIComponent(tag)}` '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(); const data = await resp.json();
if (data.success) { if (data.success) {
@@ -3658,7 +3680,11 @@
if (sMarkAll) { if (sMarkAll) {
sMarkAll.addEventListener('click', async () => { sMarkAll.addEventListener('click', async () => {
try { 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); updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true })); sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs); updateScrollerTabBadges(sCachedNotifs);
@@ -3673,7 +3699,12 @@
if (!item) return; if (!item) return;
const nid = item.dataset.id; const nid = item.dataset.id;
if (nid && item.classList.contains('unread')) { 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'); item.classList.remove('unread');
// Update cache // Update cache
const cached = sCachedNotifs.find(n => String(n.id) === String(nid)); const cached = sCachedNotifs.find(n => String(n.id) === String(nid));

View File

@@ -1119,6 +1119,68 @@
}); });
} }
const favsPrivateToggle = document.getElementById('favorites_private_toggle');
if (favsPrivateToggle) {
favsPrivateToggle.addEventListener('change', async () => {
const favorites_private = favsPrivateToggle.checked;
try {
const res = await fetch('/api/v2/settings/favorites_private', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ favorites_private })
});
const data = await res.json();
if (data.success) {
showStatus('Favorites privacy preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.favorites_private = favorites_private;
}
} else {
alert(data.msg || 'Error saving preference');
favsPrivateToggle.checked = !favorites_private; // Revert
}
} catch (err) {
console.error('Update Favorites Privacy error:', err);
alert('Connection error');
favsPrivateToggle.checked = !favorites_private; // Revert
}
});
}
const hideFavBadgeToggle = document.getElementById('hide_fav_badge_toggle');
if (hideFavBadgeToggle) {
hideFavBadgeToggle.addEventListener('change', async function() {
const hide_fav_badge = hideFavBadgeToggle.checked;
try {
const res = await fetch('/api/v2/settings/hide_fav_badge', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ hide_fav_badge })
});
const data = await res.json();
if (data.success) {
showStatus('Hide favorite badge preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.hide_fav_badge = hide_fav_badge;
}
} else {
alert(data.msg || 'Error saving preference');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
} catch (err) {
console.error('Update Hide Fav Badge error:', err);
alert('Connection error');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
});
}
// New Dual Column Layout Toggle // New Dual Column Layout Toggle
const layoutToggle = document.getElementById('use_new_layout_toggle'); const layoutToggle = document.getElementById('use_new_layout_toggle');
if (layoutToggle) { if (layoutToggle) {

View File

@@ -47,7 +47,7 @@
if (!unsafe) return ''; if (!unsafe) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = unsafe; div.textContent = unsafe;
return div.innerHTML; return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}; };
const playSidebarEmojiVideos = (container) => { const playSidebarEmojiVideos = (container) => {
@@ -153,12 +153,6 @@
const renderCommentContent = (content, commentId = null, itemId = null) => { const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return ''; 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') { if (typeof marked === 'undefined') {
return escapeHtml(content) return escapeHtml(content)
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n)); .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 // Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1); const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true; const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis const rendered = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n)) ? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: quoteContent; : escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`; return `<span class="greentext">&gt;${rendered}</span>`;
} }
@@ -496,8 +491,8 @@
|| rawContent.split('\n').length > 2 || rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0); || (c.files && c.files.length > 0);
} }
// Always force isLong if there are inline media tags that expand vertically // Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && (/\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) { if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
isLong = true; isLong = true;
} }
@@ -587,7 +582,7 @@
const isExpanded = container.classList.contains('expanded'); const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight; const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight; const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img:not(.emoji), video')).some( const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
media => { media => {
if (media.tagName === 'IMG') { if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true'; return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
@@ -639,8 +634,8 @@
const attachMediaLoadListeners = (element) => { const attachMediaLoadListeners = (element) => {
// Only target images and videos inside comment-content-inner. // Only target images and videos inside comment-content-inner.
// Avatars, emojis, and item preview thumbnails have fixed sizes and do not affect text overflow. // Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img:not(.emoji), .comment-content-inner video').forEach(media => { element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
if (media.dataset.loadListenerBound) return; if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true'; media.dataset.loadListenerBound = 'true';

View File

@@ -15,7 +15,7 @@
} }
}; };
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d']; const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'f0ck98', 'iced', 'orange', 'p1nk', '4d'];
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled'; const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
const setTheme = (theme) => { const setTheme = (theme) => {

View File

@@ -120,7 +120,13 @@
postid, postid,
existingTags: tags, existingTags: tags,
anchorEl: anchor, anchorEl: anchor,
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }), onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags renderTags
}); });
}; };

View File

@@ -306,7 +306,12 @@ if (!window.UserCommentSystem) {
const trimmed = line.trimStart(); const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) { if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1); 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 // Per-line limit
@@ -450,7 +455,7 @@ if (!window.UserCommentSystem) {
if (!unsafe) return ''; if (!unsafe) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = unsafe; div.textContent = unsafe;
return div.innerHTML; return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
} }
} }

View File

@@ -5,33 +5,33 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<div class="v0ck_progress_filled"></div> <div class="v0ck_progress_filled"></div>
<div class="v0ck_seek_marker"></div> <div class="v0ck_seek_marker"></div>
</div> </div>
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play"> <button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play" tabindex="-1">
<svg style="width: 20px; height: 20px;"> <svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_play" href="${svg}#play"></use> <use id="v0ck_svg_play" href="${svg}#play"></use>
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use> <use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
</svg> </svg>
</button> </button>
<div class="v0ck_volume_group"> <div class="v0ck_volume_group">
<button class="v0ck_player_button v0ck_volume"> <button class="v0ck_player_button v0ck_volume" tabindex="-1">
<svg style="width: 20px; height: 20px;"> <svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use> <use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use> <use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use> <use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
</svg> </svg>
</button> </button>
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" /> <input type="range" name="volume" min="0" max="1" step="0.01" value="1" tabindex="-1" />
</div> </div>
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button> <button class="v0ck_player_button v0ck_playtime" tabindex="-1">00:00 / 00:00</button>
<span style="flex: 30"></span> <span style="flex: 30"></span>
<div class="v0ck_settings_container"> <div class="v0ck_settings_container">
<button class="v0ck_player_button v0ck_settings_btn" title="Settings"> <button class="v0ck_player_button v0ck_settings_btn" title="Settings" tabindex="-1">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;"> <svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/> <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg> </svg>
</button> </button>
<div class="v0ck_settings_menu v0ck_hidden"> <div class="v0ck_settings_menu v0ck_hidden">
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button> <button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
<div class="v0ck_menu_item v0ck_bg_row"> <div class="v0ck_menu_item v0ck_bg_row">
<span class="v0ck_switch_label">Background</span> <span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div> <div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
@@ -44,11 +44,11 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<span class="v0ck_switch_label">Danmaku</span> <span class="v0ck_switch_label">Danmaku</span>
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div> <div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
</div> </div>
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button> <button id="v0ck_download" class="v0ck_menu_item" title="Download File" tabindex="-1">Download${size ? ` (${size})` : ''}</button>
</div> </div>
</div> </div>
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen"> <button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen" tabindex="-1">
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg> <svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
</button> </button>
</div> </div>

View File

@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
const cssDir = path.join(process.cwd(), 'public/s/css'); const cssDir = path.join(process.cwd(), 'public/s/css');
const files = ['f0ckm.css', 'v0ck.css']; const files = ['f0ckm.css', 'v0ck.css', '98.css'];
const outputFile = path.join(cssDir, 'bundle.css'); const outputFile = path.join(cssDir, 'bundle.css');
let combined = ''; let combined = '';

View File

@@ -30,7 +30,7 @@ const resolvePath = (defaultRel) => {
const absStorage = path.resolve(storage); const absStorage = path.resolve(storage);
if (defaultRel.startsWith('public/')) { if (defaultRel.startsWith('public/')) {
const sub = defaultRel.replace('public/', ''); const sub = defaultRel.replace('public/', '');
if (sub === 's/emojis' || sub === 's/koepfe') { if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
const storagePath = path.join(absStorage, sub.split('/').pop()); const storagePath = path.join(absStorage, sub.split('/').pop());
if (fs.existsSync(storagePath)) return path.resolve(storagePath); if (fs.existsSync(storagePath)) return path.resolve(storagePath);
return local; return local;
@@ -51,6 +51,7 @@ config.paths = {
s: path.join(base, 'public/s'), s: path.join(base, 'public/s'),
emojis: resolvePath('public/s/emojis'), emojis: resolvePath('public/s/emojis'),
koepfe: resolvePath('public/s/koepfe'), koepfe: resolvePath('public/s/koepfe'),
fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'), memes: resolvePath('public/memes'),
e: resolvePath('e'), e: resolvePath('e'),
pending: resolvePath('pending'), pending: resolvePath('pending'),

View File

@@ -132,6 +132,7 @@ export default new class {
// Build suffix with query params // Build suffix with query params
let suffix = env.strict ? '?strict=1' : ''; let suffix = env.strict ? '?strict=1' : '';
if (env.tagger) suffix += (suffix ? '&' : '?') + `tagger=${encodeURIComponent(env.tagger)}`;
// mainDisplay: decoded for human-readable display (e.g. div.location) // mainDisplay: decoded for human-readable display (e.g. div.location)
// main: keeps percent-encoding for use in href attributes // main: keeps percent-encoding for use in href attributes
@@ -459,4 +460,15 @@ export default new class {
console.error(`[ERROR REF ${errId}] ${context}:`, err); console.error(`[ERROR REF ${errId}] ${context}:`, err);
return `Internal Error. Reference: ${errId}`; 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

@@ -165,6 +165,10 @@
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite", "hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren", "disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren", "disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
"image_expand_on_click": "Bilder beim Klicken inline erweitern", "image_expand_on_click": "Bilder beim Klicken inline erweitern",
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.", "image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren", "enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -535,6 +539,7 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren", "subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden", "no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten", "no_favs": "Keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil", "back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren", "ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:", "ban_modal_reason": "Grund:",

View File

@@ -165,6 +165,10 @@
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items", "hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
"disable_swiping": "Disable Swiping", "disable_swiping": "Disable Swiping",
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices", "disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
"favorites_private": "Private Favorites",
"favorites_private_hint": "Only you and administrators can view your favorites list.",
"hide_fav_badge": "Hide Favorite Badge Avatar",
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
"image_expand_on_click": "Expand images inline on click", "image_expand_on_click": "Expand images inline on click",
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.", "image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
"enable_bg_blur": "Enable Background blur", "enable_bg_blur": "Enable Background blur",
@@ -539,6 +543,7 @@
"subscribe_uploads_btn": "Subscribe user to uploads", "subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found", "no_uploads": "no uploads found",
"no_favs": "no favorites", "no_favs": "no favorites",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile", "back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User", "ban_modal_title": "Ban User",
"ban_modal_reason": "Reason:", "ban_modal_reason": "Reason:",

View File

@@ -165,6 +165,10 @@
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items", "hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
"disable_swiping": "Swipen uitschakelen", "disable_swiping": "Swipen uitschakelen",
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten", "disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
"favorites_private": "Privé favorieten",
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
"hide_fav_badge": "Favoriet-badge avatar verbergen",
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken", "image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.", "image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
"enable_bg_blur": "Achtergrondvervaging inschakelen", "enable_bg_blur": "Achtergrondvervaging inschakelen",
@@ -535,6 +539,7 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads", "subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden", "no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten", "no_favs": "geen favorieten",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel", "back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen", "ban_modal_title": "Gebruiker Bannen",
"ban_modal_reason": "Reden:", "ban_modal_reason": "Reden:",

View File

@@ -165,6 +165,10 @@
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite", "hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren", "disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten", "disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
"image_expand_on_click": "Bildli beim Klickle inline uffblähe", "image_expand_on_click": "Bildli beim Klickle inline uffblähe",
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.", "image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren", "enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -536,6 +540,7 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren", "subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden", "no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten", "no_favs": "keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil", "back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren", "ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:", "ban_modal_reason": "Grund:",

View File

@@ -18,7 +18,7 @@ const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === '
const COUNT_CACHE_TTL_MS = 90_000; const COUNT_CACHE_TTL_MS = 90_000;
const countCache = new Map(); // key → { total, expiresAt } const countCache = new Map(); // key → { total, expiresAt }
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }) { function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
return JSON.stringify([ return JSON.stringify([
modequery, modequery,
tag ?? '', tag ?? '',
@@ -30,7 +30,8 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
excludedTags.slice().sort().join(','), excludedTags.slice().sort().join(','),
newerThan ?? '', newerThan ?? '',
minXd, minXd,
userHallObj?.id ?? '' userHallObj?.id ?? '',
tagger ?? ''
]); ]);
} }
@@ -135,8 +136,60 @@ const xdScoreMeta = (score) => {
return { tier: 5, label: 'xDDDDD+' }; return { tier: 5, label: 'xDDDDD+' };
}; };
async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) {
if (!rawUser) return { isPrivate: false, isAllowed: true };
const decodedUser = decodeURI(rawUser);
const targetUserRows = await db`
select u.id, u."user", u.admin, uo.favorites_private
from "user" u
left join user_options uo on uo.user_id = u.id
where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser}
limit 1
`;
if (!targetUserRows.length || !targetUserRows[0].favorites_private) {
return { isPrivate: false, isAllowed: true };
}
const targetUser = targetUserRows[0];
let reqUserId = user_id;
let reqIsAdmin = is_admin;
if (typeof session === 'object' && session !== null) {
if (reqUserId === undefined) reqUserId = session.id;
if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin;
}
let isAllowed = false;
if (reqUserId) {
if (+reqUserId === +targetUser.id) {
isAllowed = true;
} else if (reqIsAdmin === true) {
isAllowed = true;
} else if (reqIsAdmin === undefined) {
const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`;
if (reqUserRows.length > 0 && reqUserRows[0].admin) {
isAllowed = true;
}
}
}
return { isPrivate: true, isAllowed };
}
export default { export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => { getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table --- // --- title: prefix — search items.title instead of the tags table ---
@@ -189,7 +242,8 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0; const isStrict = strictParams.length > 0;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner }; const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment // Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null; const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0); const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
@@ -200,6 +254,21 @@ export default {
if (isTitleSearch && titleQuery) { if (isTitleSearch && titleQuery) {
// Title search: match items.title ILIKE '%query%' // Title search: match items.title ILIKE '%query%'
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`; titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
} else if (tagger && tag) {
// Tagger+tag filter: items where the specific user applied this specific tag
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
const conditions = terms.map(term => {
return db`and items.id in (
select ta.item_id from tags_assign ta
join tags t on t.id = ta.tag_id
join "user" u on u.id = ta.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
)`;
});
tagFilter = db`${conditions}`;
}
} else if (tag) { } else if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean); const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) { if (terms.length > 0) {
@@ -233,7 +302,7 @@ export default {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
} }
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }); const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = getCachedCount(cacheKey); let total = getCachedCount(cacheKey);
if (total === null) { if (total === null) {
@@ -372,7 +441,7 @@ export default {
for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++) for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++)
cheat.push(i); cheat.push(i);
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict }); const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict, tagger });
// Override link for title searches — pagination must use the /tag/title:... prefix // Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) { if (isTitleSearch && titleQuery) {
@@ -411,7 +480,18 @@ export default {
view_mode: fav ? 'favs' : 'uploads' view_mode: fav ? 'favs' : 'uploads'
}; };
}, },
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => { getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table --- // --- title: prefix — search items.title instead of the tags table ---
@@ -688,7 +768,7 @@ export default {
link.suffix = ''; link.suffix = '';
} }
const favorites = await db` const favorites = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge
from "favorites" from "favorites"
left join "user" on "user".id = "favorites".user_id left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id left join "user_options" on "user_options".user_id = "favorites".user_id
@@ -867,7 +947,14 @@ export default {
tmp tmp
}; };
return data; return data;
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => { },
getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return null;
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const hall = rawHall || null; const hall = rawHall || null;

View File

@@ -1428,8 +1428,8 @@ export default (router, tpl) => {
const userId = newUser[0].id; const userId = newUser[0].id;
await db` await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping) 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}) 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, { await audit.log(req.session.id, 'admin_create_user', 'user', userId, {

View File

@@ -218,18 +218,33 @@ export default (router, tpl) => {
mime: query.mime || (req.cookies.mime || null), mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode, mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr, ratings: ratingsArr,
session: !!req.session, session: req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id, user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
fav: query.fav === 'true', fav: query.fav === 'true',
random: isRandom, random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode, strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true', explicitStrict: query.strict === '1' || query.strict === 'true',
newer: query.newer || null, newer: query.newer || null,
minXdScore: req.session?.min_xd_score || 0 minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
}); });
if (!data.success) { if (!data.success) {
if (data.is_private) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
is_private: true,
html: privateHtml,
hasMore: false
})
});
}
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1066,7 +1066,7 @@ export default router => {
} }
const favs = await db` const favs = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge
from "favorites" from "favorites"
left join "user" on "user".id = "favorites".user_id left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id left join "user_options" on "user_options".user_id = "favorites".user_id

View File

@@ -315,6 +315,44 @@ export default router => {
} }
}); });
// Update Favorites Privacy preference
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
try {
await db`
update user_options
set favorites_private = ${favorites_private}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.favorites_private = favorites_private;
return res.json({ success: true, favorites_private }, 200);
} catch (e) {
console.error('Update Favorites Privacy pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Hide Fav Badge preference
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
try {
await db`
update user_options
set hide_fav_badge = ${hide_fav_badge}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
return res.json({ success: true, hide_fav_badge }, 200);
} catch (e) {
console.error('Update Hide Fav Badge pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference // Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => { group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post; const { color } = req.post;
@@ -486,7 +524,7 @@ export default router => {
// F-023 Security: Validate font against actual files on disk // F-023 Security: Validate font against actual files on disk
// The font value is rendered into CSS url() in header.html, so it must be a real filename // The font value is rendered into CSS url() in header.html, so it must be a real filename
if (font) { if (font) {
const fontsDir = path.join(path.resolve(), 'public/s/fonts'); const fontsDir = cfg.paths.fonts;
try { try {
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)); const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
if (!available.includes(font)) { if (!available.includes(font)) {

View File

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

View File

@@ -336,7 +336,16 @@ export default router => {
} }
} }
const url = inputUrl.trim(); let url = inputUrl.trim();
try {
const parsed = new URL(url);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
url = parsed.toString();
}
} catch (e) {
// Ignore malformed URL, keep original string
}
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i; const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = url.match(ytRegex); const ytMatch = url.match(ytRegex);

View File

@@ -99,8 +99,9 @@ export default (router, tpl) => {
ratings: ratingsArr, ratings: ratingsArr,
mime: mime, mime: mime,
fav: true, fav: true,
session: !!req.session, session: req.session,
user_id: req.session?.id, user_id: req.session?.id,
is_admin: req.session?.admin,
random: isRandom random: isRandom
}); });
if (favs && 'items' in favs) { if (favs && 'items' in favs) {
@@ -236,20 +237,36 @@ export default (router, tpl) => {
fav: req.params.mode == 'favs', fav: req.params.mode == 'favs',
mode: req.mode, mode: req.mode,
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
session: !!req.session, session: req.session,
user_id: req.session?.id, user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [], exclude: req.session ? (req.session.excluded_tags || []) : [],
url: decodeURIComponent(req.url.pathname || req.url), url: decodeURIComponent(req.url.pathname || req.url),
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode), strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
explicitStrict: !!(req.query?.strict || req.url.qs?.strict), explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
random: req.cookies.random_mode === '1', random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)), minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang lang: req.lang,
tagger: req.url.qs?.tagger || null
}); });
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`); console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) { if (!data.success) {
if (data.is_private) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
code: 403,
body: tpl.render('error', {
message: tErr('profile.private_favorites'),
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false,
error_filter_hint: null,
error_filter_hint_link: null
}, req)
});
}
// For index/grid views with zero items (empty DB), render an empty grid instead of error // For index/grid views with zero items (empty DB), render an empty grid instead of error
if (mode !== 'item') { if (mode !== 'item') {
data.items = []; data.items = [];

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.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
} }
return res.reply({ 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.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
} }
return res.reply({ 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.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
} }
return res.reply({ 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."); return renderError("Passwords do not match.");
} }
// reCAPTCHA verification // reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) { const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response']; const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA."); if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try { try {
@@ -167,8 +168,8 @@ export default (router, tpl) => {
const avatarFile = 'default.png'; const avatarFile = 'default.png';
await db` await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping) 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}) 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) { } catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err); console.error(`[REGISTER] DB Error during user creation:`, err);

View File

@@ -23,6 +23,11 @@ export default (router, tpl) => {
route: /^\/s\/koepfe\// route: /^\/s\/koepfe\//
}); });
router.static({
dir: cfg.paths.fonts,
route: /^\/s\/fonts\//
});
router.static({ router.static({
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'), dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
route: /^\/s\/ruffle\// route: /^\/s\/ruffle\//

View File

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

View File

@@ -120,7 +120,7 @@ export default (router, tpl) => {
return res.json({ return res.json({
success: true, 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: { user: userName }, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page, currentPage: page,
pagination: paginationHtml, pagination: paginationHtml,
hasMore: tags.length === TAGS_PER_PAGE hasMore: tags.length === TAGS_PER_PAGE

View File

@@ -49,7 +49,7 @@ const gateOptions = {
}, },
cloudflare_status: { cloudflare_status: {
status: 'ok', status: 'ok',
location: 'Frankfurt', location: cfg.websrv.private_society_gate_location || 'Frankfurt',
name: 'Cloudflare', name: 'Cloudflare',
status_text: 'Working', status_text: 'Working',
}, },
@@ -79,11 +79,13 @@ const nginx502Fallback = `<html>
</html>`; </html>`;
// Login + Register modal injected before </body> // 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 _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
const _rcSiteKey = (cfg.recaptcha && 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="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;"> <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" <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;" /> 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" /> <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;" <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> 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;"> <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); if (_sb.length > 12) _sb = _sb.slice(-12);
}); });
</script> </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 // 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 // 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) { function buildGatePage(req) {
if (!_cfRender) return nginx502Fallback; 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 // Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
if (req) { if (req) {
@@ -305,9 +337,9 @@ function buildGatePage(req) {
// Patch the <title> to include the domain — matches real Cloudflare behaviour // Patch the <title> to include the domain — matches real Cloudflare behaviour
html = html.replace( html = html.replace(
'<title>502: Bad Gateway</title>', '<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; return html;
} }
@@ -316,7 +348,14 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
? null ? null
: nginx502Fallback; : 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 (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=()'); res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes // Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
res.setHeader('Connection', 'keep-alive'); 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) { if (isSecure) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
} }
@@ -505,6 +560,45 @@ process.on('uncaughtException', err => {
} }
}); });
// ─── Session cache ────────────────────────────────────────────────────────
// The session middleware runs in parallel with all other app.use() handlers
// (flummpress uses Promise.all). With many concurrent requests each doing a
// DB SELECT, the pool (max:50) drains and nginx sees 502s. Caching the
// session row for 5s and the upload-count for 30s eliminates the hot path.
const SESSION_CACHE_TTL = 5_000; // ms — short enough to pick up bans quickly
const UPLOAD_COUNT_CACHE_TTL = 30_000; // ms — upload window is 12h, stale for 30s is fine
const _sessionCache = new Map(); // sha256(cookie) -> { data: row, exp: number }
const _uploadCountCache = new Map(); // username -> { count: number, exp: number }
const _scGet = (hash) => {
const e = _sessionCache.get(hash);
if (e && e.exp > Date.now()) return e.data;
_sessionCache.delete(hash);
return null;
};
const _scSet = (hash, data) => {
_sessionCache.set(hash, { data, exp: Date.now() + SESSION_CACHE_TTL });
if (_sessionCache.size > 2000) {
const now = Date.now();
for (const [k, v] of _sessionCache) if (v.exp <= now) _sessionCache.delete(k);
}
};
// Exposed so logout/ban routes can force-evict immediately
global._invalidateSessionCache = (hash) => _sessionCache.delete(hash);
const _ucGet = (username) => {
const e = _uploadCountCache.get(username);
if (e && e.exp > Date.now()) return e.count;
_uploadCountCache.delete(username);
return null;
};
const _ucSet = (username, count) => {
_uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL });
};
// Allow upload handler to bust the cache after a successful upload
global._invalidateUploadCountCache = (username) => _uploadCountCache.delete(username);
// ──────────────────────────────────────────────────────────────────────────
app.use(async (req, res) => { app.use(async (req, res) => {
// This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass // This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass
// if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`); // if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`);
@@ -565,20 +659,28 @@ process.on('uncaughtException', err => {
} }
return; return;
} }
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d']; const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'f0ck98', 'iced', 'orange', 'p1nk', '4d'];
const defaultTheme = cfg.websrv.theme || 'amoled'; const defaultTheme = cfg.websrv.theme || 'amoled';
req.theme = (req.cookies?.theme && availableThemes.includes(req.cookies.theme)) ? req.cookies.theme : defaultTheme; req.theme = (req.cookies?.theme && availableThemes.includes(req.cookies.theme)) ? req.cookies.theme : defaultTheme;
req.fullscreen = req.cookies.fullscreen || 0; req.fullscreen = req.cookies.fullscreen || 0;
if (req.cookies.session) { if (req.cookies.session) {
const user = await db` const _sessionHash = lib.sha256(req.cookies.session);
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode let _cachedRow = _scGet(_sessionHash);
let user;
if (_cachedRow) {
user = [_cachedRow];
} else {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
from "user_sessions" from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)} where "user_sessions".session = ${_sessionHash}
limit 1 limit 1
`; `;
if (user.length > 0) _scSet(_sessionHash, user[0]);
}
if (user.length === 0) { if (user.length === 0) {
res.writeHead(307, { // delete session res.writeHead(307, { // delete session
@@ -651,8 +753,10 @@ process.on('uncaughtException', err => {
req.session.pending_count = pending[0].c; req.session.pending_count = pending[0].c;
} }
// Calculate uploads remaining globally for the modal // Calculate uploads remaining globally for the modal (cached 30s per user)
if (!req.session.admin && !req.session.is_moderator) { if (!req.session.admin && !req.session.is_moderator) {
let cachedUploadCount = _ucGet(req.session.user);
if (cachedUploadCount === null) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600); const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db` const uploadCount = await db`
SELECT count(*) as count SELECT count(*) as count
@@ -661,7 +765,10 @@ process.on('uncaughtException', err => {
AND stamp > ${twelveHoursAgo} AND stamp > ${twelveHoursAgo}
AND is_deleted = false AND is_deleted = false
`; `;
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count)); cachedUploadCount = parseInt(uploadCount[0].count);
_ucSet(req.session.user, cachedUploadCount);
}
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - cachedUploadCount);
} else { } else {
req.session.uploads_remaining = undefined; // Unlimited for admins/mods req.session.uploads_remaining = undefined; // Unlimited for admins/mods
} }
@@ -749,18 +856,21 @@ process.on('uncaughtException', err => {
req.url.pathname = '/private_society_bypass'; req.url.pathname = '/private_society_bypass';
return; return;
} }
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate) // For page requests, serve the gate for all paths (custom template or fallback)
if (req.url.pathname !== '/') { 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)); res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
req.url.pathname = '/private_society_bypass'; } else if (nginx502 === null) {
return; // Homepage in cloudflare dynamic mode: serve the CF gate directly
}
// Homepage: in cloudflare dynamic mode serve gate directly; otherwise fall through to template
if (nginx502 === null) {
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req)); 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; return;
} }
req.url.pathname = '/private_society_bypass';
return;
} }
} }
}); });
@@ -1240,6 +1350,7 @@ process.on('uncaughtException', err => {
show_mime_picker: cfg.websrv.show_mime_picker !== false, show_mime_picker: cfg.websrv.show_mime_picker !== false,
private_society: cfg.websrv.private_society || false, private_society: cfg.websrv.private_society || false,
private_society_gate: cfg.websrv.private_society_gate || '', 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, show_content_warning: cfg.websrv.show_content_warning !== false,
web_url_upload: !!cfg.websrv.web_url_upload, web_url_upload: !!cfg.websrv.web_url_upload,
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false, enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
@@ -1248,6 +1359,7 @@ process.on('uncaughtException', err => {
custom_favicon: cfg.websrv.custom_favicon || "", custom_favicon: cfg.websrv.custom_favicon || "",
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""), custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "", custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "",
default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster", site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl, enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: cfg.nsfl_tag_id || 3, nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1267,6 +1379,7 @@ process.on('uncaughtException', err => {
ts: Date.now(), ts: Date.now(),
get default_layout() { return getDefaultLayout(); }, get default_layout() { return getDefaultLayout(); },
show_koepfe: !!cfg.websrv.show_koepfe, show_koepfe: !!cfg.websrv.show_koepfe,
hide_sidebar_default: !!cfg.websrv.hide_sidebar_default,
allow_language_change: cfg.websrv.allow_language_change !== false, allow_language_change: cfg.websrv.allow_language_change !== false,
enable_xd_score: !!cfg.websrv.enable_xd_score, enable_xd_score: !!cfg.websrv.enable_xd_score,
enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs, enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs,
@@ -1302,7 +1415,7 @@ process.on('uncaughtException', err => {
get fonts() { get fonts() {
try { try {
const fontsDir = path.join(path.resolve(), 'public/s/fonts'); const fontsDir = cfg.paths.fonts;
if (!fs.existsSync(fontsDir)) return []; if (!fs.existsSync(fontsDir)) return [];
return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({ return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({
name: f.split('.').shift(), name: f.split('.').shift(),
@@ -1349,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 // 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. // 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. // inside render(), so globals must carry the per-request values too.
globals.t = perRequestT; globals.t = perRequestT;
globals.lang = perRequestLang; globals.lang = perRequestLang;
globals.recaptcha_enabled = perRequestRecaptcha;
// Resolve per-request infobox preference // Resolve per-request infobox preference
// Guests always get false — the alternative infobox is a logged-in user preference only // 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') const activeReq = req || effectiveReq;
? req.session.use_alternative_infobox const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
: (req && !req.session ? activeReq.session.use_alternative_infobox
: (activeReq && !activeReq.session
? false ? false
: (data && typeof data.user_alternative_infobox === 'boolean' : (data && typeof data.user_alternative_infobox === 'boolean'
? data.user_alternative_infobox ? data.user_alternative_infobox
: (cfg.websrv.user_alternative_infobox !== false))); : (cfg.websrv.user_alternative_infobox !== false)));
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean') const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
? req.session.use_alternative_steuerung ? activeReq.session.use_alternative_steuerung
: (req && !req.session : (activeReq && !activeReq.session
? false ? false
: (data && typeof data.user_alternative_steuerung === 'boolean' : (data && typeof data.user_alternative_steuerung === 'boolean'
? data.user_alternative_steuerung ? data.user_alternative_steuerung
@@ -1377,11 +1503,12 @@ process.on('uncaughtException', err => {
data = Object.assign({}, globals, data || {}, { data = Object.assign({}, globals, data || {}, {
t: perRequestT, t: perRequestT,
lang: perRequestLang, lang: perRequestLang,
recaptcha_enabled: perRequestRecaptcha,
user_alternative_infobox: useAltInfobox, user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung, user_alternative_steuerung: useAltSteuerung,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false, user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number') comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
? req.session.comment_display_mode ? activeReq.session.comment_display_mode
: (data && typeof data.comment_display_mode === 'number' : (data && typeof data.comment_display_mode === 'number'
? data.comment_display_mode ? data.comment_display_mode
: (cfg.websrv.default_comment_display_mode || 0)) : (cfg.websrv.default_comment_display_mode || 0))
@@ -1393,17 +1520,19 @@ process.on('uncaughtException', err => {
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)]; data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
} }
if (req) { if (activeReq) {
if (req.mode !== undefined) data.mode = req.mode; data.recaptcha_enabled = perRequestRecaptcha;
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck'; if (activeReq.mode !== undefined) data.mode = activeReq.mode;
if (!data.url) data.url = req.url; data.theme = activeReq.theme || activeReq.cookies?.theme || cfg.websrv.theme || 'f0ck';
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false; if (!data.url) data.url = activeReq.url;
data.user_logged_in_bool = !!req.session; data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
data.csrf_token = req.session?.csrf_token || ''; data.user_logged_in_bool = !!activeReq.session;
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1)); data.csrf_token = activeReq.session?.csrf_token || '';
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1)); 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; data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
} else { } else {
data.recaptcha_enabled = perRequestRecaptcha;
data.theme = data.theme || cfg.websrv.theme || 'f0ck'; data.theme = data.theme || cfg.websrv.theme || 'f0ck';
data.user_strict_bool = false; data.user_strict_bool = false;
data.user_logged_in_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 () => { ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/users/activate', { var res = await fetch('/api/v2/admin/users/activate', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id })
}); });
var data = await res.json(); var data = await res.json();
@@ -339,7 +339,7 @@
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => { ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
var res = await fetch('/api/v2/admin/ban', { var res = await fetch('/api/v2/admin/ban', {
method: 'POST', 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' }) body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
}); });
var data = await res.json(); var data = await res.json();
@@ -359,7 +359,7 @@
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => { ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/unban', { var res = await fetch('/api/v2/admin/unban', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id })
}); });
var data = await res.json(); 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 () => { 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', { var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id, username: btn.dataset.username })
}); });
var data = await res.json(); 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 () => { 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', { var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id, username: btn.dataset.username })
}); });
var data = await res.json(); var data = await res.json();
@@ -422,7 +422,7 @@
ModAction.confirm('Set Display Name', hint, async (newName) => { ModAction.confirm('Set Display Name', hint, async (newName) => {
var res = await fetch('/api/v2/admin/users/set-display-name', { var res = await fetch('/api/v2/admin/users/set-display-name', {
method: 'POST', 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 || '' }) body: JSON.stringify({ user_id: id, display_name: newName || '' })
}); });
var data = await res.json(); 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 () => { 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', { var res = await fetch('/api/v2/admin/users/lock-layout', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id, lock: false })
}); });
var data = await res.json(); var data = await res.json();
@@ -481,7 +481,7 @@
var mode = document.getElementById('force-mode-select').value; var mode = document.getElementById('force-mode-select').value;
var res = await fetch('/api/v2/admin/users/lock-layout', { var res = await fetch('/api/v2/admin/users/lock-layout', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id, lock: true, mode: mode })
}); });
var data = await res.json(); var data = await res.json();
@@ -616,7 +616,7 @@
try { try {
var res = await fetch('/api/v2/admin/users/create', { var res = await fetch('/api/v2/admin/users/create', {
method: 'POST', 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 }) body: JSON.stringify({ username, email: email || null, password, role })
}); });
var data = await res.json(); var data = await res.json();
@@ -662,7 +662,7 @@
var role = selectEl.value; var role = selectEl.value;
var res = await fetch('/api/v2/admin/users/set-role', { var res = await fetch('/api/v2/admin/users/set-role', {
method: 'POST', 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 }) body: JSON.stringify({ user_id: id, role })
}); });
var data = await res.json(); var data = await res.json();

View File

@@ -197,7 +197,11 @@
</span> </span>
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif> <span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
@each(item.favorites as fav) @each(item.favorites as fav)
@if(fav.hide_fav_badge)
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a> <a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@endif
@endeach @endeach
</span> </span>
@if(enable_xd_score && item.xd_score > 0) @if(enable_xd_score && item.xd_score > 0)
@@ -296,7 +300,7 @@
@if(item.src.short) @if(item.src.short)
<tr> <tr>
<th>{{ t('info_modal.source') || 'Source' }}</th> <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> </tr>
@endif @endif
</table> </table>

View File

@@ -39,7 +39,6 @@
@endif @endif
<div class="sidebar-tags-container"> <div class="sidebar-tags-container">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--white); font-size: 0.9em; text-transform: uppercase;"></div>
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;"> <span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)
@@ -158,7 +157,11 @@
</div> </div>
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;"> <span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
@each(item.favorites as fav) @each(item.favorites as fav)
@if(fav.hide_fav_badge)
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a> <a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@endif
@endeach @endeach
</span> </span>
@@ -239,7 +242,7 @@
@if(item.src.short) @if(item.src.short)
<tr> <tr>
<th>{{ t('info_modal.source') || 'Source' }}</th> <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> </tr>
@endif @endif
</table> </table>

View File

@@ -8,7 +8,7 @@
.meme-title { font-family: 'Impact', 'VCR', sans-serif; } .meme-title { font-family: 'Impact', 'VCR', sans-serif; }
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */ /* 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 */ /* Target all possible shell triggers */
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper { html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
height: auto !important; height: auto !important;

View File

@@ -320,6 +320,20 @@
</label> </label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small> <small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
</div> </div>
<div class="setting-item" style="margin-top: 15px;">
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
<span>{{ t('settings.favorites_private') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
<span>{{ t('settings.hide_fav_badge') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
</div>
</div>
<div class="setting-item" style="margin-top: 15px;"> <div class="setting-item" style="margin-top: 15px;">
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;"> <label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif> <input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>

View File

@@ -127,7 +127,7 @@
@endif @endif
@if(private_society && !session) @if(private_society && !session)
<script> <script>
window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif }; window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
(() => { (() => {
const loginModal = document.getElementById('login-modal'); const loginModal = document.getElementById('login-modal');
@@ -406,6 +406,7 @@
dm_unencrypted: @if(dm_unencrypted) true @else false @endif, dm_unencrypted: @if(dm_unencrypted) true @else false @endif,
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
enable_comment_polls: @if(enable_comment_polls) true @else false @endif, enable_comment_polls: @if(enable_comment_polls) true @else false @endif,
hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif,
mode: {{ mode !== undefined ? mode : 0 }} mode: {{ mode !== undefined ? mode : 0 }}
}; };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};

View File

@@ -8,6 +8,7 @@
<meta name="mobile-web-app-capable" content="yes"> <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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ domain }}"> <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> <style>
html { background-color: #000; color: #fff; } html { background-color: #000; color: #fff; }
@if(session && session.font) @if(session && session.font)
@@ -15,10 +16,18 @@
font-family: 'CustomUserFont'; font-family: 'CustomUserFont';
src: url('/s/fonts/{{ session.font }}'); src: url('/s/fonts/{{ session.font }}');
} }
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
{{ "@" }}font-face {
font-family: 'SiteDefaultFont';
src: url('/s/fonts/{{ default_font }}');
}
@endif @endif
:root { :root {
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
@if(session && session.font) @if(session && session.font)
--font: 'CustomUserFont', monospace !important; --font: 'CustomUserFont', monospace !important;
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
--font: 'SiteDefaultFont', monospace !important;
@endif @endif
} }
@if(session && session.font) @if(session && session.font)
@@ -30,6 +39,15 @@
.fas::before, .far::before, .fab::before, .fa::before { .fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important; font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
} }
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
*:not(canvas):not(.meme-title):not(.force-impact):not(.fa-solid):not(.fa-regular):not(.fa-brands):not(.fa-light):not(.fa-thin):not(.fa-duotone):not([class*=" fa-"]):not([class^="fa-"]) {
font-family: 'SiteDefaultFont', monospace !important;
}
/* Explicitly protect FA pseudo-elements */
.fa-solid::before, .fa-regular::before, .fa-brands::before,
.fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
}
@endif @endif
</style> </style>
<link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin> <link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin>
@@ -38,6 +56,7 @@
@if(!private_society || session) @if(!private_society || session)
@if(development || (private_society && !session)) @if(development || (private_society && !session))
<link rel="stylesheet" href="/s/css/f0ckm.css?v={{ ts }}"> <link rel="stylesheet" href="/s/css/f0ckm.css?v={{ ts }}">
<link rel="stylesheet" href="/s/css/98.css?v={{ ts }}">
@endif @endif
@if(development) @if(development)
<link rel="stylesheet" href="/s/css/v0ck.css?v={{ ts }}"> <link rel="stylesheet" href="/s/css/v0ck.css?v={{ ts }}">
@@ -78,7 +97,7 @@
</head> </head>
<body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif"> <body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif">
<script>if(localStorage.getItem('sidebarRightHidden')==='true')document.body.classList.add('sidebar-right-hidden');if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script> <script>var sRH = localStorage.getItem('sidebarRightHidden'); if(sRH==='true' || (sRH===null && @if(hide_sidebar_default) true @else false @endif)) document.body.classList.add('sidebar-right-hidden'); if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script>
@if(!private_society || session) @if(!private_society || session)
<canvas class="hidden-xs" id="bg"></canvas> <canvas class="hidden-xs" id="bg"></canvas>
@endif @endif

View File

@@ -1,5 +1,5 @@
@each(toptags as toptag) @each(toptags as toptag)
<a href="/tag/{{ toptag.safe_tag }}" class="tag-card"> <a href="/tag/{{ toptag.safe_tag }}@if(user && user.user)?tagger={!! encodeURIComponent(user.user) !!}@endif" class="tag-card">
<div class="tag-card-image"> <div class="tag-card-image">
<img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}"> <img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}">
</div> </div>

View File

@@ -48,7 +48,7 @@
<div class="profile_head_username"> <div class="profile_head_username">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;"> <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"> <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) @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"> <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> <i class="fa-solid fa-pen"></i>
@@ -142,6 +142,14 @@
</div> </div>
@if(!user.is_ghost) @if(!user.is_ghost)
<div class="favs"> <div class="favs">
@if(favs && favs.is_private)
<div class="favs-header">
{{ t('profile.favs_label') }}
</div>
<div class="private-favs-msg" style="text-align: center;">
{{ t('profile.private_favorites') }}
</div>
@else
<div class="favs-header"> <div class="favs-header">
{{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a> {{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a>
</div> </div>
@@ -164,6 +172,7 @@
@else @else
{{ t('profile.no_favs') }} {{ t('profile.no_favs') }}
@endif @endif
@endif
</div> </div>
@endif @endif
</div> </div>
@@ -509,18 +518,27 @@
nameContainer.style.display = 'flex'; nameContainer.style.display = 'flex';
nameEditContainer.style.display = 'none'; nameEditContainer.style.display = 'none';
const nameSpan = document.getElementById('nav-display-name'); const updateNameNode = (el, text) => {
if (nameSpan) { if (!el) return;
nameSpan.style.color = color; for (let i = el.childNodes.length - 1; i >= 0; i--) {
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) { let node = el.childNodes[i];
let node = nameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) { 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*)(.*)'));
const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)')); node.nodeValue = (match ? match[1] : '') + text;
node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}');
break; 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'); const bracket = document.getElementById('username-bracket');
if (bracket) { if (bracket) {