727 Commits

Author SHA1 Message Date
1324512ebf svelte lol 2026-07-19 19:40:25 +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
b53ff15f91 xd 2026-07-18 01:33:17 +02:00
3eb44c0949 ffff 2026-07-18 01:31:16 +02:00
84d4068e19 youtkakapipibe 2026-07-18 00:47:04 +02:00
9a4aa4585c pipi kaka 2026-07-18 00:33:05 +02:00
98c8b8d097 fg 2026-07-18 00:22:16 +02:00
b7ba21c93a parser :) 2026-07-18 00:22:01 +02:00
5ef0d5c62e I am your father 2026-07-17 23:48:33 +02:00
d13693de01 luke... 2026-07-17 23:42:50 +02:00
a68c8c1618 eridi 2026-07-17 23:39:36 +02:00
3440f98bcd fdsa 2026-07-17 23:25:14 +02:00
298f12305e dynamic köpfe 2026-07-17 19:36:12 +02:00
ba36316fcb 700px! 2026-07-17 18:53:27 +02:00
4ea3db078f jfg 2026-07-17 18:49:14 +02:00
7f171d04d5 j 2026-07-17 18:46:22 +02:00
6da7951b7c user tags 2026-07-17 18:07:25 +02:00
a06beef458 fix profile comment page image attachments 2026-07-17 17:57:46 +02:00
5388813da9 fds 2026-07-17 17:50:58 +02:00
a1f1dd4523 hide ratings 2026-07-17 17:48:02 +02:00
ca74655a0e gfsd 2026-07-17 17:39:11 +02:00
caebbbe101 all i ever wanted 2026-07-17 17:16:25 +02:00
e66c4bf43b fdsa 2026-07-17 17:07:39 +02:00
e4c525cb7d margin 2026-07-17 17:06:04 +02:00
48b86e8003 fdas 2026-07-17 17:03:46 +02:00
cba0f77732 bvdx 2026-07-17 17:01:49 +02:00
2ff53b6513 profile update 2026-07-17 16:56:40 +02:00
0c0f0fd2a9 gf 2026-07-17 16:28:55 +02:00
5256a9eb07 gfd 2026-07-17 13:00:46 +02:00
41d18bbc54 gfsd 2026-07-17 12:55:11 +02:00
7fe2192142 center top 2026-07-17 12:49:08 +02:00
843acab8b3 sss 2026-07-17 12:34:29 +02:00
a6193c13fb video comments on profile comment page 2026-07-17 11:45:46 +02:00
e219bc5573 gfsd 2026-07-17 11:45:01 +02:00
32a7b1afac gfsd 2026-07-17 11:33:51 +02:00
994b607ce7 gfsd 2026-07-17 11:28:58 +02:00
a10db31534 gfdsgsd 2026-07-17 11:24:23 +02:00
5d775fd00c fdsafsda 2026-07-17 11:15:05 +02:00
8043830b88 fdsa 2026-07-17 10:53:51 +02:00
cfd9620be7 gfsd 2026-07-17 10:19:49 +02:00
09ffe600d6 gfds 2026-07-17 10:16:04 +02:00
3601346895 fugg xd 2026-07-17 10:11:20 +02:00
2719ec5e57 fuck box shadow 2026-07-17 10:07:51 +02:00
7ae0392d09 gfsd 2026-07-17 10:06:30 +02:00
dd73ae0eb6 nnn 2026-07-17 10:00:04 +02:00
5f9453c6e5 add impact to gitignore 2026-07-17 09:58:33 +02:00
09a2406b5a c 2026-07-17 09:58:00 +02:00
190d789618 f 2026-07-17 09:55:22 +02:00
1577e63e9e prevent mobile panning and zooming 2026-07-17 09:32:54 +02:00
c45870bf24 mona 2026-07-17 09:26:32 +02:00
78aee03025 gfds 2026-07-17 09:23:18 +02:00
db3da87c38 better profiles 2026-07-17 09:20:32 +02:00
9f92841ea3 gf 2026-07-17 08:52:39 +02:00
ede9e63e8a xd 2026-07-17 08:48:17 +02:00
2e9bd701b1 yeah yeah power un dat 2026-07-17 08:45:20 +02:00
6bc882e75a xd 2026-07-17 08:41:18 +02:00
36996d20d5 gfd 2026-07-17 08:31:37 +02:00
1e8fb4b2b6 tag is too long lmao 2026-07-17 07:58:25 +02:00
04fa79b6bf ghostriders in the sky 2026-07-17 07:55:14 +02:00
2ce3e4535e patheticgorjgof 2026-07-17 00:18:49 +02:00
d9783abffa her 2026-07-17 00:12:21 +02:00
b27d85aa27 pathetische scheiße 2026-07-17 00:09:44 +02:00
266068b4b4 gfsdgsd 2026-07-17 00:04:56 +02:00
bee5f00e76 patheticbkfojbgo 2026-07-17 00:02:37 +02:00
d005aa5d9c pathetic over 9000000000 2026-07-16 23:58:54 +02:00
5d490cf134 pathetic 2026-07-16 23:45:21 +02:00
f6e9e13f4e pathetic sein father 2026-07-16 22:56:07 +02:00
8d9fa3fafd another pathetic try to fix the sidebar shit 2026-07-16 22:51:16 +02:00
353fc382de banner stuff 2026-07-16 22:46:25 +02:00
9608398a8e banner fix for change? 2026-07-16 22:34:32 +02:00
ce5dfa72c6 perstistency... 2026-07-16 22:16:00 +02:00
29d144920a persistency mismatch 2026-07-16 22:13:11 +02:00
91125372c8 another sad try to make the sidebar better 2026-07-16 21:55:09 +02:00
75161e4957 hgdfhf 2026-07-16 21:44:49 +02:00
813a0a066e live update banner udpates 2026-07-16 21:37:28 +02:00
e416575419 gfd 2026-07-16 21:30:29 +02:00
286b6beaf8 fix memes 2026-07-16 21:26:13 +02:00
b370510ecd comment preview foobar 2026-07-16 21:16:32 +02:00
7e2ef4fc0f Revert "hghd"
This reverts commit 74d5ff2e03.
2026-07-16 20:15:52 +02:00
1a942066ec hgfhfd 2026-07-16 19:55:14 +02:00
74d5ff2e03 hghd 2026-07-16 19:48:24 +02:00
7e5f7366d1 jhfg 2026-07-16 19:45:50 +02:00
9c9530e8c9 h 2026-07-16 19:41:30 +02:00
b19cf9ebca gfdgsdf 2026-07-16 19:37:00 +02:00
e6562ecd90 hgfdhhhfg 2026-07-16 19:33:04 +02:00
ad06ca5956 hgfh 2026-07-16 19:23:39 +02:00
43742ecdb1 gfsd 2026-07-16 19:22:37 +02:00
2350a97199 fdsafas 2026-07-16 19:17:43 +02:00
b51b8aa11c right click for default tab favorites 2026-07-16 19:16:03 +02:00
bec1d1c6d1 fds 2026-07-16 19:09:18 +02:00
6aa58c0b74 halo 2026-07-16 19:01:39 +02:00
21706d4a93 gfsd 2026-07-16 18:40:12 +02:00
589046fed5 goldener reiter 2026-07-16 18:35:15 +02:00
05ed241db0 fdsa 2026-07-16 18:32:13 +02:00
d44bc4d2bd gfsd 2026-07-16 18:28:07 +02:00
feb515b740 f 2026-07-16 18:25:36 +02:00
7c8cb2df78 fdsfasd 2026-07-16 18:19:21 +02:00
63837fe86f fdsafdsa 2026-07-16 18:16:54 +02:00
fc237ab71a gsdf 2026-07-16 18:14:32 +02:00
360dc1db06 fdasfasd 2026-07-16 18:10:19 +02:00
77741a55a8 gfsd 2026-07-16 18:06:53 +02:00
ef5fa4a6bc danmaku 2026-07-16 18:01:36 +02:00
bf66a6d276 tt 2026-07-16 17:56:37 +02:00
ce852544d1 hgfd 2026-07-16 17:52:02 +02:00
6a117eeeb3 foo 2026-07-16 17:40:07 +02:00
a8584491b7 metaextract 2026-07-16 17:23:00 +02:00
92c7fc4d6e fix yt metadata extraction once and for all (hopefully) 2026-07-16 17:20:05 +02:00
6015befffc adding tor to docker-compose for easier and better integrated proxy support 2026-07-16 17:06:40 +02:00
4be150ce44 ghost riders 2026-07-16 16:49:43 +02:00
ae9bc17b4a hfghf 2026-07-16 16:46:43 +02:00
8dc0854de2 fdas 2026-07-16 16:39:37 +02:00
de7102ed03 fd 2026-07-16 16:18:57 +02:00
11d4b5acf9 f 2026-07-16 10:13:28 +02:00
2513bf2db9 ficket euch 2026-07-16 09:05:56 +02:00
336a759d45 ^_^ 2026-07-16 09:00:58 +02:00
0af53bf08a scrololololololol 2026-07-16 08:47:24 +02:00
4d19837645 vert 2026-07-16 08:42:36 +02:00
f740fdd404 fdsa 2026-07-16 08:30:00 +02:00
e2a8c86989 fix layout legacy 2026-07-16 08:28:07 +02:00
27ca317eb3 to the good old days 2026-07-16 08:05:39 +02:00
797b5acf86 pauer un dattt 2026-07-16 08:03:28 +02:00
cd868eaea6 beat it 2026-07-16 07:57:26 +02:00
076da81f02 banner v2 2026-07-16 07:51:16 +02:00
1a99e58d84 add user banners :) 2026-07-16 07:22:03 +02:00
41ac99cd5c fdsfdsa 2026-07-16 06:24:55 +02:00
efe3be141e fdsfasd 2026-07-16 06:19:40 +02:00
4eba51c887 yeah power un dat 2026-07-16 06:17:05 +02:00
4daa7c2dfc fuck this shit 2026-07-16 06:15:01 +02:00
d04641cb3e fdsa 2026-07-16 06:04:53 +02:00
6014c62ad8 hgdf 2026-07-16 06:02:54 +02:00
31ed3b5db9 fav fix! 2026-07-16 06:01:38 +02:00
a9b715165e finally xD 2026-07-16 05:54:14 +02:00
d29695bb7e hfg 2026-07-16 05:37:05 +02:00
8374c464cd fdsa 2026-07-16 05:35:29 +02:00
514d6597cf vfd 2026-07-16 05:31:12 +02:00
6f8a809637 22222 2026-07-16 05:27:49 +02:00
9fb0a24488 autism 2026-07-16 05:23:29 +02:00
e9f2929bf6 fdsa 2026-07-16 05:14:49 +02:00
b2f33cf384 400px lol 2026-07-16 05:10:13 +02:00
4123c0b377 ff 2026-07-16 05:08:26 +02:00
3aa1acde0a akzeptabler zwischenstand 2026-07-16 05:03:13 +02:00
7b5ae3533f tg patch 2026-07-16 04:17:12 +02:00
d53794f9bf dms fav picker 2026-07-16 04:09:51 +02:00
6a3b9675b8 f 2026-07-16 04:06:02 +02:00
9806052d5c smooth 2026-07-16 04:04:39 +02:00
f48808237e fds 2026-07-16 04:01:31 +02:00
6c07bdcb33 hi 2026-07-16 03:59:31 +02:00
4ffe83fa9d fd 2026-07-16 03:51:03 +02:00
e2c277086f fettfinger xd 2026-07-16 03:47:34 +02:00
bc44039fa9 fdas 2026-07-16 03:45:33 +02:00
2c169903f3 fdas 2026-07-16 03:42:58 +02:00
13fe1145d5 mobile baddne 2026-07-16 03:41:14 +02:00
8e62d81d7b favven unso 2026-07-16 03:37:53 +02:00
7d8a7587a7 fdsafasd 2026-07-16 02:43:43 +02:00
468107867d update schema 2026-07-16 02:43:15 +02:00
87a95c7f93 pollz 2026-07-16 02:39:08 +02:00
56220e5b7e hgfhgd 2026-07-16 02:34:32 +02:00
bdbd60070c gfsd 2026-07-16 02:27:36 +02:00
a2875bf39b db 2026-07-16 02:24:16 +02:00
698f693700 hdf 2026-07-16 02:16:17 +02:00
93e8a39a40 dd 2026-07-16 02:12:58 +02:00
0b22a1b590 f 2026-07-16 02:03:17 +02:00
5facee3ccd faaaaa 2026-07-16 02:00:57 +02:00
e82471924a favorites tab for emojis 2026-07-16 01:51:05 +02:00
aa6e76dad8 fav tab 2026-07-16 01:38:57 +02:00
08d65532b1 fuck hover 2026-07-16 01:07:57 +02:00
c5fd833790 better sticker renaming 2026-07-16 01:05:10 +02:00
48a2302053 video sticker preview img 2026-07-16 01:02:20 +02:00
e94c4a439e ff 2026-07-16 00:57:31 +02:00
64ca845ec5 renaming 2026-07-16 00:54:16 +02:00
d3550f9648 unshittifying preview 2026-07-16 00:33:05 +02:00
789a18aef7 tg stickers fill available space 2026-07-16 00:29:17 +02:00
302f7c62ad width :D 2026-07-16 00:20:17 +02:00
494a990ad4 auto height 2026-07-16 00:18:22 +02:00
00a2f48fbf tg sticker v5.6 2026-07-16 00:12:07 +02:00
a1f80d7407 tg sticker v5.5 2026-07-16 00:09:58 +02:00
d94831db0f tg stickers v5.4 2026-07-15 23:51:10 +02:00
082f8669fb tg stickers v5.3 2026-07-15 23:45:55 +02:00
f9943bd47a tg stickers v5.2 2026-07-15 23:34:00 +02:00
805c97b8e4 tg stickers v5.1 2026-07-15 23:28:56 +02:00
a2b5a3f349 tg stickers v5 2026-07-15 23:20:43 +02:00
83c92262d8 tg stickers v4 2026-07-15 23:13:48 +02:00
ab9f2daaf9 tg stickers v3 2026-07-15 23:10:35 +02:00
46d4b49157 tg stickers v2 2026-07-15 23:04:43 +02:00
a7c811025e telegram stickers v1 2026-07-15 22:56:26 +02:00
afc776d948 mode reminder for lazy peeps 2026-07-15 22:04:20 +02:00
8778dbf3da tg 2026-07-15 19:45:21 +02:00
c4ef2bfbca tarkock 2026-07-15 19:38:00 +02:00
bdb6be1702 telegram 2026-07-15 19:33:49 +02:00
2338858254 yt metadata 2026-07-15 19:12:28 +02:00
ca0f6f19cc maji manji 2026-07-15 19:07:19 +02:00
c2cb897190 d 2026-07-15 19:03:40 +02:00
5a10649c57 1 2026-07-15 19:03:06 +02:00
563d90dbbc 1.2 2026-07-15 18:59:34 +02:00
d61b05195b emoji dms 2026-07-15 18:55:36 +02:00
3b5f646422 sidebar bigger font size 2026-07-15 18:52:48 +02:00
98c2aa3b7f metayt 2026-07-15 18:49:05 +02:00
5ef981d35b power un dat 2026-07-15 18:32:14 +02:00
d9b5b95f54 uga buga kaka 2026-07-15 18:27:45 +02:00
3bbee6029f repost check for upload 2026-07-15 18:03:06 +02:00
f057ea29d6 QoL enhacement 2026-07-15 17:52:20 +02:00
d53a635495 thubmnail fix for vid preview in shitpost mode upload 2026-07-15 17:45:39 +02:00
9e9fd4adf3 fix chrome shitpost mode video preview in upload 2026-07-15 17:41:58 +02:00
a613d01873 xd 2026-07-15 17:17:00 +02:00
c7edcfdfc2 xd wrapper 2026-07-15 17:15:26 +02:00
aae79eb56a emoji sizes 2026-07-15 17:13:25 +02:00
782c50ff1a chromium is shit 2026-07-15 17:03:12 +02:00
6a8a8a6bda fuck chrome 2026-07-15 16:49:00 +02:00
2fa7e01b3a fuck chromium! its a shit browser 2026-07-15 16:42:23 +02:00
8b003c4d57 Revert "apparently chrome is a shit browser"
This reverts commit f6f47fcbbe.
2026-07-15 16:32:01 +02:00
f6f47fcbbe apparently chrome is a shit browser 2026-07-15 16:30:07 +02:00
ac7bb2e699 g 2026-07-15 16:25:55 +02:00
01475c42f5 pipi kaka 2026-07-15 16:21:55 +02:00
71b06bc64a f 2026-07-15 15:40:25 +02:00
b6e92cd6f2 optimize video load 2026-07-15 14:20:48 +02:00
581d35c983 testing html cache 2026-07-15 14:08:23 +02:00
9fc8741f9c no user select for rating label 2026-07-15 13:56:36 +02:00
24f0ec44ec restore sf ratings on items 2026-07-15 13:30:21 +02:00
7368339bae gfds 2026-07-15 08:34:25 +02:00
0ea1995de5 h 2026-07-15 08:33:08 +02:00
6d73cfcea9 tweaking meme gen 2026-07-15 07:55:23 +02:00
dd5f3dc8b9 smaller char count 2026-07-15 00:39:50 +02:00
d80aa3b96b limit sidebar comments to 20 2026-07-14 23:59:54 +02:00
1c7bf20d6d f 2026-07-14 23:55:15 +02:00
92e6b7c450 css fix 2026-07-14 23:53:23 +02:00
1e11426043 makign url upload better 2026-07-14 21:37:26 +02:00
b5d688ec0d url upload better 2026-07-14 21:05:02 +02:00
0c6132a2b9 testing lazyload shit 2026-07-14 20:58:09 +02:00
9092bc8a98 testing lazyload stuff 2026-07-14 20:56:17 +02:00
8b18c4c27f test 2026-07-14 20:53:32 +02:00
e05b60cee8 change grid rendering 2026-07-14 20:45:33 +02:00
d92004ba3a :) 2026-07-14 20:34:43 +02:00
0129face77 removing border for alternative infobox 2026-07-14 20:27:10 +02:00
cdd7fa97d5 adding some QoL Css fixes 2026-07-14 20:24:28 +02:00
965573a80f add new v0ck feature 2026-07-14 19:12:15 +02:00
4a491f404c fix for item page 2026-07-14 19:06:40 +02:00
c5796744e7 arrow keys for changing volume of video 2026-07-14 18:20:36 +02:00
78879bd028 f is for fullscreen 2026-07-14 18:16:01 +02:00
4f4e3bada3 do not try to preview gifs in thumbs 2026-07-14 18:05:40 +02:00
ca39b3cfca gfdgsd 2026-07-14 18:00:55 +02:00
0488b187fc gfsd 2026-07-14 18:00:33 +02:00
56e17a8e27 fuck chrome 2026-07-14 18:00:19 +02:00
ef531bf6a2 Revert "fixing quicknav jumps in settings"
This reverts commit f7a6104896.
2026-07-14 18:00:19 +02:00
0a979ddc78 j 2026-07-13 05:51:32 +02:00
6a8986b160 lets see 2026-07-13 05:46:08 +02:00
bc85891027 fuck skeletons 2026-07-13 05:42:41 +02:00
aeb9868507 f 2026-07-13 05:40:08 +02:00
55036fcc0d jhtztjr 2026-07-13 05:35:05 +02:00
90994017b0 pb! 2026-07-13 05:16:50 +02:00
d9c2f7cdcc less ajax sometimes can be more 2026-07-13 05:14:37 +02:00
f486123cb7 jfg 2026-07-12 22:07:27 +02:00
9c6db72d5b jhfg 2026-07-12 21:41:42 +02:00
763ef32275 Revert "chromium"
This reverts commit 2492ca8c25.
2026-07-12 21:15:27 +02:00
0afd5bf7f6 Revert "uga aga pipi kaka"
This reverts commit ecdf54a894.
2026-07-12 21:15:26 +02:00
b7b799b17d Revert "pipi kaka uga aga"
This reverts commit 587ba77f71.
2026-07-12 21:15:25 +02:00
d3d7f2209f Revert "ef"
This reverts commit 1762687fc8.
2026-07-12 21:15:25 +02:00
e7f76af63d Revert "jgfd"
This reverts commit 11bf73acc2.
2026-07-12 21:15:24 +02:00
0731f607b1 Revert "tzrf"
This reverts commit ca702c9e5d.
2026-07-12 21:15:23 +02:00
3672ee8afb Revert "I hate chrome"
This reverts commit 4b0abfb551.
2026-07-12 21:15:23 +02:00
68fd1050c2 Revert "chrome is the shittiest browser ever made, fuck this!"
This reverts commit 6c926a2374.
2026-07-12 21:15:22 +02:00
36d9dca211 Revert "jhfg"
This reverts commit 38f5c9d6fe.
2026-07-12 21:15:22 +02:00
d75e5fb218 Reapply "jhfg"
This reverts commit 9898a0704b.
2026-07-12 21:15:21 +02:00
1c88a28520 Reapply "chrome is the shittiest browser ever made, fuck this!"
This reverts commit e0e6ebec29.
2026-07-12 21:15:20 +02:00
e4bac101e8 Reapply "I hate chrome"
This reverts commit b06a8faabd.
2026-07-12 21:15:20 +02:00
ecb49d65e7 Reapply "tzrf"
This reverts commit a9ca344955.
2026-07-12 21:15:19 +02:00
2c93fedaed Reapply "jgfd"
This reverts commit 8294d9626f.
2026-07-12 21:15:19 +02:00
7855db7b09 Reapply "ef"
This reverts commit 9e579d2cdd.
2026-07-12 21:15:18 +02:00
d7af8a98d8 Reapply "pipi kaka uga aga"
This reverts commit d4273ed515.
2026-07-12 21:15:17 +02:00
db44e04dba Reapply "uga aga pipi kaka"
This reverts commit 3856f752bd.
2026-07-12 21:15:17 +02:00
656f3ea4b1 Reapply "chromium"
This reverts commit 5b85f5de2e.
2026-07-12 21:15:16 +02:00
8c0a3bdaaa Revert "fuck chrome"
This reverts commit 9473293ed2.
2026-07-12 21:15:16 +02:00
41a95c8e0d Revert "hgdf"
This reverts commit 1a314ce752.
2026-07-12 21:15:15 +02:00
fbacc9ae56 Revert "fgfsd"
This reverts commit db08e5d3c9.
2026-07-12 21:15:14 +02:00
f2e75389e6 Revert "jfg"
This reverts commit d3aa1d0b69.
2026-07-12 21:15:13 +02:00
93338f877b Revert "hgfhhdf"
This reverts commit eb14192ee8.
2026-07-12 21:15:13 +02:00
a2dcc1d622 Revert "kgh"
This reverts commit a978ddcba6.
2026-07-12 21:15:11 +02:00
a978ddcba6 kgh 2026-07-12 20:59:42 +02:00
eb14192ee8 hgfhhdf 2026-07-12 20:54:18 +02:00
d3aa1d0b69 jfg 2026-07-12 20:51:16 +02:00
db08e5d3c9 fgfsd 2026-07-12 20:47:10 +02:00
1a314ce752 hgdf 2026-07-12 20:34:17 +02:00
9473293ed2 fuck chrome 2026-07-12 20:29:16 +02:00
5b85f5de2e Revert "chromium"
This reverts commit 2492ca8c25.
2026-07-12 20:22:20 +02:00
3856f752bd Revert "uga aga pipi kaka"
This reverts commit ecdf54a894.
2026-07-12 20:22:19 +02:00
d4273ed515 Revert "pipi kaka uga aga"
This reverts commit 587ba77f71.
2026-07-12 20:22:18 +02:00
9e579d2cdd Revert "ef"
This reverts commit 1762687fc8.
2026-07-12 20:22:17 +02:00
8294d9626f Revert "jgfd"
This reverts commit 11bf73acc2.
2026-07-12 20:22:17 +02:00
a9ca344955 Revert "tzrf"
This reverts commit ca702c9e5d.
2026-07-12 20:22:16 +02:00
b06a8faabd Revert "I hate chrome"
This reverts commit 4b0abfb551.
2026-07-12 20:22:15 +02:00
e0e6ebec29 Revert "chrome is the shittiest browser ever made, fuck this!"
This reverts commit 6c926a2374.
2026-07-12 20:22:11 +02:00
9898a0704b Revert "jhfg"
This reverts commit 38f5c9d6fe.
2026-07-12 20:21:55 +02:00
38f5c9d6fe jhfg 2026-07-12 20:15:39 +02:00
6c926a2374 chrome is the shittiest browser ever made, fuck this! 2026-07-12 20:13:01 +02:00
4b0abfb551 I hate chrome 2026-07-12 20:06:49 +02:00
ca702c9e5d tzrf 2026-07-12 20:02:11 +02:00
11bf73acc2 jgfd 2026-07-12 19:59:22 +02:00
1762687fc8 ef 2026-07-12 19:56:39 +02:00
587ba77f71 pipi kaka uga aga 2026-07-12 19:52:43 +02:00
ecdf54a894 uga aga pipi kaka 2026-07-12 19:50:25 +02:00
2492ca8c25 chromium 2026-07-12 19:46:17 +02:00
f7a6104896 fixing quicknav jumps in settings 2026-07-12 19:21:30 +02:00
b96f22be8a add option to create users from admin dashboard and give or take permissions via dashboard 2026-07-12 19:15:37 +02:00
59120ed96e altering the url uploads 2026-07-12 19:00:58 +02:00
6adfb55581 update url uploads 2026-07-12 18:46:09 +02:00
06ced46db1 fix sharex 2 2026-07-12 18:05:37 +02:00
c48a644bb9 fix sharex 2026-07-12 17:37:57 +02:00
88b25ab251 fix unbanning for inactivity ban resets last seen 2026-07-12 17:23:08 +02:00
8bb9a14773 adding parallel file computing for better api performance and upload speed and responses 2026-07-12 17:15:59 +02:00
d424b24221 add support for uploading archives 2026-07-12 17:03:19 +02:00
ab1ea7b368 soy2 2026-06-29 23:50:03 +02:00
6f2afc992f soy 2026-06-29 22:27:29 +02:00
81c0afc534 fix user api keys in schema 2026-06-29 19:09:12 +02:00
977cab8ad2 adding working metadata extract butto for yt embeds 2026-06-21 20:26:44 +02:00
f190ff073a add automatic inactivity bans 2026-06-21 15:47:36 +02:00
e30ead0174 nsfp manager #1 2026-06-19 14:52:02 +02:00
17ee7a3b2d add counter how many items are hidden from public 2026-06-19 14:47:08 +02:00
8a24564cd9 add nsfp tag manager 2026-06-19 14:44:56 +02:00
06564af203 QoL v0ck and tagging 2026-06-19 14:20:15 +02:00
c051df18c2 fix rehost button mutation 2026-06-19 14:13:52 +02:00
78d08aa751 comment input form validation required 2026-06-19 14:07:40 +02:00
a748cca833 v0ck QoL 2026-06-19 14:02:20 +02:00
1b8860d8ff add phash repost detection 2026-06-13 19:09:36 +02:00
4c742aaf66 bump nginx upload size 2026-06-13 16:51:36 +02:00
6b6ed9d42b add direct url to upload response via api 2026-06-13 16:11:10 +02:00
9df81105e2 update private_society to allow direct urls 2026-06-13 16:05:54 +02:00
fb2489812e api upload min tags 2026-06-13 15:16:12 +02:00
d29edd735e umlaut fix 2026-06-12 10:30:41 +02:00
f06a7ffe55 less aggressive html sanitize 2026-06-12 03:02:00 +02:00
8b29ee6722 hfgd 2026-06-12 02:58:53 +02:00
34ed0e4621 fixing about/terms/rules pages 2026-06-12 02:56:02 +02:00
83f3980300 add flash fullscreen button 2026-06-12 01:58:28 +02:00
7b599e3afa update attachment logic 2026-06-12 01:48:04 +02:00
7dcd7005e4 sidebar hidden modern layout same size 2026-06-12 01:30:22 +02:00
c093e9703d ruffle scrolling 2026-06-12 01:12:23 +02:00
9ca60629a0 stabilizing layout 2026-06-12 01:04:41 +02:00
075adcc8c6 center content in modern layout 2026-06-12 00:54:01 +02:00
5f2fe0c732 scroll to file in shitpost mode 2026-06-12 00:38:17 +02:00
28f35861c3 cccc 2026-06-10 10:27:14 +02:00
4e45e0fd66 turn splash screen back on and fix regen script 2026-06-08 20:05:04 +02:00
69e90f8d2d overhaul rethumbing flashs 2026-06-08 16:37:15 +02:00
c615676465 mobile qol 2026-06-08 15:09:38 +02:00
bdc78fc5a5 ++++++ 2026-06-08 14:09:50 +02:00
7fb049e1ed video shortcuts 2026-06-08 13:53:55 +02:00
7f71285c80 texttop 2026-06-08 13:50:41 +02:00
a6997bc4b4 add clickable video timestamps 2026-06-08 13:43:28 +02:00
219cab1b03 j 2026-06-08 13:34:20 +02:00
8c3556fd68 QoL for images 2026-06-08 13:29:33 +02:00
c9892ec62f qol 2026-06-08 13:16:47 +02:00
018428d236 more blur bg! 2026-06-08 13:05:31 +02:00
f4dcfe0e50 arab bunny party 2026-06-08 09:28:47 +02:00
25a878cb7a bunny party 2026-06-08 09:26:51 +02:00
2f048c0105 pipi kaka :D 2026-06-05 12:58:57 +02:00
e3822254a3 0000 2026-06-05 12:47:44 +02:00
5b193bc001 ererere 2026-06-04 21:01:34 +02:00
f36c10b428 gfdhfgd 2026-06-04 20:55:41 +02:00
4943a87e13 hgf 2026-06-04 20:42:22 +02:00
a868e9f94b fix notis 2026-06-04 20:29:30 +02:00
e281484b8a notification thumbnails according to rating blurred or unblurred 2026-06-04 15:44:57 +02:00
39dfcad52f f 2026-06-04 13:47:32 +02:00
fe3af86c87 yt fix #2 2026-06-03 13:43:26 +02:00
f5101da85c fix broken yt dests... 2026-06-03 13:37:41 +02:00
d5de02511b add option to have public nsfw/nsfl 2026-06-03 13:07:24 +02:00
adc8431522 update emoji manager 2026-06-03 12:50:21 +02:00
d30642ca4a QoL fixes 2026-06-03 12:25:57 +02:00
5bb86f7028 Testing: fixing user comments page and profile respects now sidebar width 2026-06-03 11:08:37 +02:00
33411fc5ed adding missing stuff for fresh deployments 2026-06-03 10:58:32 +02:00
e72f9a6ef3 QoL fixes 2026-06-03 08:54:56 +02:00
066fa97c43 rrrrrrrrr 2026-06-02 16:55:59 +02:00
dceadf7113 jgh 2026-06-02 16:45:21 +02:00
a0ef658684 new filenames + backfill 2026-06-02 16:35:34 +02:00
2c88953d97 jhgf 2026-06-02 10:26:53 +02:00
9d54e03ae0 bhgds 2026-06-02 07:54:45 +02:00
c20e54c11b gfds 2026-06-01 22:27:23 +02:00
bbbb4397f0 padding 2026-06-01 18:26:01 +02:00
0bbbf5ce13 ölkj 2026-06-01 18:21:46 +02:00
7ebb74a295 g 2026-06-01 13:48:52 +02:00
0dd1f30777 nginx 2026-06-01 13:40:11 +02:00
2bc0f9c5fd jghf 2026-06-01 13:28:44 +02:00
a8ef68fee6 update nginx compose 2026-06-01 13:24:26 +02:00
39e6d58e18 add approval message 2026-06-01 13:14:31 +02:00
1557d59300 nginx test 2026-06-01 11:32:15 +02:00
1df8caa940 adding nginx reverse proxy in docker 2026-06-01 11:09:45 +02:00
0f69365b02 ffmpeg fallback for specific vp9 videos 2026-06-01 10:33:04 +02:00
8d8416e650 add tooltips for sidebar 2026-06-01 10:26:26 +02:00
412995ece6 removing alt=av 2026-06-01 10:20:30 +02:00
df3405ac9b f 2026-06-01 08:43:46 +02:00
9146e37039 update readme 2026-06-01 08:43:02 +02:00
bb6322e187 hgfd 2026-06-01 08:30:50 +02:00
064bd51c64 gdf 2026-06-01 08:10:37 +02:00
f6de7f72cf hngfdhfgd 2026-05-31 22:08:02 +02:00
d594ac2edd hgfdhgfd 2026-05-31 22:02:38 +02:00
09fcf8d8ec ghfd 2026-05-31 21:52:46 +02:00
1efd3b18ad errrr 2026-05-31 20:51:24 +02:00
b2128ef0f8 jjj 2026-05-31 20:38:45 +02:00
8e4e40d92f update example config 2026-05-31 20:26:13 +02:00
e0c29f203b gfdsgfds 2026-05-31 19:54:34 +02:00
994039370c scroll to top button for mobile 2026-05-31 18:48:45 +02:00
08cdada5bc silent scroll :) 2026-05-31 18:37:45 +02:00
8a3a77d273 gfds 2026-05-31 18:24:22 +02:00
235f1b6d14 better timestamps 2026-05-31 18:10:35 +02:00
52a18acf40 gfdsgfds 2026-05-31 17:30:44 +02:00
c8f0982f22 hgfd 2026-05-31 17:22:07 +02:00
1f97701779 jfgh 2026-05-31 17:19:10 +02:00
f863580f20 jhgf 2026-05-31 17:10:30 +02:00
d299117eb0 jhgf 2026-05-31 17:07:46 +02:00
62588a3a4b hhhhhhhhh 2026-05-31 17:00:57 +02:00
d50a8b5965 gfds 2026-05-31 16:56:39 +02:00
ec95f548ff obermainbrücke 2026-05-31 16:55:30 +02:00
379298acc7 hfgd 2026-05-31 16:42:44 +02:00
7b80a3434c gfds 2026-05-31 16:03:48 +02:00
23427f009f gfds 2026-05-31 15:57:55 +02:00
c4a571a714 gfds 2026-05-31 12:25:35 +02:00
db6344d055 gfds 2026-05-31 12:23:14 +02:00
f97de44a0c gfds 2026-05-31 12:21:19 +02:00
448efc69f8 hgfd 2026-05-31 12:18:59 +02:00
c2cb67c51c hgfdhfd 2026-05-31 12:18:50 +02:00
89548e1105 magnet und hydrodynamik 2026-05-31 12:17:12 +02:00
7c23c646fe gfdsgdfs 2026-05-31 12:15:36 +02:00
7671e8c0cf gfds 2026-05-31 12:14:43 +02:00
7a57f4897f fdfdd 2026-05-31 06:59:05 +02:00
0b89e446e7 hausdavid 2026-05-31 06:56:45 +02:00
83bf04e965 hackinga to tha gato 2026-05-31 06:21:42 +02:00
69bf968a4b hgfdhgfdhd 2026-05-30 18:59:07 +02:00
85cf4e0fc6 long covid 2026-05-30 18:44:27 +02:00
86a08bb76e gfdsgdfsgdfs 2026-05-30 17:33:53 +02:00
73e328c0b6 gfds 2026-05-30 17:21:30 +02:00
57c12057d9 gfds 2026-05-30 17:05:16 +02:00
0ae82ce433 huo 2026-05-30 13:19:12 +02:00
067f202c08 hgfd 2026-05-30 13:07:38 +02:00
9177b993fc jhgf 2026-05-30 12:55:22 +02:00
1a0a5d7679 fdas 2026-05-30 12:50:15 +02:00
ae2a9b76cb hgfd 2026-05-30 12:38:47 +02:00
47f1b5bb41 pipi kaka uga aga 2026-05-30 11:24:18 +02:00
6fdfed5cae gfds 2026-05-30 09:01:52 +02:00
c949453c9e im already tracer 2026-05-30 08:51:15 +02:00
0e8e26237e ff 2026-05-30 08:46:31 +02:00
4a0784746d ghf 2026-05-30 08:41:28 +02:00
c98e797d4f add alternative controls 2026-05-30 08:35:20 +02:00
3ff61f4e36 eierfon gay fix? 2026-05-30 07:29:13 +02:00
26b4081984 fjnf 2026-05-30 06:57:58 +02:00
80457014c1 ina ina 2026-05-30 06:55:43 +02:00
8dd1ed22a2 dönerkebab 2026-05-30 06:48:12 +02:00
db0b28fe0c hgfd 2026-05-29 21:23:35 +02:00
44bf46f02c gfds 2026-05-29 21:20:44 +02:00
d9b49b1e21 hgdf 2026-05-29 21:17:07 +02:00
a7e4d5b0dd hgf 2026-05-29 20:53:38 +02:00
67643f17a9 hgfd 2026-05-29 20:49:30 +02:00
e0e0456768 gfhgfd 2026-05-29 20:49:26 +02:00
7e7da4030d jghf 2026-05-29 20:18:44 +02:00
754fc95d56 add polls 2026-05-29 20:15:00 +02:00
9365cb21c8 big oppai 2026-05-29 19:41:34 +02:00
264b6c3e6d gfds 2026-05-29 19:38:18 +02:00
78fe42ef3a jghf 2026-05-29 19:33:45 +02:00
45df561e9d kkkkkkkkk 2026-05-29 19:20:33 +02:00
f38d77a4d8 gdfs 2026-05-29 19:17:11 +02:00
beb5460797 hgfd 2026-05-29 19:08:01 +02:00
37460ba224 regression for filter setting possible fix 2026-05-29 19:05:44 +02:00
f79e4d6f32 prevent comment attachment to be abused 2026-05-29 18:38:26 +02:00
86085c435a gfds 2026-05-29 12:24:29 +02:00
697d62f89b beautifying the tags 2026-05-29 09:12:21 +02:00
18add9f21a gfd 2026-05-29 09:00:46 +02:00
5e298383a3 f 2026-05-29 08:59:01 +02:00
d5f118f2fc test 2026-05-29 08:56:21 +02:00
63bb86defc gfds 2026-05-29 05:38:56 +02:00
066ca99dd3 sidebar 2026-05-28 21:50:34 +02:00
d1b0e3542c gfdsgfds 2026-05-28 21:46:47 +02:00
a8978e232f sexy grabbing finally!!! 2026-05-28 21:25:37 +02:00
ddd87b6336 hdf 2026-05-28 21:22:11 +02:00
6be580dc92 fdsagfds 2026-05-28 21:15:47 +02:00
420f58c85a fds 2026-05-28 20:48:45 +02:00
62a6a345cb fdsa 2026-05-28 20:43:10 +02:00
700e705bee gfdsgfds 2026-05-28 20:40:16 +02:00
80240ccb66 gfds 2026-05-28 20:34:26 +02:00
eabf1585b9 border 2026-05-28 20:28:45 +02:00
4125db98ba spicy queen 2026-05-28 20:14:31 +02:00
98075423f0 bhi 2026-05-28 19:28:22 +02:00
b8024acf12 uga aga 2026-05-28 18:41:35 +02:00
4ed87cd331 fix hall creation 2026-05-28 18:33:23 +02:00
dcdea5e9ea kjhg 2026-05-28 16:42:05 +02:00
1cfb001148 jhgf 2026-05-28 16:34:02 +02:00
9a003be98f kjhg 2026-05-28 16:22:53 +02:00
2a73a00e98 add dimensions 2026-05-28 16:02:16 +02:00
9804376d30 gfds 2026-05-28 13:21:18 +02:00
006ee727ec jhgf 2026-05-28 13:15:45 +02:00
1fe506da65 h 2026-05-28 12:47:43 +02:00
df997db8eb slightly bigger pagination 2026-05-28 12:33:45 +02:00
4d1d5d4332 gurkenwasser 2026-05-28 11:52:18 +02:00
03f751954d hfgd 2026-05-28 08:21:31 +02:00
33abde6f79 j 2026-05-28 08:17:39 +02:00
3e427b7b58 pagination now correctly centers in available space 2026-05-28 08:10:30 +02:00
ac1d710811 chud 2026-05-28 08:06:32 +02:00
93bb36883f eeeeeeeeeee 2026-05-28 08:04:14 +02:00
a6bf8fe6df tinkering with the dms 2026-05-28 07:57:09 +02:00
090c0b8016 easier fullscreen for mobile 2026-05-28 07:26:40 +02:00
7593033ab9 kjhg 2026-05-28 06:43:12 +02:00
991a31ff35 f 2026-05-28 06:38:59 +02:00
aff3153a84 lkjh 2026-05-28 06:34:29 +02:00
a4cd858114 fds 2026-05-27 20:32:36 +02:00
ebaea76b90 hgdf 2026-05-27 20:29:27 +02:00
b705cd3a9c hbg 2026-05-27 20:21:42 +02:00
57a59dcda0 n 2026-05-27 20:02:54 +02:00
8ec4f1c1b3 fdsa 2026-05-27 20:02:14 +02:00
181c30e9ee fd 2026-05-27 19:40:35 +02:00
ab8d751330 f 2026-05-27 19:38:29 +02:00
603b3f37b9 add total amount of users to stats 2026-05-27 19:33:38 +02:00
9f6215706d idk fuck this 2026-05-27 19:28:00 +02:00
3e1657ec34 hgfd 2026-05-27 19:25:58 +02:00
8909e02ddc fdsa 2026-05-27 19:21:28 +02:00
61754e058f attachment fix 2026-05-27 19:12:35 +02:00
514dae7906 uga aga pipi kaka 2026-05-27 18:48:54 +02:00
ffb328ab96 drachenfotze 2026-05-27 16:27:53 +02:00
8ddcff61e4 133 2026-05-27 16:14:45 +02:00
c4c311c541 testing: adding smooth animation for comment hding 2026-05-27 15:51:05 +02:00
b575a07921 filter update 2026-05-27 15:25:42 +02:00
236e540204 fav gap and ugly horizontal scrollbar 1050px width... 2026-05-27 07:01:53 +02:00
ef08f85d25 fges 2026-05-27 06:44:20 +02:00
635afe9f9f scroller filename as metadata 2026-05-27 06:33:28 +02:00
25dfa6c8a2 fa 2026-05-26 21:49:16 +02:00
cfd446597d fuck this shit 2026-05-26 21:44:24 +02:00
9c42e8ea2b gfdsgfds 2026-05-26 21:39:57 +02:00
4f9fc25ef2 gfbds 2026-05-26 21:28:16 +02:00
8693e16802 fix dynamic thumb for mme+rating filter 2026-05-26 21:19:14 +02:00
9732b30aa5 change mode display 2026-05-26 19:56:22 +02:00
7c2619e492 Testing: allow selecting multiple ratings for better filtering 2026-05-26 19:10:24 +02:00
0f7eced14d cockvore 2026-05-26 15:13:24 +02:00
924dcc0641 xdddd 2026-05-26 15:10:21 +02:00
54bdbad25e fuck italic 2026-05-26 14:52:45 +02:00
1174cd6947 feat: add support for encrypted DM attachments, per-user upload API keys, and configurable feed layouts via new database migrations and API documentation. 2026-05-26 14:30:02 +02:00
6f0b62cf8d feat: implement API documentation, add database migrations for site features, and include comment file attachments in API responses 2026-05-26 14:24:52 +02:00
ef3a9bd3b0 feat: add database migration scripts and supplementary tooling for data imports and site management 2026-05-26 14:17:05 +02:00
dd4e56c8fb feat: implement database migrations for wordfilter, DMs, user invites, and site settings while adding video title import utilities. 2026-05-26 14:14:05 +02:00
0ed609c8ce legacy layout scrollbutton fix 2026-05-26 14:12:30 +02:00
3f92e62820 feat: implement database migrations for word filter, DM attachments, message editing, and user invite tracking, plus add API documentation and dev scripts. 2026-05-26 14:02:28 +02:00
cb3bd4358c same height for attachments in sidebar than comments 2026-05-26 13:50:17 +02:00
be499ddb36 gfds 2026-05-25 20:25:18 +02:00
df8797b92a empty title still reserves the same space as set title for layout consistency 2026-05-25 15:24:47 +02:00
6622ea93aa gtfds 2026-05-25 13:10:00 +02:00
cce4eb3d57 ehehehe 2026-05-25 13:06:37 +02:00
49a1365cf9 xdddd 2026-05-25 12:24:48 +02:00
0dad6924b5 bvgfd 2026-05-25 12:12:46 +02:00
7ca88f6416 search testing xd 2026-05-25 12:09:35 +02:00
f2cebddd4d update search to include video titles 2026-05-25 10:08:32 +02:00
fda2ed36bd option to enable/disable item titles 2026-05-25 09:55:53 +02:00
1adc0f4ee2 remove title 2026-05-25 09:50:42 +02:00
9b64feb8ed adding back tooltip for timeago on comments 2026-05-25 09:48:59 +02:00
c6dd2a7ff8 geilify admin pages 2026-05-25 09:46:21 +02:00
8977c072f2 rating switch more visual 2026-05-25 09:32:44 +02:00
d4a68f59fd add apu bingo 2026-05-25 09:20:40 +02:00
92cc474ca3 eeee 2026-05-25 09:03:58 +02:00
3a436304bd meme generator update 2026-05-25 08:57:13 +02:00
96b13db79c center thumb 2026-05-25 08:48:53 +02:00
b0d53d34ac eee 2026-05-25 08:45:03 +02:00
107a184c04 test 2026-05-25 08:41:30 +02:00
29d33fe277 meme test 2026-05-25 08:38:14 +02:00
8257c8d021 update env example 2026-05-24 23:24:09 +02:00
fa8ed5e354 fix yt links 2026-05-24 23:16:01 +02:00
95e37c1dd1 fix xss... 2026-05-24 23:11:31 +02:00
613f099a8b add item titles 2026-05-24 23:02:49 +02:00
187f35227b no margin 2026-05-24 22:10:45 +02:00
8a679c2fe6 fix tooltip breakout calculation position 2026-05-24 22:08:26 +02:00
b2584763ee cd score layout modern fixes 2026-05-24 22:04:07 +02:00
f9e45327bf hgfd 2026-05-24 21:27:33 +02:00
096720c266 fdf 2026-05-24 21:24:48 +02:00
83fdada12d updating compose with latest mounts and adding missing gitkeeps 2026-05-24 21:19:24 +02:00
0714b0a68c use correct class for quicknav 2026-05-24 21:16:25 +02:00
78c28b4734 remove iconset.svg 2026-05-24 21:10:26 +02:00
34fa51a6e9 bghd 2026-05-24 21:09:50 +02:00
060d73122b fd 2026-05-24 21:07:05 +02:00
026bf4a421 hgfd 2026-05-24 20:58:13 +02:00
6c85a86959 prevent image modal for emojis and make the emojis bigger 2026-05-24 20:53:33 +02:00
5ce2371b41 fix image expansion 2026-05-24 20:41:24 +02:00
503c131f0b t 2026-05-24 19:00:05 +02:00
5bbcb5be41 hgfd 2026-05-24 18:49:47 +02:00
126bf41d9a eeee 2026-05-24 18:39:12 +02:00
5ae397bb0c g 2026-05-24 18:37:12 +02:00
d8a8626dae update usermanager 2026-05-24 18:31:35 +02:00
3abefe64de quick nav style update 2026-05-24 18:15:07 +02:00
d77936e58f add missing i18n for sidebar 2026-05-24 18:03:30 +02:00
88fc872df6 display reposts in info modal 2026-05-24 17:51:16 +02:00
18e07e43b6 only load captcha resources if not logged in. 2026-05-24 17:37:22 +02:00
f735a144af fix layout modern image expansion 2026-05-24 17:17:08 +02:00
fa350e8f1c gf 2026-05-24 17:00:07 +02:00
ad72c053d9 hgfd 2026-05-24 16:58:01 +02:00
7e458e4450 f 2026-05-24 16:56:24 +02:00
4a155bc5f4 add recaptcha option 2026-05-24 16:44:20 +02:00
393db5fe2a add possibility to create account without email and token 2026-05-24 16:35:07 +02:00
a5e79cca0c custom navbar 2026-05-24 16:09:02 +02:00
cdd415a52f fd 2026-05-24 15:55:48 +02:00
0b9b049f82 correct nsfw badge color for shitpost uploading 2026-05-24 10:22:05 +02:00
ca4a722029 comment safe guards 2026-05-24 10:11:41 +02:00
bb4125601f option for users to delete their own comments 2026-05-24 10:05:40 +02:00
375e1a85d4 xd score fix 2026-05-24 09:51:10 +02:00
18cac93bf1 space to enlarge 2026-05-24 09:40:44 +02:00
2ab4ae06af make quick cycling great again 2026-05-24 09:25:05 +02:00
2bce856153 speed up shitpost mode upload 2026-05-24 08:56:09 +02:00
8e6011785a higher quality tag and hall images 2026-05-24 08:49:18 +02:00
a87123cb43 unify sidebar emoji height 2026-05-24 08:27:33 +02:00
28d5b9364f notis on blur aswell 2026-05-24 00:11:05 +02:00
6688db6145 require item revealing when user wants it 2026-05-23 23:23:31 +02:00
a3bec09864 update wordfilter func 2026-05-23 23:09:24 +02:00
229cfacd5c remove dashboard link 2026-05-23 23:06:29 +02:00
0c246aab30 wordfilter update 2026-05-23 23:01:28 +02:00
a0ac4607cc add wordfilter 2026-05-23 22:55:53 +02:00
9a9b787fd7 add shitpost mode config options 2026-05-23 22:38:28 +02:00
e61654c567 update xd score scoring system 2026-05-23 22:23:51 +02:00
6137545cab do not show chat manager in admin dashboard if disabled 2026-05-23 22:13:39 +02:00
e3ba7d3b10 gfds 2026-05-23 22:07:29 +02:00
0945f780a3 gfdgdf 2026-05-23 22:06:32 +02:00
ac98e292e9 fix shitpost mode ugly padding 2026-05-23 22:02:43 +02:00
4e10aec872 utf8 2026-05-23 21:51:00 +02:00
1aaa0493a9 rules,about,tos markdown 2026-05-23 21:48:47 +02:00
7155b3a7da updating meme manager to update memes 2026-05-23 21:25:46 +02:00
2f044a8d02 generate blur thumbnail for all items! 2026-05-23 21:05:40 +02:00
8c9e89c771 fix quick nav highlight 2026-05-23 20:55:41 +02:00
d7377c108a better 2026-05-23 20:47:57 +02:00
cbccac6e22 hide thumb higher 2026-05-23 20:46:40 +02:00
046ecf8321 gjgjg 2026-05-23 20:41:47 +02:00
a2dd32989e add get sharex config button to api key 2026-05-23 20:37:47 +02:00
b608208cf9 fix for api upload when private_society is enabled 2026-05-23 20:34:18 +02:00
e0c435009b better filters 2026-05-23 20:21:47 +02:00
0f3b80f0c1 making settings more readable and navigatable 2026-05-23 20:10:57 +02:00
c6ff4fa703 fix inviting 2026-05-23 19:32:50 +02:00
bf92d53620 implement user invites 2026-05-23 19:14:13 +02:00
dd6cda2b44 fd 2026-05-23 18:32:53 +02:00
dbe0859750 sidebar blurring 2026-05-23 18:22:46 +02:00
c480b82db6 content preferences 2026-05-23 15:41:40 +02:00
3e6298f81c previews and make sure blurry thumb gets always generated when rating is changed later on 2026-05-23 15:35:42 +02:00
97cc69b337 better blur 2026-05-23 15:29:50 +02:00
4b50e56eb8 add option to blur any thumb 2026-05-23 15:26:35 +02:00
c488b93290 fix fullscreen control and cursor hide again... 2026-05-23 13:05:34 +02:00
aebb20b37f speed up review process 2026-05-23 13:01:14 +02:00
fcfe73178b fix player flickering when entering with mouse 2026-05-23 12:57:49 +02:00
224af7e8cb 2.5s are better 2026-05-23 12:34:43 +02:00
29f73c9271 hide mouse cursor in fullscreen after 5 seconds aswell 2026-05-23 12:30:33 +02:00
0e04e9f49f add file info button 2026-05-23 12:19:51 +02:00
ca9ccab697 double speed also when paused 2026-05-23 11:54:42 +02:00
74f1c31df2 fdfdfdfdfd 2026-05-23 09:54:56 +02:00
fdf54e4513 x2 speed for v0ck 2026-05-23 09:49:57 +02:00
867304bcb1 agayn 2026-05-23 09:44:38 +02:00
3b5144f475 hide controls after 5 seconds and remove unnecesarry min-height 2026-05-23 09:40:58 +02:00
422f80cabd gs 2026-05-23 09:34:50 +02:00
f3b2887df3 uga aga 2026-05-23 09:31:09 +02:00
1be9216624 hgfd 2026-05-23 08:45:59 +02:00
0d85ff0535 auto 2026-05-23 08:43:03 +02:00
1410200cf2 ye 2026-05-23 08:39:58 +02:00
0745637229 hehe 2026-05-22 21:28:01 +02:00
832581ad68 fuck border radius 2026-05-22 21:24:04 +02:00
748da77678 eeeeeee 2026-05-22 21:23:06 +02:00
fe1a29c2a6 add config option for api keys 2026-05-22 21:20:52 +02:00
013bdce1db settings padding... 2026-05-22 21:05:20 +02:00
615aacae9a fuck formatting!!! 2026-05-22 20:59:11 +02:00
a1ef06e573 Revert "ff"
This reverts commit 0450d9e2ed.
2026-05-22 20:53:08 +02:00
6ca29806b0 Revert "gfds"
This reverts commit 9694a560f7.
2026-05-22 20:52:48 +02:00
9694a560f7 gfds 2026-05-22 20:46:18 +02:00
0450d9e2ed ff 2026-05-22 20:44:02 +02:00
71f292f243 add api key for uploading via 3rd party tools 2026-05-22 20:42:48 +02:00
d44fb1ac05 dynamic comment length 2026-05-22 20:21:15 +02:00
b836ce37f3 gf 2026-05-22 18:59:35 +02:00
29551f9d27 lets see if this works out 2026-05-22 18:49:10 +02:00
de2302b589 gfd 2026-05-22 18:27:29 +02:00
126923f2b7 fd 2026-05-22 18:19:21 +02:00
bc89c1d7a8 zomg indicator 2026-05-22 18:15:31 +02:00
e07fb589c5 better filter visualization 2026-05-22 18:12:37 +02:00
3ec97f4451 fooooo 2026-05-22 15:43:12 +02:00
c569cde8cf keep keyboard open when opening emoji selector 2026-05-22 15:37:29 +02:00
df312009b8 attempting to fix legacy user accounts with less than 20 characters. 2026-05-22 15:34:23 +02:00
6c6764202e must select template before using text 2026-05-22 08:35:46 +02:00
b093f7618a goo goo ga ga 2026-05-22 08:22:25 +02:00
7e00c090e2 adding custom meme template instead of fixed library 2026-05-22 08:18:30 +02:00
9c46396a39 attempting to fix mobile replies/quotes 2026-05-22 08:09:41 +02:00
9ef3207cd4 fixing random api endpoint 2026-05-21 19:51:57 +02:00
72b8140f77 fix meme manager text overflowing 2026-05-21 15:53:53 +02:00
948e6461fd convenience for shitpost mode link uploader 2026-05-21 15:41:42 +02:00
eb209f6d27 add option to have unencrypted direct messages 2026-05-21 15:31:57 +02:00
d0a014705b updating phash/dhash generation 2026-05-20 19:10:50 +02:00
07edfcb71d fixing legacy password login 2026-05-20 18:53:57 +02:00
0074355df8 lflf 2026-05-18 18:32:19 +02:00
3ac1489d1f lazyloading 2026-05-18 18:26:46 +02:00
f87642341b scrollbottom for chat 2026-05-18 18:17:32 +02:00
e445169456 37 2026-05-18 18:15:03 +02:00
8f8bda1d0d adding online presence for dms 2026-05-18 18:02:11 +02:00
bcb17dc48b gonna hate race conditions 2026-05-18 17:56:14 +02:00
ab566fc126 lets see 2026-05-18 17:52:45 +02:00
aabc33a6cd fix bug lol 2026-05-18 17:39:50 +02:00
9c129b7a37 add decent replying to direct messages 2026-05-18 17:36:07 +02:00
0393878c9f add option to edit/delete direct mssages and dynamic expiry date for attachments via config 2026-05-18 17:22:44 +02:00
313cbeddc4 fix attachment button and logic 2026-05-18 16:49:31 +02:00
e97698877d possible fix for boot crash and image expansion 2026-05-18 16:44:29 +02:00
ec8c423304 add encrypted dm attachments 2026-05-18 16:26:53 +02:00
ad325c085a yippi ya ohhhh 2026-05-17 14:24:30 +02:00
385b731ee8 abyss metadata preview #2 2026-05-17 14:19:21 +02:00
82574466ee abyss internal link shortening, removal of # for ids and external preview 2026-05-17 14:16:13 +02:00
198 changed files with 29257 additions and 3033 deletions

View File

@@ -1,3 +1,10 @@
POSTGRES_USER=
POSTGRES_DB=
POSTGRES_PASSWORD=
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
POSTGRES_PASSWORD=f0ckm
# --- Nginx & Let's Encrypt Configuration (Optional) ---
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
# COMPOSE_PROFILES=f0ckm-nginx
#
# VIRTUAL_HOST=yourdomain.com
# LETSENCRYPT_HOST=yourdomain.com
# LETSENCRYPT_EMAIL=your-email@example.com

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ public/tag_cache
.env
config.json
bundle.css
public/s/fonts/impact.woff

View File

@@ -1,18 +1,30 @@
# f0ckm
Happy to finally bring you f0ckm! The long awaited imageboard solution that you can host yourself. It is designed to be a real alternative to big tech platforms that you can modify to suit your communities needs.
It features extensive tagging, searching, filtering and a variety of options.
The software is mostly generic, it can be modified easily via config.json to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
The software comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software.
## Software Requirements
- Docker (https://docs.docker.com/engine/install/debian/)
## prod
first things
`cp .env.example .env`
fill with for example: f0ckm
fill with for example: f0ckm (prefilled)
`cp config_example.json config.json`
Edit to needs, for sql you can do this:
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname, if you choose localhost you wont need to add the ENV `DB_HOST=localhost` and if you also change the port to be `DB_PORT=5454` you wont need this ENV either, but then Docker wont find the DB.
```
"sql": {
@@ -42,6 +54,8 @@ now vist http://localhost:1337 in your browser
## dev
NOTE: when developing locally it might be necessary to run commands with `DB_HOST=localhost DB_PORT=5454`
`docker compose up -d f0ckm-db`
on dev machine:
@@ -49,8 +63,18 @@ on dev machine:
`npm i`
`npm run dev`
Fill Database
`docker exec -i f0ckm-db psql -U f0ckm -d f0ckm < migrations/f0ckm_schema.sql`
`DB_HOST=localhost DB_PORT=5454 node scripts/seed.mjs`
Create admin user in dev env
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
## NGINX
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.

3
config/nginx/f0ck.conf Normal file
View File

@@ -0,0 +1,3 @@
client_max_body_size 10000M;
client_body_timeout 120s;
client_header_timeout 120s;

View File

@@ -53,14 +53,20 @@
"amoled"
],
"theme": "amoled",
"default_font": "",
"default_layout": "legacy",
"custom_favicon": "/s/img/favicon.gif",
"custom_brand_image": [],
"custom_navbar_brand_text": "",
"show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [],
"enable_global_chat": true,
"enable_danmaku": true,
"private_messages": true,
"dm_attachments": true,
"dm_unencrypted": false,
"dm_attachment_expiry_days": 90,
"halls_enabled": true,
"userhalls_enabled": true,
"enable_userhall_image_upload": true,
@@ -68,12 +74,24 @@
"meme_creator": true,
"enable_cleanup": false,
"enable_data_export": true,
"inactivity_ban_days": 60,
"enable_user_api_keys": true,
"enable_user_invites": true,
"user_invite_slots": 2,
"invite_criteria": {
"uploads": 10,
"age_days": 10,
"comments": 10,
"tags": 10
},
"cleanup_timeframe_days": 30,
"web_url_upload": true,
"enable_youtube_upload": true,
"web_meta_extraction": true,
"bypass_duplicate_check": true,
"shitpost_mode": false,
"shitpost_require_rating": false,
"shitpost_min_tags": 0,
"protect_files": false,
"enable_dynamic_thumbs": false,
"allowed_comment_images": [
@@ -83,6 +101,14 @@
],
"show_mime_picker": true,
"embed_youtube_in_comments": true,
"allow_fileupload_comments": true,
"allow_comment_deletion": false,
"enable_comment_polls": false,
"fileupload_comments_multifile": true,
"fileupload_comments_size": 104857600,
"fileupload_comments_max": 5,
"fileupload_comments_mode": "attachment",
"fileupload_comments_mimes": ["image", "video", "audio"],
"show_content_warning": true,
"default_comment_display_mode": 1,
"phrases": [
@@ -94,12 +120,19 @@
"enable_swiping": true,
"enable_profile_description": true,
"user_alternative_infobox": false,
"user_banner_enabled": true,
"user_alternative_steuerung": false,
"enable_swf": false,
"swf_thumb": "/s/img/swf.png",
"enable_archive": true,
"archive_thumb": "/s/img/archive.webp",
"enable_item_title": true,
"open_registration": true,
"open_registration_web_toggle": false,
"open_registration_require_mail_andor_token": false,
"private_society": false,
"private_society_gate": "cloudflare",
"public_nsfw": false,
"paths": {
"images": "/b",
"thumbnails": "/t",
@@ -177,7 +210,16 @@
"video/x-matroska": "mkv",
"application/x-shockwave-flash": "swf",
"application/vnd.adobe.flash.movie": "swf",
"application/pdf": "pdf"
"application/pdf": "pdf",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
"application/x-rar-compressed": "rar",
"application/vnd.rar": "rar",
"application/x-7z-compressed": "7z",
"application/x-tar": "tar",
"application/gzip": "gz",
"application/x-bzip2": "bz2",
"application/x-xz": "xz"
},
"apis": {},
"smtp": {
@@ -189,5 +231,10 @@
"password": "smtp_password",
"from": "admin@example.com",
"mail_reset_password": false
},
"recaptcha": {
"enabled": false,
"site_key": "YOUR_RECAPTCHA_V2_SITE_KEY",
"secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY"
}
}

View File

@@ -11,12 +11,14 @@ services:
networks:
- f0ckm-net
volumes:
- ./config.json:/opt/f0ckm/config.json:Z
- ./config.json:/opt/f0ckm/config.json:ro,Z
- ./src/:/opt/f0ckm/src/:Z
- ./views/:/opt/f0ckm/views/:Z
- ./scripts/:/opt/f0ckm/scripts/:Z
- ./f0ckm-data/a/:/opt/f0ckm/public/a/:Z
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
@@ -33,6 +35,10 @@ services:
environment:
- GIT_HASH=${f0ckm_TAG:-unknown}
- VIRTUAL_HOST=${VIRTUAL_HOST:-localhost}
- VIRTUAL_PORT=1337
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
ports:
- "1337:1337"
restart: unless-stopped
@@ -40,6 +46,16 @@ services:
f0ckm-db:
condition: service_healthy
tor:
image: dockurr/tor
container_name: tor
ports:
- "9050:9050"
- "8118:8118"
networks:
- f0ckm-net
restart: always
f0ckm-db:
container_name: f0ckm-db
image: postgres:17
@@ -78,6 +94,49 @@ services:
# - f0ckm-net
# restart: unless-stopped
f0ckm-nginx:
image: nginxproxy/nginx-proxy:latest
container_name: f0ckm-nginx
profiles:
- f0ckm-nginx
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost:/etc/nginx/vhost.d:rw
- html:/usr/share/nginx/html:rw
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
networks:
- f0ckm-net
restart: unless-stopped
acme-companion:
image: nginxproxy/acme-companion:latest
container_name: f0ckm-acme-companion
profiles:
- f0ckm-nginx
environment:
- NGINX_PROXY_CONTAINER=f0ckm-nginx
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost:/etc/nginx/vhost.d:rw
- html:/usr/share/nginx/html:rw
- acme:/etc/acme.sh:rw
depends_on:
- f0ckm-nginx
networks:
- f0ckm-net
restart: unless-stopped
networks:
f0ckm-net:
driver: bridge
volumes:
certs:
vhost:
html:
acme:

23
f0ck-svelte/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
f0ck-svelte/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
f0ck-svelte/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
f0ck-svelte/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.3 create --template minimal --types ts --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

2486
f0ck-svelte/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
f0ck-svelte/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "f0ck-svelte",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.1",
"daisyui": "^5.6.18",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}

77
f0ck-svelte/src/app.css Normal file
View File

@@ -0,0 +1,77 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: f0ck --default, light;
}
@theme {
/* f0ck dark theme — AMOLED-inspired */
--color-base-100: oklch(10% 0.02 265);
--color-base-200: oklch(13% 0.02 265);
--color-base-300: oklch(16% 0.02 265);
--color-base-content: oklch(92% 0.01 265);
--color-primary: oklch(55% 0.22 265);
--color-primary-content: oklch(98% 0.01 265);
--color-secondary: oklch(60% 0.18 300);
--color-secondary-content: oklch(98% 0.01 265);
--color-accent: oklch(72% 0.15 290);
--color-accent-content: oklch(10% 0.02 265);
--color-neutral: oklch(20% 0.02 265);
--color-neutral-content: oklch(85% 0.01 265);
--color-info: oklch(70% 0.15 220);
--color-success: oklch(70% 0.15 160);
--color-warning: oklch(78% 0.18 80);
--color-error: oklch(63% 0.22 25);
/* Rating colors */
--color-sfw: oklch(70% 0.15 160);
--color-nsfw: oklch(65% 0.20 25);
--color-nsfl: oklch(60% 0.22 0);
}
/* ── Global resets & base styles ───────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
}
html {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--color-base-100);
color: var(--color-base-content);
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
overscroll-behavior: none;
}
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: oklch(30% 0.02 265); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: oklch(40% 0.02 265); }
/* ── Rating badge utility classes ─────────────────────────────────────── */
.badge-sfw { background: oklch(70% 0.15 160 / 20%); color: oklch(70% 0.15 160); border-color: oklch(70% 0.15 160 / 40%); }
.badge-nsfw { background: oklch(65% 0.20 25 / 20%); color: oklch(65% 0.20 25); border-color: oklch(65% 0.20 25 / 40%); }
.badge-nsfl { background: oklch(55% 0.22 0 / 20%); color: oklch(55% 0.22 0); border-color: oklch(55% 0.22 0 / 40%); }
.badge-unt { background: oklch(55% 0.02 265 / 20%); color: oklch(65% 0.02 265); border-color: oklch(55% 0.02 265 / 40%); }
/* ── Grid thumb skeleton pulse ─────────────────────────────────────────── */
@keyframes thumb-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.thumb-skeleton { animation: thumb-pulse 1.5s ease-in-out infinite; }
/* ── Fade-in for navigations ───────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.page-enter { animation: fade-in 0.15s ease-out; }

21
f0ck-svelte/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { SessionUser } from '$lib/types/session';
declare global {
namespace App {
interface Error {
message: string;
code?: number;
}
interface Locals {
session: SessionUser | null;
}
interface PageData {
session: SessionUser | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
f0ck-svelte/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
import type { Handle } from '@sveltejs/kit';
/**
* Server-side hook: read session from the existing f0ckm backend
* by forwarding the session cookie on every SSR request.
*
* This keeps auth as a single source of truth in the backend.
*/
export const handle: Handle = async ({ event, resolve }) => {
// Forward cookies to the backend session endpoint
const cookieHeader = event.request.headers.get('cookie') ?? '';
try {
const res = await fetch(`${getApiBase(event)}/api/v2/session`, {
headers: { cookie: cookieHeader }
});
if (res.ok) {
const data = await res.json();
event.locals.session = data.loggedIn ? data.user : null;
} else {
event.locals.session = null;
}
} catch {
// Backend not reachable (dev without backend running)
event.locals.session = null;
}
return resolve(event);
};
/**
* Determine the API base URL for server-side fetches.
* In production the SvelteKit server and the API backend run on separate ports.
* The VITE_API_URL env var (or API_URL for SSR) sets the backend address.
*/
function getApiBase(event: Parameters<Handle>[0]['event']): string {
// SSR: use internal backend address (avoids going through Nginx proxy)
const internal = process.env.API_URL ?? 'http://localhost:1337';
return internal;
}

View File

@@ -0,0 +1,132 @@
import type { Comment } from '$lib/types/comment';
import { csrfFetch } from '$lib/utils/csrfFetch';
// ── Comments ──────────────────────────────────────────────────────────────────
export interface CommentsResponse {
success: boolean;
comments: Comment[];
total?: number;
}
export async function getComments(
itemId: number,
fetchFn = fetch
): Promise<CommentsResponse> {
const res = await fetchFn(`/api/comments/${itemId}`);
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
return res.json();
}
export async function postComment(payload: {
itemId: number;
content: string;
replyTo?: number | null;
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
const body = new URLSearchParams({
item_id: String(payload.itemId),
content: payload.content,
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
});
const res = await csrfFetch('/api/comments', { method: 'POST', body });
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
return res.json();
}
export async function deleteComment(
commentId: number
): Promise<{ success: boolean; msg?: string }> {
// Backend uses POST /api/comments/:id/delete (not DELETE method)
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
return res.json();
}
// ── Favorites ─────────────────────────────────────────────────────────────────
export interface FavResponse {
success: boolean;
itemid: number;
favs: Array<{
user: string;
display_name: string | null;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
}>;
}
export async function toggleFav(itemId: number): Promise<FavResponse> {
const body = new URLSearchParams({ postid: String(itemId) });
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
return res.json();
}
// ── Tags ──────────────────────────────────────────────────────────────────────
export interface TagsResponse {
success: boolean;
tags: import('$lib/types/item').Tag[];
msg?: string;
}
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
const body = new URLSearchParams({ tagname });
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
return res.json();
}
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
return res.json();
}
export interface CycleRatingResponse {
success: boolean;
rating_tag_id: number;
rating_label: 'SFW' | 'NSFW' | 'NSFL';
rating_class: 'sfw' | 'nsfw' | 'nsfl';
msg?: string;
}
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
return res.json();
}
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error('suggestTags failed');
return res.json();
}
// ── Item management (mod/admin) ───────────────────────────────────────────────
export async function deleteItem(
itemId: number,
reason = ''
): Promise<{ success: boolean; msg?: string }> {
const body = new URLSearchParams({ postid: String(itemId), reason });
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
return res.json();
}
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
// Backend reads id from query string: req.url.qs.id
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
return res.json();
}
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
return res.json();
}

View File

@@ -0,0 +1,29 @@
import type { SessionResponse } from '$lib/types/session';
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
const res = await fetchFn('/api/v2/session');
if (!res.ok) return { loggedIn: false, user: null };
return res.json();
}
export async function login(
username: string,
password: string,
kmsi = false
): Promise<{ success: boolean; msg?: string }> {
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
const res = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
},
body: body.toString(),
credentials: 'include'
});
return res.json();
}
export async function logout(): Promise<void> {
await fetch('/logout', { method: 'POST', credentials: 'include' });
}

View File

@@ -0,0 +1,96 @@
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
export interface GetItemsParams {
page?: number;
mode?: number;
ratings?: string;
mime?: string;
tag?: string;
hall?: string;
user?: string;
fav?: boolean;
random?: boolean;
strict?: boolean;
min_xd?: number;
newer?: number;
tagger?: string;
}
/**
* Fetch a page of grid items.
* Called from +page.ts (server-side) and from the client for infinite-scroll.
*/
export async function getItems(
params: GetItemsParams,
fetchFn: typeof fetch = fetch
): Promise<ItemsResponse> {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.mode !== undefined) qs.set('mode', String(params.mode));
if (params.ratings) qs.set('ratings', params.ratings);
if (params.mime) qs.set('mime', params.mime);
if (params.tag) qs.set('tag', params.tag);
if (params.hall) qs.set('hall', params.hall);
if (params.user) qs.set('user', params.user);
if (params.fav) qs.set('fav', 'true');
if (params.random) qs.set('random', '1');
if (params.strict) qs.set('strict', '1');
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
if (params.newer) qs.set('newer', String(params.newer));
if (params.tagger) qs.set('tagger', params.tagger);
const res = await fetchFn(`/api/v2/items?${qs}`);
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
return res.json();
}
export interface GetItemParams {
mode?: number;
ratings?: string;
tag?: string;
hall?: string;
user?: string;
fav?: boolean;
random?: boolean;
strict?: boolean;
mime?: string;
userHall?: string;
userHallOwner?: string;
}
/**
* Fetch a single item with its neighbours and metadata.
*/
export async function getItem(
id: number | string,
params: GetItemParams = {},
fetchFn: typeof fetch = fetch
): Promise<ItemResponse> {
const qs = new URLSearchParams();
if (params.mode !== undefined) qs.set('mode', String(params.mode));
if (params.ratings) qs.set('ratings', params.ratings);
if (params.tag) qs.set('tag', params.tag);
if (params.hall) qs.set('hall', params.hall);
if (params.user) qs.set('user', params.user);
if (params.fav) qs.set('fav', 'true');
if (params.random) qs.set('random', '1');
if (params.strict) qs.set('strict', '1');
if (params.mime) qs.set('mime', params.mime);
if (params.userHall) qs.set('userHall', params.userHall);
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
const suffix = qs.toString() ? `?${qs}` : '';
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
return res.json();
}
/**
* Get a random item ID (optionally within a context).
* Returns the JSON response; caller should navigate to response.url.
*/
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
const res = await fetchFn('/api/v2/random');
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
return res.json();
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,232 @@
<script lang="ts">
import { getComments, postComment, deleteComment } from '$lib/api/actions';
import { session, isModOrAdmin } from '$lib/stores/session';
import { flash } from '$lib/stores/flash';
import { timeAgo, formatDate } from '$lib/utils/timeago';
import { onSSE } from '$lib/stores/sse';
import { onDestroy } from 'svelte';
import type { Comment } from '$lib/types/comment';
interface Props {
itemId: number;
}
let { itemId }: Props = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<Comment[]>([]);
let loading = $state(true);
let error = $state('');
let text = $state('');
let replyTo = $state<Comment | null>(null);
let submitting = $state(false);
// ── Load ──────────────────────────────────────────────────────────────────
async function load() {
loading = true;
error = '';
try {
const data = await getComments(itemId);
comments = data.comments ?? [];
} catch {
error = 'Failed to load comments.';
} finally {
loading = false;
}
}
// Run on mount and when itemId changes
$effect(() => { load(); });
// ── Live updates via SSE ──────────────────────────────────────────────────
const unsubSSE = onSSE((event) => {
if (event.type !== 'comments') return;
const d = event.data as { item_id: number; type?: string };
if (d.item_id !== itemId) return;
// Reload the full comment list on any comment activity for this item
load();
});
onDestroy(unsubSSE);
// ── Post comment ──────────────────────────────────────────────────────────
async function submit(e: Event) {
e.preventDefault();
if (!text.trim()) return;
submitting = true;
try {
const res = await postComment({
itemId,
content: text.trim(),
replyTo: replyTo?.id ?? null
});
if (res.success) {
text = '';
replyTo = null;
await load();
flash('Comment posted', 'success');
} else {
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
}
} catch {
flash('Network error', 'error');
} finally {
submitting = false;
}
}
// ── Delete comment ────────────────────────────────────────────────────────
async function handleDelete(comment: Comment) {
if (!confirm('Delete this comment?')) return;
try {
const res = await deleteComment(comment.id);
if (res.success) {
await load();
flash('Deleted', 'success');
} else {
flash(res.msg ?? 'Failed to delete', 'error');
}
} catch {
flash('Network error', 'error');
}
}
function canDelete(comment: Comment) {
return $session && ($isModOrAdmin || $session.user === comment.username);
}
function avatarSrc(comment: Comment): string {
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
if (comment.avatar) return `/t/${comment.avatar}.webp`;
return '/a/default.png';
}
</script>
<section class="flex flex-col gap-3">
<!-- Header -->
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
</span>
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
</button>
</div>
<!-- Comment form -->
{#if $session}
<form onsubmit={submit} class="flex flex-col gap-2">
{#if replyTo}
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100"></button>
</div>
{/if}
<div class="flex gap-2">
<textarea
bind:value={text}
placeholder="Write a comment…"
rows="2"
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
disabled={submitting}
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
></textarea>
<button
type="submit"
class="btn btn-primary btn-sm self-end"
disabled={submitting || !text.trim()}
>
{#if submitting}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
{/if}
</button>
</div>
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
</form>
{:else}
<p class="text-xs opacity-40">
<a href="/login" class="link link-primary">Log in</a> to comment.
</p>
{/if}
<!-- Comments list -->
{#if loading}
<div class="flex justify-center py-4">
<span class="loading loading-dots loading-sm opacity-40"></span>
</div>
{:else if error}
<p class="text-xs text-error opacity-70">{error}</p>
{:else if comments.length === 0}
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each comments as comment (comment.id)}
<li
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
id="comment-{comment.id}"
>
<!-- Avatar -->
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
<div class="avatar">
<div class="w-7 rounded-full border border-base-300">
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
</div>
</div>
</a>
<!-- Body -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-1.5 flex-wrap">
<a
href="/user/{comment.username?.toLowerCase()}"
class="font-semibold text-xs hover:text-primary transition-colors"
style={comment.username_color ? `color: ${comment.username_color}` : ''}
>
{comment.display_name || comment.username}
</a>
<time
datetime={comment.stamp_full}
title={formatDate(comment.stamp_full)}
class="text-xs opacity-40"
>
{comment.stamp}
</time>
</div>
{#if comment.is_deleted}
<p class="text-xs italic opacity-50">— deleted —</p>
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
{@html comment.content}
</div>
{/if}
<!-- Actions -->
{#if !comment.is_deleted}
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
{#if $session}
<button
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
class="btn btn-ghost btn-xs gap-1 text-xs"
>
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
</button>
{/if}
{#if canDelete(comment)}
<button
onclick={() => handleDelete(comment)}
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
>
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
</button>
{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import type { GridItem } from '$lib/types/item';
import type { ItemLink } from '$lib/types/item';
interface Props {
item: GridItem;
link: ItemLink;
}
let { item, link }: Props = $props();
// Build the item URL respecting the current filter context
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
const thumbUrl = $derived(`/t/${item.id}.webp`);
// Rating badge
const ratingClass = $derived(
item.tag === 'NSFL' ? 'badge-nsfl' :
item.tag === 'NSFW' ? 'badge-nsfw' :
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
);
</script>
<a
href={itemUrl}
data-sveltekit-preload-data="hover"
class="
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
hover:border-primary/50 transition-all duration-200
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
"
>
<!-- Thumbnail -->
<div class="aspect-video w-full overflow-hidden bg-base-300">
<img
src={thumbUrl}
alt="Item {item.id}"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
/>
</div>
<!-- Overlay on hover -->
<div class="
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
opacity-0 group-hover:opacity-100 transition-opacity duration-200
flex flex-col justify-end p-2 gap-1
">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
<div class="flex items-center gap-1">
{#if item.is_oc}
<span class="badge badge-xs badge-accent">OC</span>
{/if}
{#if item.is_pinned}
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
{/if}
{#if item.xd_tier > 0}
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
{/if}
</div>
</div>
{#if item.display_name || item.username}
<span class="text-xs text-base-content/60 truncate">
{item.display_name || item.username}
</span>
{/if}
</div>
<!-- Rating badge (always visible) -->
{#if item.tag}
<div class="absolute top-1.5 right-1.5">
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
</div>
{/if}
<!-- Notification dot -->
{#if item.has_notification}
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
{/if}
</a>

View File

@@ -0,0 +1,503 @@
<script lang="ts">
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
import TagList from '$lib/components/item/TagList.svelte';
import Comments from '$lib/components/item/Comments.svelte';
import { mimeCategory } from '$lib/utils/mimeHelpers';
import { formatDate } from '$lib/utils/timeago';
import { session, isModOrAdmin } from '$lib/stores/session';
import { goto, preloadData } from '$app/navigation';
import { flash } from '$lib/stores/flash';
import { onSSE } from '$lib/stores/sse';
import { onDestroy } from 'svelte';
import { broadcastPin } from '$lib/stores/pin';
import {
toggleFav, addTag, deleteTag,
deleteItem, pinItem, unpinItem, cycleRating
} from '$lib/api/actions';
interface Props {
item: FullItem;
pagination: Pagination;
link: ItemLink;
isSubscribed?: boolean;
}
let { item, pagination, link, isSubscribed = false }: Props = $props();
// ── Reactive local state (mutable copies updated after actions) ───────────
let favorites = $state(item.favorites);
let tags = $state(item.tags);
let isPinned = $state(item.is_pinned);
let isOc = $state(item.is_oc);
let isSfw = $state(item.is_sfw);
let isNsfw = $state(item.is_nsfw);
let isNsfl = $state(item.is_nsfl);
// Sync when item prop changes (navigation)
$effect(() => {
favorites = item.favorites;
tags = item.tags;
isPinned = item.is_pinned;
isOc = item.is_oc;
isSfw = item.is_sfw;
isNsfw = item.is_nsfw;
isNsfl = item.is_nsfl;
});
// ── Live SSE updates ──────────────────────────────────────────────────────
const unsubSSE = onSSE((event) => {
if (event.type === 'favorites') {
const d = event.data as { item_id: number; favs: FavoriteUser[] };
if (d.item_id === item.id) favorites = d.favs;
} else if (event.type === 'tags') {
const d = event.data as { item_id: number; tags: Tag[] };
if (d.item_id === item.id) {
tags = d.tags;
// Re-derive rating state from the fresh tag list
const SFW_ID = 1, NSFW_ID = 2;
const ids = d.tags.map(t => t.id);
isSfw = ids.includes(SFW_ID);
isNsfw = ids.includes(NSFW_ID);
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
}
} else if (event.type === 'delete_item') {
const d = event.data as { id: number };
if (d.id === item.id) goto('/');
}
});
onDestroy(unsubSSE);
const cat = $derived(mimeCategory(item.mime));
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
// Rating derived from local mutable state (so cycle updates instantly)
const ratingClass = $derived(
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
);
const ratingLabel = $derived(
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
);
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
let ratingCycling = $state(false);
async function handleCycleRating() {
if (!isMod) return;
ratingCycling = true;
try {
const res = await cycleRating(item.id);
if (res.success) {
isSfw = res.rating_class === 'sfw';
isNsfw = res.rating_class === 'nsfw';
isNsfl = res.rating_class === 'nsfl';
flash(`Rating → ${res.rating_label}`, 'success');
} else {
flash(res.msg ?? 'Failed to cycle rating', 'error');
}
} catch {
flash('Network error', 'error');
} finally {
ratingCycling = false;
}
}
// ── Keyboard navigation ───────────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
}
// ── Tag input state ───────────────────────────────────────────────────────
let tagInput = $state('');
let tagAdding = $state(false);
let showTagInput = $state(false);
let suggestions = $state<string[]>([]);
async function submitTag(e: Event) {
e.preventDefault();
const name = tagInput.trim();
if (!name) return;
tagAdding = true;
try {
const res = await addTag(item.id, name);
if (res.success) {
tags = res.tags;
tagInput = '';
showTagInput = false;
flash(`Tag "${name}" added`, 'success');
} else {
flash(res.msg ?? 'Failed to add tag', 'error');
}
} catch {
flash('Network error', 'error');
} finally {
tagAdding = false;
}
}
async function handleRemoveTag(tag: Tag) {
try {
const res = await deleteTag(item.id, tag.tag);
if (res.success) {
tags = res.tags;
flash(`Tag "${tag.tag}" removed`, 'success');
} else {
flash(res.msg ?? 'Failed to remove tag', 'error');
}
} catch {
flash('Network error', 'error');
}
}
// ── Favorite ──────────────────────────────────────────────────────────────
let favLoading = $state(false);
async function handleFav() {
if (!$session) { flash('Log in to favorite', 'warning'); return; }
favLoading = true;
try {
const res = await toggleFav(item.id);
if (res.success) favorites = res.favs;
} catch {
flash('Failed to update favorite', 'error');
} finally {
favLoading = false;
}
}
// ── Delete ────────────────────────────────────────────────────────────────
async function handleDelete() {
const reason = prompt('Reason for deletion (optional):') ?? '';
if (reason === null) return; // cancelled
try {
const res = await deleteItem(item.id, reason);
if (res.success) {
flash('Item deleted', 'success');
goto('/');
} else {
flash(res.msg ?? 'Delete failed', 'error');
}
} catch {
flash('Network error', 'error');
}
}
// ── Pin ───────────────────────────────────────────────────────────────────
async function handlePin() {
try {
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
if (res.success) {
isPinned = res.pinned;
broadcastPin(item.id, res.pinned); // patch grid card live
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
} else {
flash('Pin failed', 'error');
}
} catch {
flash('Network error', 'error');
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
<!-- Tags card -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3 gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
{#if $session}
<button
class="btn btn-xs btn-ghost"
onclick={() => showTagInput = !showTagInput}
aria-label="Add tag"
>
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
</button>
{/if}
</div>
<!-- Tag input -->
{#if showTagInput}
<form onsubmit={submitTag} class="flex gap-1">
<input
bind:value={tagInput}
type="text"
placeholder="tag name…"
class="input input-xs input-bordered flex-1"
disabled={tagAdding}
autocomplete="off"
autofocus
/>
<button
type="submit"
class="btn btn-xs btn-primary"
disabled={tagAdding || !tagInput.trim()}
>
{#if tagAdding}
<span class="loading loading-spinner loading-xs"></span>
{:else}
OK
{/if}
</button>
</form>
{/if}
{#if tags.length > 0}
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
{:else}
<p class="text-xs opacity-40">No tags yet.</p>
{/if}
</div>
</div>
<!-- xD Score -->
{#if item.xd_tier > 0}
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
{item.xd_label}
<span class="opacity-70 text-xs">{item.xd_score}</span>
</div>
{/if}
<!-- Comments -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3">
<Comments itemId={item.id} />
</div>
</div>
</aside>
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
{#if item.title}
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
{/if}
<!-- Media + prev/next arrows -->
<div class="relative group">
<!-- Prev -->
<a
href={prevUrl ?? '#'}
aria-disabled={!prevUrl}
onmouseenter={() => prevUrl && preloadData(prevUrl)}
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
opacity-0 group-hover:opacity-100 transition-opacity
{!prevUrl ? 'pointer-events-none' : ''}"
>
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</div>
</a>
<div class="rounded-lg overflow-hidden bg-base-300">
{#if cat === 'youtube'}
<YouTubeEmbed videoId={ytId} />
{:else if cat === 'video'}
<VideoPlayer src={item.dest} mime={item.mime} />
{:else if cat === 'audio'}
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
{:else if cat === 'image'}
<ImageViewer src={item.dest} alt="Item {item.id}" />
{:else if cat === 'pdf'}
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
{:else}
<div class="flex flex-col items-center justify-center p-12 gap-4">
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
<a href={item.dest} download class="btn btn-primary gap-2">
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
</a>
</div>
{/if}
</div>
<!-- Next -->
<a
href={nextUrl ?? '#'}
aria-disabled={!nextUrl}
onmouseenter={() => nextUrl && preloadData(nextUrl)}
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
opacity-0 group-hover:opacity-100 transition-opacity
{!nextUrl ? 'pointer-events-none' : ''}"
>
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</div>
</a>
</div>
<!-- Metadata / action bar -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3 gap-2">
<!-- Prev / Random / Next -->
<div class="flex items-center justify-between text-sm">
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
onmouseenter={() => prevUrl && preloadData(prevUrl)}
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
</a>
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
</a>
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
onmouseenter={() => nextUrl && preloadData(nextUrl)}
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
</a>
</div>
<div class="divider my-0"></div>
<!-- Item metadata row -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
#{item.id}
</a>
{#if isMod}
<button
onclick={handleCycleRating}
disabled={ratingCycling}
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
title="Cycle rating: {ratingLabel}{isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
aria-label="Cycle rating"
>
{#if ratingCycling}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
{/if}
{ratingLabel}
</button>
{:else}
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
{/if}
<a href="/user/{item.username.toLowerCase()}"
class="font-medium hover:text-primary transition-colors"
style={item.author_color ? `color: ${item.author_color}` : ''}>
{item.author_display_name || item.username}
</a>
{#if isOc}
<span class="badge badge-sm badge-accent">OC</span>
{/if}
{#if isPinned}
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
{/if}
{#if item.primaryHall}
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
{item.primaryHall.name}
</a>
{/if}
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
{item.timestamp.timeago}
</time>
</div>
<!-- Action buttons -->
<div class="flex items-center flex-wrap gap-1 pt-1">
<!-- Favorite -->
<button
onclick={handleFav}
disabled={favLoading || !$session}
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
>
{#if favLoading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
{/if}
{#if favorites.length > 0}
<span class="text-xs">{favorites.length}</span>
{/if}
</button>
<!-- Subscribe (stub) -->
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
</button>
<!-- Link copy -->
<button
class="btn btn-ghost btn-xs"
title="Copy link"
aria-label="Copy link"
onclick={() => {
navigator.clipboard.writeText(location.href);
flash('Link copied!', 'success');
}}
>
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
</button>
<!-- Mod / owner actions -->
{#if canManage}
<div class="flex items-center gap-1 ml-auto">
<!-- Pin/Unpin -->
<button
onclick={handlePin}
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
title="{isPinned ? 'Unpin' : 'Pin'}"
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
>
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
</button>
<!-- Delete -->
<button
onclick={handleDelete}
class="btn btn-ghost btn-xs text-error"
title="Delete"
aria-label="Delete item"
>
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
</button>
</div>
{/if}
</div>
<!-- Favorites avatars -->
{#if favorites.length > 0}
<div class="flex flex-wrap gap-1 pt-1">
{#each favorites as fav (fav.user)}
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
<div class="avatar">
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
{#if fav.avatar_file}
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
{:else if fav.avatar}
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
{:else}
<img src="/a/default.png" alt={fav.user} />
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
</main>
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import type { Tag } from '$lib/types/item';
interface Props {
tags: Tag[];
canRemove?: boolean;
onRemove?: (tag: Tag) => void;
}
let { tags, canRemove = false, onRemove }: Props = $props();
// Map badge class names from the existing tag badge values
const badgeClass = (badge: string) => {
if (badge === 'badge-sfw') return 'badge-sfw';
if (badge === 'badge-nsfw') return 'badge-nsfw';
if (badge === 'badge-nsfl') return 'badge-nsfl';
return 'badge-neutral';
};
</script>
<div class="flex flex-wrap gap-1.5">
{#each tags as tag (tag.id)}
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
{tag.tag}
</a>
{#if canRemove && onRemove}
<button
class="hover:text-error ml-0.5 transition-colors"
onclick={() => onRemove?.(tag)}
aria-label="Remove tag {tag.tag}"
>
<i class="fa-solid fa-xmark text-[10px]"></i>
</button>
{/if}
</span>
{/each}
</div>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { session, loggedIn } from '$lib/stores/session';
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
import { goto } from '$app/navigation';
import { getRandom } from '$lib/api/items';
import { flash } from '$lib/stores/flash';
import { get } from 'svelte/store';
async function goRandom() {
try {
const res = await getRandom();
if (res.success) goto(res.url);
} catch {
flash('Could not fetch a random item', 'error');
}
}
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
// OR just use them in the template with $ prefix — both work, but $ in templates
// is fine for Svelte writable stores even in runes mode.
</script>
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
<!-- Logo / Home -->
<div class="navbar-start gap-2">
<a href="/" class="flex items-center gap-2 group">
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
f0ckm
</span>
</a>
</div>
<!-- Center: actions -->
<div class="navbar-center gap-1 hidden sm:flex">
<!-- Random button -->
<button
class="btn btn-ghost btn-sm gap-1"
onclick={goRandom}
title="Random item"
aria-label="Random item"
>
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
<span class="hidden md:inline">Random</span>
</button>
<!-- Rating filter badge -->
<div class="dropdown dropdown-bottom">
<button
tabindex="0"
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
aria-label="Content filter"
>
{$filterLabel}
</button>
</div>
</div>
<!-- Right: user area -->
<div class="navbar-end gap-2">
{#if $loggedIn}
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
<span class="hidden sm:inline">Upload</span>
</a>
<!-- Avatar / user menu -->
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
{#if $session?.avatar_file}
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
{:else if $session?.avatar}
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
{:else}
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
{/if}
</div>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
{#if $session?.admin || $session?.is_moderator}
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
{/if}
<li class="divider my-1"></li>
<li>
<form method="post" action="/logout">
<button type="submit" class="text-error w-full text-left">
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
</button>
</form>
</li>
</ul>
</div>
{:else}
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
<a href="/register" class="btn btn-primary btn-sm">Register</a>
{/if}
</div>
</nav>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
interface Props {
src: string;
mime: string;
coverart?: string;
}
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
</script>
<div
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
>
<!-- Dark overlay over coverart -->
<div class="absolute inset-0 bg-black/60"></div>
<!-- svelte-ignore a11y-media-has-caption -->
<audio
{src}
loop
controls
preload="auto"
class="relative z-10 w-full max-w-md"
></audio>
</div>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
interface Props {
src: string;
alt?: string;
}
let { src, alt = 'Image' }: Props = $props();
let expanded = $state(false);
let imgEl = $state<HTMLImageElement | undefined>();
function toggleExpand() {
expanded = !expanded;
}
// ESC to close modal
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded) expanded = false;
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
<img
bind:this={imgEl}
{src}
{alt}
loading="lazy"
decoding="async"
onclick={toggleExpand}
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
/>
<button
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
onclick={toggleExpand}
aria-label="Expand"
>
<i class="fa-solid fa-expand"></i>
</button>
</div>
<!-- Fullscreen modal -->
{#if expanded}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
onclick={toggleExpand}
>
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
<button
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
onclick={toggleExpand}
aria-label="Close"
>
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
{/if}

View File

@@ -0,0 +1,36 @@
<script lang="ts">
interface Props {
src: string;
mime: string;
autoplay?: boolean;
}
let { src, mime, autoplay = false }: Props = $props();
let videoEl = $state<HTMLVideoElement | undefined>();
// Keyboard shortcut: space to toggle play/pause
function onKeydown(e: KeyboardEvent) {
if (e.target !== document.body) return;
if (e.key === ' ') {
e.preventDefault();
if (videoEl?.paused) videoEl.play();
else videoEl?.pause();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={videoEl}
{src}
{autoplay}
loop
playsinline
controls
class="max-h-[80dvh] w-full object-contain"
preload="auto"
></video>
</div>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
interface Props {
videoId: string; // e.g. "dQw4w9WgXcQ"
}
let { videoId }: Props = $props();
</script>
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
<iframe
class="absolute inset-0 w-full h-full"
src="https://www.youtube.com/embed/{videoId}"
title="YouTube video"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
const iconMap: Record<FlashType, string> = {
info: 'fa-solid fa-circle-info',
success: 'fa-solid fa-circle-check',
warning: 'fa-solid fa-triangle-exclamation',
error: 'fa-solid fa-circle-xmark'
};
const alertMap: Record<FlashType, string> = {
info: 'alert-info',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error'
};
</script>
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
{#each $flashes as msg (msg.id)}
<div
role="alert"
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
animate-[slide-up_0.2s_ease-out]"
>
<i class="{iconMap[msg.type]}"></i>
<span class="flex-1 text-sm">{msg.text}</span>
<button
class="btn btn-ghost btn-xs"
onclick={() => dismiss(msg.id)}
aria-label="Dismiss"
></button>
</div>
{/each}
</div>
<style>
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import type { Pagination, ItemLink } from '$lib/types/item';
interface Props {
pagination: Pagination;
link: ItemLink;
}
let { pagination, link }: Props = $props();
const base = $derived(link.main + link.path);
const suffix = $derived(link.suffix ?? '');
</script>
{#if pagination.end > 1}
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
<!-- Prev -->
<a
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
aria-disabled={!pagination.prev}
aria-label="Previous page"
>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</a>
<!-- First page -->
{#if pagination.cheat[0] > 1}
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
{#if pagination.cheat[0] > 2}
<span class="px-1 opacity-40">…</span>
{/if}
{/if}
<!-- Page range -->
{#each pagination.cheat as p}
<a
href="{base}{p}{suffix}"
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
>
{p}
</a>
{/each}
<!-- Last page -->
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
<span class="px-1 opacity-40">…</span>
{/if}
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
{/if}
<!-- Next -->
<a
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
aria-disabled={!pagination.next}
aria-label="Next page"
>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
</nav>
{/if}

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,28 @@
import { writable } from 'svelte/store';
// ── Flash message type ────────────────────────────────────────────────────────
export type FlashType = 'info' | 'success' | 'warning' | 'error';
export interface FlashMessage {
id: number;
text: string;
type: FlashType;
}
let _nextId = 0;
export const flashes = writable<FlashMessage[]>([]);
/**
* Show a flash message. Auto-dismisses after `duration` ms.
* Returns the message ID so callers can dismiss early if needed.
*/
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
const id = ++_nextId;
flashes.update((q) => [...q, { id, text, type }]);
setTimeout(() => dismiss(id), duration);
return id;
}
export function dismiss(id: number): void {
flashes.update((q) => q.filter((m) => m.id !== id));
}

View File

@@ -0,0 +1,20 @@
/**
* Lightweight broadcast store for pin/unpin events.
* ItemView fires it after a successful pin action;
* the main grid subscribes to patch the item card immediately.
*/
import { writable } from 'svelte/store';
export interface PinEvent {
itemId: number;
pinned: boolean;
}
// Null when idle, set to an event when pin/unpin succeeds
export const pinEvent = writable<PinEvent | null>(null);
export function broadcastPin(itemId: number, pinned: boolean) {
pinEvent.set({ itemId, pinned });
// Reset so subsequent identical events still trigger subscribers
setTimeout(() => pinEvent.set(null), 0);
}

View File

@@ -0,0 +1,50 @@
import { writable, derived, get } from 'svelte/store';
// ── Content mode ─────────────────────────────────────────────────────────────
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
export const activeMode = writable<number>(3);
// ── Multi-rating filter ───────────────────────────────────────────────────────
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
export const activeRatings = writable<Rating[]>([]);
// ── MIME filter ───────────────────────────────────────────────────────────────
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
export const activeMimes = writable<MimeFilter[]>([]);
// ── Derived: ratings query param string for API calls ─────────────────────────
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
// ── Derived: readable label for the filter badge ─────────────────────────────
export const filterLabel = derived(
[activeMode, activeRatings],
([$mode, $ratings]) => {
if ($ratings.length > 0) {
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
return $ratings.map((r) => abbr[r]).join('+');
}
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
return labels[$mode] ?? 'ALL';
}
);
// ── Persistence helpers ───────────────────────────────────────────────────────
// Call these after receiving the session from the server to hydrate from cookie/session.
export function initPreferences(opts: { mode: number; ratings?: string }) {
activeMode.set(opts.mode ?? 3);
if (opts.ratings) {
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
);
activeRatings.set(parsed);
}
}
/** Build a mode number → server mode number (for API calls when using multi-rating). */
export function getServerMode(): number {
const ratings = get(activeRatings);
if (ratings.length === 0) return get(activeMode);
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
}

View File

@@ -0,0 +1,11 @@
import { writable, derived } from 'svelte/store';
import type { SessionUser } from '$lib/types/session';
/** The current authenticated user, or null if not logged in. */
export const session = writable<SessionUser | null>(null);
/** Convenience derived — true when logged in. */
export const loggedIn = derived(session, ($s) => $s !== null);
/** True when the user has admin or moderator role. */
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));

View File

@@ -0,0 +1,126 @@
/**
* SSE store — connects to the backend's /api/notifications/stream endpoint.
*
* Emits typed events that components subscribe to with `onSSE()`.
* Auto-reconnects with exponential backoff on disconnect.
* Client-only: never runs during SSR.
*/
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
// ── Event types broadcast by the backend ─────────────────────────────────────
export interface SSEComment {
type: 'comment' | 'delete';
id?: number;
item_id: number;
comment_id?: number;
body?: string;
username?: string;
display_name?: string | null;
avatar?: string | null;
avatar_file?: string | null;
username_color?: string | null;
created_at?: string;
parent_id?: number | null;
xd_score?: number | null;
}
export interface SSEFavorites {
item_id: number;
favs: Array<{
user: string;
display_name: string | null;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
}>;
}
export interface SSETags {
item_id: number;
fresh: boolean;
tags: unknown[];
}
export interface SSENewItem {
id: number;
mime?: string;
}
export interface SSEDeleteItem {
id: number;
}
export interface SSEEvent {
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
}
// ── Connection state ──────────────────────────────────────────────────────────
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
export const sseState = writable<SSEState>('disconnected');
// ── Listener registry ─────────────────────────────────────────────────────────
type SSEListener = (event: SSEEvent) => void;
const listeners = new Set<SSEListener>();
/** Subscribe to SSE events. Returns an unsubscribe function. */
export function onSSE(fn: SSEListener): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// ── Internal connection state ─────────────────────────────────────────────────
let es: EventSource | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryDelay = 2000;
const MAX_RETRY = 30_000;
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
function dispatch(raw: SSEEvent) {
for (const fn of listeners) {
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
}
}
function scheduleRetry() {
if (retryTimer) clearTimeout(retryTimer);
retryTimer = setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
}
export function connect() {
if (!browser) return;
if (es) { es.close(); es = null; }
sseState.set('connecting');
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
es.onopen = () => {
sseState.set('connected');
retryDelay = 2000; // reset backoff on successful connect
};
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as SSEEvent;
dispatch(data);
} catch {
// ignore malformed frames
}
};
es.onerror = () => {
sseState.set('error');
es?.close();
es = null;
scheduleRetry();
};
}
export function disconnect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
es?.close();
es = null;
sseState.set('disconnected');
}

View File

@@ -0,0 +1,81 @@
import { writable } from 'svelte/store';
// ── WebSocket message types ───────────────────────────────────────────────────
export type WSMessage =
| { type: 'pong' }
| { type: 'notification'; count: number; item?: unknown }
| { type: 'comment:new'; itemId: number; comment: unknown }
| { type: 'comment:delete'; itemId: number; commentId: number }
| { type: 'item:tag_added'; itemId: number; tag: unknown }
| { type: 'item:favorited'; itemId: number; user: string }
| { type: 'global:activity'; payload: unknown };
// ── Store ─────────────────────────────────────────────────────────────────────
export const wsConnected = writable(false);
let _socket: WebSocket | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _reconnectDelay = 1000;
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
/** Connect to the WebSocket server. Call once from the root layout. */
export function wsConnect(): void {
if (typeof window === 'undefined') return; // SSR guard
if (_socket?.readyState === WebSocket.OPEN) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
_socket = new WebSocket(`${proto}://${location.host}/ws`);
_socket.onopen = () => {
wsConnected.set(true);
_reconnectDelay = 1000; // reset backoff on successful connect
_ping();
};
_socket.onmessage = (e) => {
try {
const msg = JSON.parse(e.data) as WSMessage;
// Dispatch to typed listeners
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
_listeners.get('*')?.forEach((fn) => fn(msg));
} catch {
// ignore malformed messages
}
};
_socket.onclose = () => {
wsConnected.set(false);
_socket = null;
// Exponential backoff reconnect
if (_reconnectTimer) clearTimeout(_reconnectTimer);
_reconnectTimer = setTimeout(() => {
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
wsConnect();
}, _reconnectDelay);
};
_socket.onerror = () => {
_socket?.close();
};
}
/** Send a typed message to the server. */
export function wsSend(msg: object): void {
if (_socket?.readyState === WebSocket.OPEN) {
_socket.send(JSON.stringify(msg));
}
}
/** Subscribe to a specific message type. Returns an unsubscribe function. */
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
if (!_listeners.has(type)) _listeners.set(type, new Set());
_listeners.get(type)!.add(fn);
return () => _listeners.get(type)?.delete(fn);
}
let _pingTimer: ReturnType<typeof setInterval> | null = null;
function _ping() {
if (_pingTimer) clearInterval(_pingTimer);
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
}

View File

@@ -0,0 +1,21 @@
export interface Comment {
id: number;
item_id: number;
username: string;
display_name: string | null;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
content: string; // may be HTML (server-rendered)
stamp: string; // human-readable relative time
stamp_full: string; // ISO timestamp
is_deleted: boolean;
reply_to: number | null;
poll?: unknown;
}
export interface CommentsResponse {
success: boolean;
comments: Comment[];
total?: number;
}

View File

@@ -0,0 +1,137 @@
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
export interface GridItem {
id: number;
mime: string;
dest: string;
username: string;
display_name: string | null;
is_pinned: boolean;
is_oc: boolean;
xd_score: number;
xd_tier: number;
xd_label: string;
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
tag_id: number | null;
thumb_size: 1 | 2;
has_notification: boolean;
}
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
export interface FullItem {
id: number;
mime: string;
dest: string;
username: string;
author_id: number | null;
author_color: string | null;
author_display_name: string | null;
author_avatar: string | null;
author_avatar_file: string | null;
title: string | null;
src: { long: string; short: string };
thumbnail: string;
og_thumbnail: string;
og_description: string;
coverart: string;
size: string;
checksum: string | null;
timestamp: { timeago: string; timefull: string };
favorites: FavoriteUser[];
tags: Tag[];
halls: Hall[];
user_halls: Hall[];
is_nsfw: boolean;
is_nsfl: boolean;
is_sfw: boolean;
is_pinned: boolean;
is_comments_locked: boolean;
is_oc: boolean;
is_repost: boolean;
reposts: RepostItem[];
width: number | null;
height: number | null;
original_filename: string | null;
xd_score: number;
xd_tier: number;
xd_label: string;
primaryHall: Hall | null;
otherHalls: Hall[];
}
// ── Pagination ───────────────────────────────────────────────────────────────
export interface Pagination {
prev: number | null;
next: number | null;
start: number;
end: number;
current: number;
page: number;
cheat: number[];
location?: string;
suffix?: string;
}
// ── Link context ─────────────────────────────────────────────────────────────
export interface ItemLink {
main: string;
mainDisplay?: string;
path: string;
suffix: string;
}
// ── Tags ─────────────────────────────────────────────────────────────────────
export interface Tag {
id: number;
tag: string;
normalized: string;
badge: string;
display_name: string | null;
user: string;
}
// ── Halls ────────────────────────────────────────────────────────────────────
export interface Hall {
name: string;
slug: string;
}
// ── Favorites ────────────────────────────────────────────────────────────────
export interface FavoriteUser {
user: string;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
display_name: string | null;
}
// ── Reposts ──────────────────────────────────────────────────────────────────
export interface RepostItem {
id: number;
username: string;
stamp: number;
match_type: 'checksum' | 'phash';
}
// ── API response wrappers ─────────────────────────────────────────────────────
export interface ItemsResponse {
success: boolean;
items: GridItem[];
pagination: Pagination;
total: number;
link: ItemLink;
}
export interface ItemResponse {
success: boolean;
item: FullItem | null;
pagination: Pagination;
link: ItemLink;
isSubscribed: boolean;
message?: string;
}
export interface RandomResponse {
success: boolean;
id: number;
url: string;
}

View File

@@ -0,0 +1,27 @@
export interface SessionUser {
id: number;
user: string;
login: string;
admin: boolean;
is_moderator: boolean;
mode: number;
theme: string;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
display_name: string | null;
use_new_layout: boolean;
excluded_tags: number[];
min_xd_score: number;
uploads_remaining: number | undefined;
pending_count?: number;
language: string | null;
disable_autoplay: boolean;
disable_swiping: boolean;
csrf_token: string;
}
export interface SessionResponse {
loggedIn: boolean;
user: SessionUser | null;
}

View File

@@ -0,0 +1,23 @@
/**
* Wrapper around fetch that automatically injects the CSRF token as a header
* on all state-changing requests (POST, PUT, PATCH, DELETE).
*
* The token is read from the session store at call time so it's always fresh.
*/
import { get } from 'svelte/store';
import { session } from '$lib/stores/session';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const method = (init.method ?? 'GET').toUpperCase();
if (SAFE_METHODS.has(method)) return fetch(input, init);
const token = get(session)?.csrf_token;
if (!token) return fetch(input, init); // not logged in — let backend return 401
const headers = new Headers(init.headers ?? {});
headers.set('X-CSRF-Token', token);
return fetch(input, { ...init, headers });
}

View File

@@ -0,0 +1,32 @@
/** Map a MIME type to a simple category string. */
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
if (mime === 'video/youtube') return 'youtube';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('image/')) return 'image';
if (mime === 'application/pdf') return 'pdf';
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
if (mime.startsWith('application/')) return 'archive';
return 'unknown';
}
/** Return a Font Awesome icon class for a MIME category. */
export function mimeIcon(mime: string): string {
const cat = mimeCategory(mime);
const map: Record<string, string> = {
youtube: 'fa-brands fa-youtube',
video: 'fa-solid fa-film',
audio: 'fa-solid fa-music',
image: 'fa-solid fa-image',
flash: 'fa-solid fa-bolt',
pdf: 'fa-solid fa-file-pdf',
archive: 'fa-solid fa-file-zipper',
unknown: 'fa-solid fa-file'
};
return map[cat] ?? map.unknown;
}
/** True if the MIME type represents inline-playable media. */
export function isPlayable(mime: string): boolean {
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
}

View File

@@ -0,0 +1,45 @@
/**
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
* relative time string, e.g. "3 hours ago".
*/
export function timeAgo(input: number | string | Date): string {
const date =
input instanceof Date
? input
: typeof input === 'number'
? new Date(input * 1000)
: new Date(input);
const now = Date.now();
const diff = now - date.getTime();
const epochs: [string, number][] = [
['year', 365 * 24 * 3600 * 1000],
['month', 30 * 24 * 3600 * 1000],
['week', 7 * 24 * 3600 * 1000],
['day', 24 * 3600 * 1000],
['hour', 3600 * 1000],
['minute', 60 * 1000],
['second', 1000]
];
for (const [label, ms] of epochs) {
const n = Math.floor(diff / ms);
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
}
return 'just now';
}
/**
* Format an ISO timestamp to a readable full date string.
*/
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
/** Pass session to all pages via PageData. */
export const load: LayoutServerLoad = ({ locals }) => {
return {
session: locals.session
};
};

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import '../app.css';
import Navbar from '$lib/components/layout/Navbar.svelte';
import FlashMessages from '$lib/components/ui/FlashMessages.svelte';
import { session as sessionStore } from '$lib/stores/session';
import { initPreferences } from '$lib/stores/preferences';
import { connect as sseConnect, disconnect as sseDisconnect } from '$lib/stores/sse';
import { onMount, onDestroy } from 'svelte';
interface Props {
data: {
session: import('$lib/types/session').SessionUser | null;
};
children: import('svelte').Snippet;
}
let { data, children }: Props = $props();
// Hydrate session store from server-loaded data
$effect(() => {
sessionStore.set(data.session);
});
// Init user preferences from session
$effect(() => {
if (data.session) {
initPreferences({ mode: data.session.mode ?? 3 });
}
});
onMount(() => {
sseConnect();
});
onDestroy(() => {
sseDisconnect();
});
</script>
<svelte:head>
<title>f0ckm</title>
<meta name="description" content="f0ckm — media sharing" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
</svelte:head>
<div class="min-h-dvh flex flex-col" data-theme="f0ck">
<Navbar />
<div class="flex-1">
{@render children()}
</div>
<!-- Global flash messages -->
<FlashMessages />
</div>

View File

@@ -0,0 +1,30 @@
import type { PageServerLoad } from './$types';
import { getItems } from '$lib/api/items';
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const page = Number(url.searchParams.get('page') ?? 1);
const mode = locals.session?.mode ?? 3;
try {
const data = await getItems({ page, mode }, fetch);
if (data.success) {
return {
items: data.items,
pagination: data.pagination,
total: data.total,
link: data.link
};
}
} catch (e) {
// Backend not yet running or API route not registered — return empty state
// so the page renders rather than hard-erroring.
console.warn('[/] Failed to load items:', (e as Error).message);
}
return {
items: [],
pagination: null,
total: 0,
link: { main: '/', path: 'p/', suffix: '' }
};
};

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import type { PageData } from './$types';
import type { GridItem } from '$lib/types/item';
import ItemCard from '$lib/components/item/ItemCard.svelte';
import Pagination from '$lib/components/ui/Pagination.svelte';
import { onSSE } from '$lib/stores/sse';
import { pinEvent } from '$lib/stores/pin';
import { onDestroy } from 'svelte';
let { data }: { data: PageData } = $props();
// Local mutable copy of the items list — SSE patches it live
let items = $state<GridItem[]>(data.items);
let total = $state(data.total);
// Re-sync when server data changes (e.g. navigation / invalidation)
$effect(() => {
items = data.items;
total = data.total;
});
// ── Live grid updates via SSE ─────────────────────────────────────────────
const unsubSSE = onSSE((event) => {
if (event.type === 'new_item') {
const d = event.data as GridItem & { dest: string; username: string };
if (!items.some(i => i.id === d.id)) {
items = [d, ...items];
total += 1;
}
} else if (event.type === 'delete_item') {
const d = event.data as { id: number };
const before = items.length;
items = items.filter(i => i.id !== d.id);
if (items.length < before) total = Math.max(0, total - 1);
}
});
onDestroy(unsubSSE);
// ── Pin store: patch the grid card is_pinned live ─────────────────────────
const unsubPin = pinEvent.subscribe((evt) => {
if (!evt) return;
items = items.map(i => i.id === evt.itemId ? { ...i, is_pinned: evt.pinned } : i);
});
onDestroy(unsubPin);
</script>
<svelte:head>
<title>f0ckm — {total} items</title>
</svelte:head>
<div class="max-w-[1600px] mx-auto px-3 py-4">
<!-- Grid header -->
{#if total > 0}
<p class="text-xs opacity-40 mb-3 font-mono">{total.toLocaleString()} items</p>
{/if}
<!-- Thumbnail grid -->
{#if items.length > 0}
<div class="
grid gap-1.5
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
">
{#each items as item (item.id)}
<ItemCard {item} link={data.link} />
{/each}
</div>
{#if data.pagination}
<Pagination pagination={data.pagination} link={data.link} />
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
<i class="fa-solid fa-box-open text-5xl"></i>
<p class="text-sm">No items found.</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { getItem } from '$lib/api/items';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, fetch, locals, url }) => {
const id = params.itemid;
if (!id || !/^\d+$/.test(id)) throw error(404, 'Not found');
const mode = locals.session?.mode ?? 3;
try {
const data = await getItem(id, { mode }, fetch);
if (!data.success || !data.item) {
throw error(404, 'Item not found or not visible in current mode');
}
return {
item: data.item,
pagination: data.pagination,
link: data.link,
isSubscribed: data.isSubscribed
};
} catch (e) {
if (e instanceof Response) throw e;
throw error(500, 'Failed to load item');
}
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { PageData } from './$types';
import ItemView from '$lib/components/item/ItemView.svelte';
let { data }: { data: PageData } = $props();
const ogTitle = $derived(data.item.title ?? `#${data.item.id} — f0ckm`);
const ogImage = $derived(data.item.og_thumbnail);
const ogDesc = $derived(data.item.og_description ?? '');
</script>
<svelte:head>
<title>{ogTitle}</title>
<meta name="description" content={ogDesc} />
<!-- OG tags for embeds -->
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={ogDesc} />
<meta property="og:image" content={ogImage} />
<meta property="og:type" content="article" />
<!-- Twitter card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={ogDesc} />
<meta name="twitter:image" content={ogImage} />
</svelte:head>
<ItemView
item={data.item}
pagination={data.pagination}
link={data.link}
isSubscribed={data.isSubscribed}
/>

View File

@@ -0,0 +1,9 @@
// Stub page for /login — will be built in Sprint 2.
// For now redirect to the legacy login page so auth still works.
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// Temporary: redirect to legacy backend login until we build the Svelte login form
throw redirect(307, 'http://localhost:1337/login');
};

View File

@@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { getRandom } from '$lib/api/items';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch }) => {
try {
const data = await getRandom(fetch);
if (data.success && data.url) {
throw redirect(307, data.url);
}
} catch (e) {
if (e instanceof Response) throw e;
}
// Fallback: go home if no random item found
throw redirect(307, '/');
};

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
throw redirect(307, 'http://localhost:1337/register');
};

View File

@@ -0,0 +1,31 @@
import type { PageServerLoad } from './$types';
import { getItems } from '$lib/api/items';
export const load: PageServerLoad = async ({ fetch, url, params, locals }) => {
const tag = decodeURIComponent(params.tag);
const page = Number(url.searchParams.get('page') ?? 1);
const mode = locals.session?.mode ?? 3;
try {
const data = await getItems({ page, mode, tag }, fetch);
if (data.success) {
return {
tag,
items: data.items,
pagination: data.pagination,
total: data.total,
link: data.link
};
}
} catch (e) {
console.warn(`[/tag/${tag}] Failed to load items:`, (e as Error).message);
}
return {
tag,
items: [],
pagination: null,
total: 0,
link: { main: `/tag/${encodeURIComponent(tag)}/`, path: 'p/', suffix: '' }
};
};

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { PageData } from './$types';
import type { GridItem } from '$lib/types/item';
import ItemCard from '$lib/components/item/ItemCard.svelte';
import Pagination from '$lib/components/ui/Pagination.svelte';
import { onSSE } from '$lib/stores/sse';
import { onDestroy } from 'svelte';
let { data }: { data: PageData } = $props();
let items = $state<GridItem[]>(data.items);
let total = $state(data.total);
$effect(() => {
items = data.items;
total = data.total;
});
// Live: remove items that get deleted
// (we don't prepend new_item because it may not match this tag filter)
const unsubSSE = onSSE((event) => {
if (event.type === 'delete_item') {
const d = event.data as { id: number };
const before = items.length;
items = items.filter(i => i.id !== d.id);
if (items.length < before) total = Math.max(0, total - 1);
}
});
onDestroy(unsubSSE);
</script>
<svelte:head>
<title>#{data.tag} — f0ckm</title>
<meta name="description" content="Items tagged #{data.tag} on f0ckm" />
</svelte:head>
<div class="max-w-[1600px] mx-auto px-3 py-4">
<!-- Tag header -->
<div class="flex items-center gap-3 mb-4">
<div class="flex items-center gap-2">
<i class="fa-solid fa-tag text-primary opacity-70" aria-hidden="true"></i>
<h1 class="text-xl font-bold">{data.tag}</h1>
</div>
{#if total > 0}
<span class="badge badge-neutral font-mono text-xs">{total.toLocaleString()}</span>
{/if}
<a href="/" class="btn btn-ghost btn-xs ml-auto opacity-60 hover:opacity-100 gap-1">
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i> Clear
</a>
</div>
<!-- Grid -->
{#if items.length > 0}
<div class="
grid gap-1.5
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
">
{#each items as item (item.id)}
<ItemCard {item} link={data.link} />
{/each}
</div>
{#if data.pagination}
<Pagination pagination={data.pagination} link={data.link} />
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
<i class="fa-solid fa-tag text-5xl" aria-hidden="true"></i>
<p class="text-sm">No items found for <strong>#{data.tag}</strong>.</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,27 @@
import type { PageServerLoad } from './$types';
import { getItem } from '$lib/api/items';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch, params, locals }) => {
const tag = decodeURIComponent(params.tag);
const itemid = Number(params.itemid);
const mode = locals.session?.mode ?? 3;
if (!itemid) throw error(404, 'Not found');
try {
const data = await getItem(itemid, { mode, tag }, fetch);
if (!data.success || !data.item) throw error(404, data.message ?? 'Item not found');
return {
tag,
item: data.item,
pagination: data.pagination,
link: data.link,
isSubscribed: data.isSubscribed ?? false
};
} catch (e: unknown) {
if ((e as { status?: number })?.status) throw e;
throw error(500, 'Failed to load item');
}
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { PageData } from './$types';
import ItemView from '$lib/components/item/ItemView.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>#{data.tag} — item {data.item.id} — f0ckm</title>
</svelte:head>
<!-- Tag breadcrumb -->
<div class="max-w-[1600px] mx-auto px-4 pt-2">
<div class="text-xs opacity-50 flex items-center gap-1.5">
<a href="/" class="hover:opacity-100 transition-opacity">f0ckm</a>
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
<a href="/tag/{encodeURIComponent(data.tag)}" class="hover:opacity-100 transition-opacity flex items-center gap-1">
<i class="fa-solid fa-tag text-[10px]" aria-hidden="true"></i>
{data.tag}
</a>
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
<span>#{data.item.id}</span>
</div>
</div>
<ItemView
item={data.item}
pagination={data.pagination}
link={data.link}
isSubscribed={data.isSubscribed}
/>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

21
f0ck-svelte/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["node"]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -0,0 +1,47 @@
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
})
],
server: {
port: 5173,
proxy: {
// Proxy API + WebSocket to the existing f0ckm backend
'/api': {
target: 'http://localhost:1337',
changeOrigin: true
},
'/ajax': {
target: 'http://localhost:1337',
changeOrigin: true
},
'/ws': {
target: 'ws://localhost:1337',
ws: true
},
// Static media files — use exact prefix matching to avoid
// colliding with Vite's own /src/ module paths.
// Each rule anchored to exactly /X/ (with trailing slash).
'^/b/': { target: 'http://localhost:1337', changeOrigin: true },
'^/t/': { target: 'http://localhost:1337', changeOrigin: true },
'^/ca/': { target: 'http://localhost:1337', changeOrigin: true },
'^/a/': { target: 'http://localhost:1337', changeOrigin: true },
// /s/ — NOTE: must NOT match /src/ — regex anchors it
'^/s/': { target: 'http://localhost:1337', changeOrigin: true },
'^/memes/': { target: 'http://localhost:1337', changeOrigin: true },
// mod actions (pin, unpin, delete)
'^/mod/': { target: 'http://localhost:1337', changeOrigin: true }
}
}
});

0
f0ckm-data/c/.gitkeep Normal file
View File

0
f0ckm-data/e/.gitkeep Normal file
View File

View File

@@ -0,0 +1,10 @@
-- User emoji favorites — persisted per-user, cross-device
CREATE TABLE IF NOT EXISTS user_emoji_favorites (
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
emoji_name text NOT NULL,
emoji_url text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, emoji_name)
);
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON user_emoji_favorites (user_id);

View File

@@ -0,0 +1,53 @@
-- Migration: Add sticker_packs table and link custom_emojis to packs
-- Run this against the f0ckm database (idempotent — safe to run multiple times)
-- Create sticker_packs table
CREATE TABLE IF NOT EXISTS public.sticker_packs (
id serial PRIMARY KEY,
name text NOT NULL,
tg_name text UNIQUE,
tg_title text,
thumb_url text,
sticker_count integer DEFAULT 0,
created_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
-- Add pack_id column to custom_emojis (nullable: NULL = standalone emoji, no pack)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'custom_emojis'
AND column_name = 'pack_id'
) THEN
ALTER TABLE public.custom_emojis
ADD COLUMN pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
RAISE NOTICE 'pack_id column added to custom_emojis';
ELSE
RAISE NOTICE 'pack_id column already exists on custom_emojis — skipping';
END IF;
END
$$;
-- Add thumb_url column to sticker_packs if missing
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'sticker_packs'
AND column_name = 'thumb_url'
) THEN
ALTER TABLE public.sticker_packs ADD COLUMN thumb_url text;
RAISE NOTICE 'thumb_url column added to sticker_packs';
ELSE
RAISE NOTICE 'thumb_url already exists on sticker_packs — skipping';
END IF;
END
$$;
-- Index for fast pack lookups
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);

View File

@@ -0,0 +1,15 @@
-- Add banner_file column to user_options for custom profile banners
-- Banners are displayed in the alternative infobox (user-infobox-block)
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner (e.g. cover, contain, or a percentage)';

View File

@@ -20,6 +20,9 @@ SET client_min_messages = warning;
SET row_security = off;
DROP PUBLICATION IF EXISTS alltables;
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_user_id_fkey;
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_api_key_key;
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_pkey;
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey;
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey;
ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey;
@@ -76,7 +79,7 @@ CREATE OR REPLACE VIEW public.items_li AS
SELECT
NULL::integer AS id,
NULL::character varying(255) AS src,
NULL::character varying(40) AS dest,
NULL::character varying(60) AS dest,
NULL::character varying(100) AS mime,
NULL::integer AS size,
NULL::character varying(255) AS checksum,
@@ -91,6 +94,7 @@ DROP INDEX IF EXISTS public.idx_user_last_seen;
DROP INDEX IF EXISTS public.idx_user_halls_user_id;
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
DROP INDEX IF EXISTS public.idx_user_halls_assign_hall;
DROP INDEX IF EXISTS public.idx_user_api_keys_api_key;
DROP INDEX IF EXISTS public.idx_user_alias_userid;
DROP INDEX IF EXISTS public.idx_user_alias_type;
DROP INDEX IF EXISTS public.idx_user_alias_alias;
@@ -191,6 +195,7 @@ DROP TABLE IF EXISTS public.user_halls_assign;
DROP TABLE IF EXISTS public.user_halls;
DROP TABLE IF EXISTS public.user_dm_keyvault;
DROP TABLE IF EXISTS public.user_conversation_states;
DROP TABLE IF EXISTS public.user_api_keys;
DROP TABLE IF EXISTS public.user_alias;
DROP TABLE IF EXISTS public."user";
DROP SEQUENCE IF EXISTS public.user_id_seq;
@@ -588,7 +593,7 @@ CREATE TABLE public.comment_files (
id integer NOT NULL,
comment_id integer,
user_id integer NOT NULL,
dest character varying(40) NOT NULL,
dest character varying(60) NOT NULL,
mime character varying(100) NOT NULL,
size integer NOT NULL,
checksum character varying(255) NOT NULL,
@@ -600,6 +605,8 @@ CREATE TABLE public.comment_files (
ALTER TABLE public.comment_files OWNER TO f0ckm;
ALTER TABLE ONLY public.comment_files REPLICA IDENTITY DEFAULT;
--
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
--
@@ -820,12 +827,25 @@ CREATE TABLE public.invite_tokens (
used_by integer,
is_used boolean DEFAULT false,
created_by_discord character varying(255) DEFAULT NULL::character varying,
created_by_matrix character varying(255) DEFAULT NULL::character varying
created_by_matrix character varying(255) DEFAULT NULL::character varying,
used_at timestamp with time zone DEFAULT NULL
);
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
--
-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by);
--
-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true;
--
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
--
@@ -869,7 +889,7 @@ ALTER SEQUENCE public.items_id_seq OWNER TO f0ckm;
CREATE TABLE public.items (
id integer DEFAULT nextval('public.items_id_seq'::regclass) NOT NULL,
src character varying(255) NOT NULL,
dest character varying(40) NOT NULL,
dest character varying(60) NOT NULL,
mime character varying(100) NOT NULL,
size integer NOT NULL,
checksum character varying(255) NOT NULL,
@@ -887,7 +907,10 @@ CREATE TABLE public.items (
is_pinned boolean DEFAULT false,
is_oc boolean DEFAULT false,
xd_score integer DEFAULT 0 NOT NULL,
original_filename text
original_filename text,
title text,
width integer,
height integer
);
@@ -1326,6 +1349,21 @@ CREATE TABLE public."user" (
ALTER TABLE public."user" OWNER TO f0ckm;
--
-- Name: user_api_keys; Type: TABLE; Schema: public; Owner: f0ckm
--
CREATE TABLE public.user_api_keys (
user_id integer NOT NULL,
api_key text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_api_keys_pkey PRIMARY KEY (user_id),
CONSTRAINT user_api_keys_api_key_key UNIQUE (api_key)
);
ALTER TABLE public.user_api_keys OWNER TO f0ckm;
--
-- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm
--
@@ -1455,6 +1493,7 @@ CREATE TABLE public.user_options (
hide_koepfe boolean DEFAULT false NOT NULL,
language text,
use_alternative_infobox boolean DEFAULT false,
use_alternative_steuerung boolean DEFAULT NULL,
receive_system_notifications boolean DEFAULT true,
receive_user_notifications boolean DEFAULT true,
do_not_disturb boolean DEFAULT false,
@@ -1586,6 +1625,13 @@ ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.au
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
--
-- Name: comment_files id; Type: DEFAULT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files ALTER COLUMN id SET DEFAULT nextval('public.comment_files_id_seq'::regclass);
--
-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: f0ckm
--
@@ -1693,6 +1739,14 @@ ALTER TABLE ONLY public.comment_subscriptions
ADD CONSTRAINT comment_subscriptions_pkey PRIMARY KEY (user_id, item_id);
--
-- Name: comment_files comment_files_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files
ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id);
--
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
--
@@ -2241,6 +2295,13 @@ CREATE INDEX idx_user_alias_type ON public.user_alias USING btree (type);
CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid);
--
-- Name: idx_user_api_keys_api_key; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX idx_user_api_keys_api_key ON public.user_api_keys USING btree (api_key);
--
-- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm
--
@@ -2385,6 +2446,22 @@ ALTER TABLE ONLY public.comment_subscriptions
ADD CONSTRAINT comment_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: comment_files comment_files_comment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files
ADD CONSTRAINT comment_files_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE;
--
-- Name: comment_files comment_files_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files
ADD CONSTRAINT comment_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: comments comments_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
@@ -2721,6 +2798,14 @@ ALTER TABLE ONLY public.user_warnings
ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: user_api_keys user_api_keys_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.user_api_keys
ADD CONSTRAINT user_api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
--
@@ -2759,4 +2844,104 @@ CREATE INDEX IF NOT EXISTS idx_user_ips_user_id ON user_ips(user_id);
-- Add IP tracking to user_sessions for "current" IP view
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT;
-- DM encrypted attachments (Migration 005)
CREATE TABLE IF NOT EXISTS public.dm_attachments (
id bigserial PRIMARY KEY,
sender_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
recipient_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
iv text NOT NULL,
file_path text NOT NULL,
original_name text NOT NULL DEFAULT '',
mime_hint text NOT NULL DEFAULT '',
size_bytes integer NOT NULL DEFAULT 0,
created_at timestamp with time zone DEFAULT now(),
expires_at timestamp with time zone DEFAULT (now() + interval '90 days')
);
CREATE INDEX IF NOT EXISTS idx_dm_att_sender ON public.dm_attachments(sender_id);
CREATE INDEX IF NOT EXISTS idx_dm_att_recipient ON public.dm_attachments(recipient_id);
CREATE INDEX IF NOT EXISTS idx_dm_att_expires ON public.dm_attachments(expires_at);
-- DM message edit/delete support (Migration 006)
ALTER TABLE private_messages
ADD COLUMN IF NOT EXISTS edited_at timestamp with time zone DEFAULT NULL;
-- Wordfilter Table (Migration 009)
CREATE TABLE IF NOT EXISTS public.wordfilter (
id SERIAL PRIMARY KEY,
word VARCHAR(255) UNIQUE NOT NULL,
replacement VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Comment Polls
CREATE TABLE IF NOT EXISTS public.comment_polls (
id SERIAL PRIMARY KEY,
comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE,
question TEXT NOT NULL,
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
UNIQUE(comment_id)
);
CREATE TABLE IF NOT EXISTS public.comment_poll_options (
id SERIAL PRIMARY KEY,
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
text TEXT NOT NULL,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS public.comment_poll_votes (
id SERIAL PRIMARY KEY,
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
option_id INTEGER NOT NULL REFERENCES public.comment_poll_options(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(poll_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_comment_polls_comment_id ON public.comment_polls(comment_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_options_poll ON public.comment_poll_options(poll_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_poll ON public.comment_poll_votes(poll_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_user ON public.comment_poll_votes(user_id);
-- Sticker Packs (Migration 010)
-- sticker_packs table: holds imported Telegram (or custom) sticker pack metadata
CREATE TABLE IF NOT EXISTS public.sticker_packs (
id serial PRIMARY KEY,
name text NOT NULL,
tg_name text UNIQUE,
tg_title text,
thumb_url text,
sticker_count integer DEFAULT 0,
created_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
-- Link custom_emojis to a sticker pack (NULL = standalone emoji)
ALTER TABLE public.custom_emojis
ADD COLUMN IF NOT EXISTS pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
-- Ensure thumb_url exists even on databases created before this column was added
ALTER TABLE public.sticker_packs
ADD COLUMN IF NOT EXISTS thumb_url text;
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
-- User Emoji Favorites (Migration 011)
-- Persists per-user sticker/emoji favorites for cross-device sync
CREATE TABLE IF NOT EXISTS public.user_emoji_favorites (
user_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
emoji_name text NOT NULL,
emoji_url text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, emoji_name)
);
ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,13 @@
@font-face {
font-family: 'Impact';
src: url('/s/fonts/impact.woff') format('woff');
font-weight: normal;
}
@font-face {
font-family: 'Impact';
src: url('/s/fonts/impact.woff') format('woff');
font-weight: bold;
}
/* Meme Creator Styles */
@@ -185,6 +192,8 @@ canvas#memeCanvas {
.meme-layout-wrapper input[type="range"] {
width: 100%;
accent-color: var(--accent, #9f0);
touch-action: pan-x;
cursor: pointer;
}
.meme-layout-wrapper .layer-input-group {
@@ -234,7 +243,7 @@ canvas#memeCanvas {
.meme-editor-layout {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 0.6fr 1fr !important;
grid-template-rows: 0.6fr auto !important;
gap: 20px;
}
.meme-controls {
@@ -260,3 +269,45 @@ canvas#memeCanvas {
gap: 10px;
}
}
/* Custom Template Card & Drag Hover Styles */
.custom-template-card .template-image-wrapper {
transition: background-color 0.2s ease;
}
.custom-template-card:hover .template-image-wrapper {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.custom-template-card i {
transition: transform 0.2s ease, color 0.2s ease;
}
.custom-template-card:hover i {
transform: scale(1.1);
}
.canvas-wrapper.drag-hover {
border-color: var(--accent, #9f0) !important;
box-shadow: 0 0 25px rgba(159, 255, 0, 0.4) !important;
}
/* Sidebar space reservation for meme pages */
.meme-layout-wrapper {
width: 100%;
transition: padding-right 0.3s ease-in-out;
}
@media (min-width: 1200px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
.meme-layout-wrapper {
padding-right: 300px;
}
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0;
}
}

View File

@@ -118,7 +118,7 @@
flex-direction: column;
/* Stacked */
align-items: center;
gap: 1rem;
gap: 0.3rem;
width: 100%;
}
@@ -219,19 +219,13 @@
.upload-form:not(.shitpost-mode-active) .preview-media-small {
width: 100%;
max-width: 400px;
min-height: auto;
height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
}
.upload-form.shitpost-mode-active .preview-media-small {
width: 120px;
height: 80px;
object-fit: contain;
min-height: auto;
}
.preview-media-small {
flex-shrink: 0;
@@ -247,7 +241,7 @@
align-items: center;
}
.upload-form.shitpost-mode-active .file-meta-row-small {
.upload-form.shitpost-mode-active .file-name-small {
padding-right: 30px; /* Space for X button */
}
@@ -255,8 +249,9 @@
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible;
min-width: 0;
width: 100%;
}
.file-info-small {
@@ -403,56 +398,71 @@
color: rgba(255, 255, 255, 0.4);
transition: all 0.2s;
text-transform: uppercase;
user-select: none;
}
.item-rating-option input:checked + .item-rating-label.sfw {
background: #40c057;
background: var(--badge-sfw);
color: #fff;
border-color: #40c057;
border-color: var(--badge-sfw);
}
.item-rating-option input:checked + .item-rating-label.nsfw {
background: #fd7e14;
background: var(--badge-nsfw);
color: #fff;
border-color: #fd7e14;
border-color: var(--badge-nsfw);
}
.item-rating-option input:checked + .item-rating-label.nsfl {
background: #fa5252;
background: var(--badge-nsfl);
color: #fff;
border-color: #fa5252;
border-color: var(--badge-nsfl);
}
.item-rating-label:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Bigger Previews in Shitpost Mode */
.file-preview-item {
.upload-form.shitpost-mode-active .file-preview-item {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 15px;
gap: 0;
gap: 16px;
width: 100%;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.preview-media-small {
.upload-form.shitpost-mode-active .preview-media-small {
flex: 0 0 50%;
width: 50% !important;
height: auto !important;
min-height: 120px;
max-height: 350px;
max-width: 50% !important;
height: 240px !important;
max-height: 240px !important;
object-fit: contain;
border-radius: 8px;
background: rgba(0,0,0,0.3);
overflow: hidden; /* Clip the video — Chrome cannot expand past this */
}
/* Inner video inside the wrapper fills the container and cannot resize it */
.upload-form.shitpost-mode-active .preview-video-wrapper > video {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
display: block;
object-fit: contain;
}
/* Per-item Tags */
.item-tags-container {
margin-top: 10px;
width: 100%;
position: relative;
}
.item-tags-list {
@@ -549,6 +559,34 @@
border-color: var(--accent);
}
.item-title-container {
margin-top: 6px;
width: 100%;
}
.item-title-input {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 5px 10px;
color: #fff;
font-size: 0.8rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.item-title-input::placeholder {
color: rgba(255,255,255,0.3);
font-style: italic;
}
.item-title-input:focus {
border-color: var(--accent);
}
.rating-options {
display: flex;
gap: 1rem;
@@ -676,6 +714,29 @@
outline: none;
}
/* Global title input (normal mode) — matches tag-input-container style */
.upload-title-input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0;
padding: 0.5rem;
color: inherit;
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.upload-title-input:focus {
border-color: var(--accent, #7c5cbf);
}
.upload-title-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.tag-count {
font-weight: normal;
font-size: 0.85rem;
@@ -735,9 +796,9 @@
.upload-form .tag-suggestions {
position: absolute !important;
min-width: 220px !important;
max-width: 320px !important;
max-height: 260px !important;
min-width: 220px;
max-width: 320px;
max-height: 260px;
overflow-y: auto !important;
background: var(--dropdown-bg, #1a1a1a) !important;
border: 1px solid var(--black, #000) !important;
@@ -750,9 +811,7 @@
scrollbar-width: thin !important;
}
.upload-form.shitpost-mode-active .tag-suggestions {
position: relative !important;
}
@keyframes tagDropIn {
from {
@@ -1243,7 +1302,7 @@
flex-shrink: 0;
}
@media (max-width: 600px) {
@media (max-width: 768px) {
.upload-form.shitpost-mode-active .file-preview-item {
flex-direction: column;
align-items: stretch;
@@ -1253,6 +1312,7 @@
.upload-form.shitpost-mode-active .preview-media-small,
.upload-form.shitpost-mode-active .item-media-col {
width: 100% !important;
max-width: 100% !important;
flex: 0 0 auto;
min-height: auto;
}
@@ -1260,4 +1320,341 @@
.upload-form.shitpost-mode-active .item-media-col iframe {
height: 200px;
}
/* Keep suggestions visible and position them above the tag input */
.upload-form.shitpost-mode-active .file-meta-row-small {
overflow: visible !important;
}
.upload-form.shitpost-mode-active .tag-suggestions {
position: absolute !important;
bottom: 100% !important;
top: auto !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
max-width: none !important;
margin-top: 0 !important;
margin-bottom: 8px !important;
z-index: 200000 !important;
}
}
/* Video thumbnail loading animation */
.video-thumbnail-loading {
animation: thumbPulse 1.5s ease-in-out infinite;
background: rgba(255, 255, 255, 0.05);
}
@keyframes thumbPulse {
0% { opacity: 0.4; }
50% { opacity: 0.8; }
100% { opacity: 0.4; }
}
/* ── Ruffle (Flash) Upload Preview ─────────────────────────────────────────── */
/* The container: CSS Grid with 3 rows — player | snapshot preview | button.
All rows are auto so the container grows naturally; no overflow clipping. */
.swf-upload-preview {
position: relative;
display: grid;
grid-template-rows: auto auto auto;
max-height: none !important;
background: #000;
border-radius: 8px;
}
/* Normal (non-shitpost) mode: full-width, player drives container height via aspect-ratio */
.upload-form:not(.shitpost-mode-active) .swf-upload-preview.preview-media-small {
width: 100% !important;
height: auto !important;
max-height: none !important;
min-height: auto !important;
}
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-player,
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-object {
aspect-ratio: 16 / 9;
width: 100% !important;
height: auto !important;
}
/* Shitpost mode: player row is a fixed height so the snapshot row doesn't steal its space */
.upload-form.shitpost-mode-active .swf-upload-preview.preview-media-small {
width: 45% !important;
flex: 0 0 45% !important;
height: auto !important;
max-height: none !important;
min-height: auto !important;
aspect-ratio: unset;
}
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-player,
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-object {
width: 100% !important;
height: 220px !important;
min-height: 220px;
}
/* Placeholder shown while Ruffle is loading */
.swf-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 200px;
background: rgba(0, 0, 0, 0.6);
animation: thumbPulse 1.8s ease-in-out infinite;
}
.swf-upload-placeholder-icon {
font-size: 2.5rem;
line-height: 1;
filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.7));
}
.swf-upload-placeholder-text {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
}
/* ── Ruffle Snapshot Button ─────────────────────────────────────────────────── */
/* The snapshot button lives inside .swf-upload-preview, pinned to the bottom */
.swf-upload-preview {
position: relative;
display: flex;
flex-direction: column;
}
.btn-ruffle-snapshot {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 0;
padding: 8px 16px;
background: rgba(255, 200, 0, 0.08);
border: none;
border-top: 1px solid rgba(255, 200, 0, 0.2);
border-radius: 0 0 6px 6px;
color: rgba(255, 200, 0, 0.9);
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.btn-ruffle-snapshot:hover:not(:disabled) {
background: rgba(255, 200, 0, 0.15);
border-top-color: rgba(255, 200, 0, 0.4);
color: #ffd700;
}
.btn-ruffle-snapshot:active:not(:disabled) {
background: rgba(255, 200, 0, 0.22);
}
.btn-ruffle-snapshot:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Snapshot preview: sits in its own grid row, below the player */
.swf-upload-preview .ruffle-snapshot-preview {
display: block;
width: 100%;
height: auto;
object-fit: contain;
object-position: center;
background: rgba(0, 0, 0, 0.6);
border-top: 1px solid rgba(81, 207, 102, 0.3);
animation: snapPreviewIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes snapPreviewIn {
from { opacity: 0; transform: scale(0.96) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* =============================================
URL Upload Tracker — single persistent panel
============================================= */
#url-upload-tracker {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 9998;
width: 320px;
background: var(--nav-bg, #1c1c1c);
border: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.15);
overflow: hidden;
font-size: 0.8rem;
opacity: 0;
transform: translateY(10px);
transition: opacity 1.5s ease, transform 1.5s ease;
pointer-events: none;
}
#url-upload-tracker.uut-visible {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
/* ── Header ──────────────────────────────────────── */
.uut-header {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 10px 8px 12px;
background: rgba(var(--accent-rgb, 120,120,120), 0.07);
border-bottom: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.1);
user-select: none;
}
.uut-header > .fa-solid {
color: var(--accent);
font-size: 11px;
opacity: 0.75;
flex-shrink: 0;
}
.uut-title {
flex: 1;
font-size: 0.74rem;
font-weight: 600;
color: var(--white, #fff);
letter-spacing: 0.01em;
}
.uut-badge {
background: var(--accent);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.uut-actions { display: flex; gap: 2px; }
.uut-btn {
background: none;
border: none;
cursor: pointer;
color: var(--white, #fff);
opacity: 0.3;
padding: 3px 6px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
transition: opacity 0.15s, background 0.15s;
}
.uut-btn:hover { opacity: 0.8; background: rgba(var(--accent-rgb,120,120,120),0.12); }
/* ── Body (scrollable) ───────────────────────────── */
.uut-body {
max-height: 340px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(var(--accent-rgb,120,120,120), 0.25) transparent;
}
/* ── Active job rows ─────────────────────────────── */
.uut-job {
padding: 8px 12px 6px;
border-bottom: 1px solid rgba(var(--accent-rgb,120,120,120), 0.06);
transition: opacity 0.3s;
}
.uut-job--fading { opacity: 0; }
.uut-job-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.uut-spinner {
width: 11px;
height: 11px;
flex-shrink: 0;
margin-top: 3px;
border: 1.5px solid rgba(var(--accent-rgb,120,120,120), 0.18);
border-top-color: var(--accent);
border-radius: 50%;
animation: uutSpin 0.75s linear infinite;
}
@keyframes uutSpin { to { transform: rotate(360deg); } }
.uut-job-info { flex: 1; min-width: 0; }
.uut-url {
display: block;
font-size: 0.72rem;
color: var(--white, #fff);
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uut-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.uut-stage {
font-size: 0.71rem;
color: var(--accent);
white-space: nowrap;
}
.uut-speed {
font-size: 0.67rem;
color: var(--white, #fff);
opacity: 0.38;
margin-left: auto;
white-space: nowrap;
}
/* Progress bar */
.uut-bar {
height: 2px;
background: rgba(var(--accent-rgb,120,120,120), 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 6px;
}
.uut-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.65s ease;
background-image: linear-gradient(90deg, var(--accent) 0%, rgba(255,255,255,0.4) 50%, var(--accent) 100%);
background-size: 200% 100%;
animation: uutShimmer 1.6s linear infinite;
}
@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }

View File

@@ -138,13 +138,11 @@
transform: translateY(100%) translateY(-3px);
}
.v0ck:hover .v0ck_player_controls,
.v0ck.v0ck_hover .v0ck_player_controls,
.v0ck.v0ck_swf_active .v0ck_player_controls {
transform: translateY(0);
}
.v0ck:hover .v0ck_progress,
.v0ck.v0ck_hover .v0ck_progress,
.v0ck.v0ck_swf_active .v0ck_progress {
height: 8px;
@@ -434,8 +432,9 @@
padding: 0 3px;
text-shadow: none;
}
/* Hide images (emojis, inline imgs) inside unrevealed spoiler */
.danmaku-pill .dpill-spoiler img {
/* Hide images/videos (emojis, inline imgs, webm stickers) inside unrevealed spoiler */
.danmaku-pill .dpill-spoiler img,
.danmaku-pill .dpill-spoiler video {
opacity: 0;
transition: opacity 0.15s ease;
}
@@ -448,7 +447,9 @@
-1px 1px 0 #000, 1px 1px 0 #000;
}
.danmaku-pill .dpill-spoiler:hover img,
.danmaku-pill .dpill-spoiler.revealed img {
.danmaku-pill .dpill-spoiler.revealed img,
.danmaku-pill .dpill-spoiler:hover video,
.danmaku-pill .dpill-spoiler.revealed video {
opacity: 1;
}
@@ -512,3 +513,38 @@
from { transform: translateX(calc(100vw + 100%)); }
to { transform: translateX(calc(-100% - 200px)); }
}
/* Speedup 2x HUD Pill */
.v0ck_speed_indicator {
position: absolute;
top: 20px;
left: 50%;
transform: translate(-50%, -10px);
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #fff;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.v0ck_speed_indicator:not(.v0ck_hidden) {
opacity: 1;
transform: translate(-50%, 0);
}
/* Hide mouse cursor on inactivity in fullscreen */
.v0ck.v0ck_fullscreen:not(.v0ck_hover),
.v0ck.v0ck_fullscreen:not(.v0ck_hover) * {
cursor: none !important;
}

BIN
public/s/img/archive.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
<defs>
<symbol id="heart_regular" viewBox="0 0 512 512"><path d="M458.4 64.3C400.6 15.7 311.3 23 256 79.3 200.7 23 111.4 15.6 53.6 64.3-21.6 127.6-10.6 230.8 43 285.5l175.4 178.7c10 10.2 23.4 15.9 37.6 15.9 14.3 0 27.6-5.6 37.6-15.8L469 285.6c53.5-54.7 64.7-157.9-10.6-221.3zm-23.6 187.5L259.4 430.5c-2.4 2.4-4.4 2.4-6.8 0L77.2 251.8c-36.5-37.2-43.9-107.6 7.3-150.7 38.9-32.7 98.9-27.8 136.5 10.5l35 35.7 35-35.7c37.8-38.5 97.8-43.2 136.5-10.6 51.1 43.1 43.5 113.9 7.3 150.8z"/></symbol>
<symbol id="heart_solid" viewBox="0 0 512 512"><path d="M462.3 62.6C407.5 15.9 326 24.3 275.7 76.2L256 96.5l-19.7-20.3C186.1 24.3 104.5 15.9 49.7 62.6c-62.8 53.6-66.1 149.8-9.9 207.9l193.5 199.8c12.5 12.9 32.8 12.9 45.3 0l193.5-199.8c56.3-58.1 53-154.3-9.8-207.9z"/></symbol>
<symbol id="cross" viewBox="0 0 512 512"><path d="M53.6,62.3 L458.4,458.4 M458.4,62.3 L53.6,458.4" style="stroke-linecap: round;stroke-width: 60;"/></symbol>
<symbol id="window-maximize" viewBox="0 0 512 512"><path d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm0 394c0 3.3-2.7 6-6 6H54c-3.3 0-6-2.7-6-6V192h416v234z" style="fill: var(--maximize_button);"/></symbol>
<symbol id="window-minimize" viewBox="0 0 512 512"><path d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zm-96 464H48V256h320v208zm96-96h-48V144c0-26.5-21.5-48-48-48H144V48h320v320z" style="fill: var(--maximize_button);"/></symbol>
<symbol id="plus" viewBox="0 0 512 512"><path d="M256 80c0-8.84-7.16-16-16-16s-16 7.16-16 16v160H64c-8.84 0-16 7.16-16 16s7.16 16 16 16h160v160c0 8.84 7.16 16 16 16s16-7.16 16-16V272h160c8.84 0 16-7.16 16-16s-7.16-16-16-16H272V80z"/></symbol>
<symbol id="tag" viewBox="0 0 512 512"><path d="M0 252.118V48C0 21.49 21.49 0 48 0h204.118a48 48 0 0 1 33.941 14.059l211.882 211.882c18.745 18.745 18.745 49.137 0 67.882L293.823 497.941c-18.745 18.745-49.137 18.745-67.882 0L14.059 286.059A48 48 0 0 1 0 252.118zM112 64a48 48 0 1 0 48 48 48.055 48.055 0 0 0-48-48z"/></symbol>
<symbol id="shield" viewBox="0 0 512 512"><path d="M466.5 83.71c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91a27.3 27.3 0 0 1-31.4-18.41V28c0-15.46-12.54-28-28-28h-74.8c-15.46 0-28 12.54-28 28v37.39a27.3 27.3 0 0 1-31.4 18.41c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91A27.3 27.3 0 0 1 12 102.12V256c0 141.38 102.34 230.13 234.33 254.12a27.5 27.5 0 0 0 9.34 0C387.66 486.13 490 397.38 490 256V102.12a27.3 27.3 0 0 1-23.5-18.41z"/></symbol>
<symbol id="bell_solid" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z"/></symbol>
<symbol id="bell_regular" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM44.75 330.4C63.2 312 96 281.3 96 208c0-61.94 43.15-112 112-112s112 50.06 112 112c0 73.3 32.8 104 51.25 122.4 2.8 2.8 3.5 6.9 1.75 10.4s-5.4 5.6-9.15 5.6H52.1c-3.75 0-7.35-2.1-9.1-5.6s-1.05-7.6 1.75-10.4z"/></symbol>
<symbol id="pin_solid" viewBox="0 0 24 24"><path d="M12 17h-7v-1.76a2 2 0 0 1 1.11-1.79l1.78-.9a2 0 0 0 1.11-1.76v-4.79a3 3 0 0 1 3-3 3 3 0 0 1 3 3v4.76a2 2 0 0 0 1.11 1.79l1.78.9a2 0 0 1 1.11 1.79v1.76h-7v5z" fill="currentColor" transform="rotate(45 12 12)"/></symbol>
<symbol id="pin_regular" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"></line><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path></g></symbol>
<symbol id="building" viewBox="0 0 512 512"><path d="M432 64H80c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16zm-32 352H112V96h288v320zM192 288h128v32H192zm0-64h128v32H192zm0-64h128v32H192z" fill="currentColor"/></symbol>
<symbol id="arrow-down" viewBox="0 0 448 512"><path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" fill="currentColor"/></symbol>
<symbol id="arrow-up" viewBox="0 0 448 512"><path d="M34.9 289.5l-22.2-22.2c-9.4-9.4-9.4-24.6 0-33.9L207 39c9.4-9.4 24.6-9.4 33.9 0l194.3 194.3c9.4 9.4 9.4 24.6 0 33.9l-22.2 22.2c-9.5 9.5-25 9.3-34.3-.4L264 168.6V456c0 13.3-10.7 24-24 24h-32c-13.3 0-24-10.7-24-24V168.6L69.2 289.1c-9.3 9.8-24.8 10-34.3.4z" fill="currentColor"/></symbol>
<symbol id="star_regular" viewBox="0 0 576 512"><path d="M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 137.4L288 384.8l-124.3 64.9 23.7-137.4L87.5 214.4l138-20.1L288 69.1l62.5 125.2 138 20.1-99.9 97.9z" fill="currentColor"/></symbol>
<symbol id="star_solid" viewBox="0 0 576 512"><path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z" fill="currentColor"/></symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -60,7 +60,7 @@
a.textContent = tag.tag;
const span = document.createElement("span");
span.classList.add("badge", "mr-2");
span.classList.add("badge");
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
span.classList.add('new-tag-glow');
}
@@ -155,7 +155,13 @@
postid,
existingTags: tags,
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
});
};
@@ -199,7 +205,6 @@
a.appendChild(img);
favcontainer.appendChild(a);
favcontainer.appendChild(document.createTextNode('\u00A0'));
});
favcontainer.hidden = false;
} else {
@@ -379,40 +384,91 @@
window.adminSetPassword = async (btn) => {
const id = btn.dataset.id;
const name = btn.dataset.name;
const password = prompt(`Enter new password for ${name} (min 20 chars):`);
if (!password) return;
if (password.length < 20) return alert('Password must be at least 20 characters.');
if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
try {
const hint =
'Set a new password for <strong>' + escHTML(name) + '</strong>. Must be at least 20 characters.<br><br>' +
'<input type="password" id="admin-pw-new" class="input" placeholder="New password (min 20 chars)" style="width:100%;margin-bottom:8px;" autocomplete="new-password">' +
'<input type="password" id="admin-pw-confirm" class="input" placeholder="Confirm new password" style="width:100%;" autocomplete="new-password">';
ModAction.confirm('Set Password', hint, async () => {
const password = document.getElementById('admin-pw-new')?.value || '';
const confirm = document.getElementById('admin-pw-confirm')?.value || '';
if (password.length < 20) throw new Error('Password must be at least 20 characters.');
if (password !== confirm) throw new Error('Passwords do not match.');
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
if (data.success) {
alert(data.msg);
showFlash(data.msg, 'success');
} else {
alert(data.msg || 'Failed to set password');
throw new Error(data.msg || 'Failed to set password');
}
} catch (err) {
alert('Network error');
}, { hideReason: true, confirmText: 'Set Password', unsafeContent: true });
};
window.adminRenameUser = async (btn) => {
const id = btn.dataset.id;
const currentName = btn.dataset.name;
const currentUsername = btn.dataset.username;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
ModAction.confirm(
'Rename User',
'Enter a new login name for <strong>' + escHTML(currentName) + '</strong>.<br>' +
'<small style="color:#888;">Current login: <code>' + escHTML(currentUsername) + '</code> — All uploads will be reassigned. User sessions will be invalidated. And the user has to login with the NEW name from now on.</small>',
async (newUsername) => {
const data = await post('/api/v2/admin/users/rename', { user_id: id, new_username: newUsername });
if (data.success) {
showFlash(data.msg, 'success');
// Update the row in-place: links, text, and all button data attributes
const row = document.getElementById('user-row-' + id);
if (row) {
// Update the name link
const link = row.querySelector('.user-info-cell a');
if (link) {
link.href = '/user/' + data.new_login;
// Only overwrite text if there's no display_name (plain username link)
if (!link.querySelector('span[style*="accent"]')) {
link.textContent = data.new_user;
}
}
// Update all buttons in the row with the new name/username
row.querySelectorAll('[data-username]').forEach(el => { el.dataset.username = data.new_login; });
row.querySelectorAll('[data-name]').forEach(el => { el.dataset.name = data.new_user; });
// Update activity stat links
row.querySelectorAll('a[href^="/user/"]').forEach(a => {
a.href = a.href.replace(/\/user\/[^/]+/, '/user/' + data.new_login);
});
}
} else {
throw new Error(data.msg || 'Rename failed');
}
},
{ hideReason: false, singleLine: true, confirmText: 'Rename', placeholder: 'new username' }
);
};
window.adminDeleteUser = async (btn) => {
const id = btn.dataset.id;
const name = btn.dataset.name;
if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return;
try {
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
ModAction.confirm(
'Delete User',
'<strong style="color:#d9534f">CRITICAL ACTION</strong>: Permanently delete user <strong>' + escHTML(name) + '</strong>?<br><br>All their uploads and comments will be reassigned to <code>deleted_user</code>. <strong>This cannot be undone.</strong>',
async () => {
const data = await post('/api/v2/admin/users/delete', { user_id: id });
if (data.success) {
alert(data.msg);
showFlash(data.msg, 'success');
document.getElementById(`user-row-${id}`)?.remove();
} else {
alert(data.msg || 'Failed to delete user');
}
} catch (err) {
alert('Network error');
throw new Error(data.msg || 'Failed to delete user');
}
},
{ hideReason: true, confirmText: 'Delete User' }
);
};
window.adminResetLoginAttempts = async (btn) => {
@@ -422,8 +478,13 @@
try {
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
if (data.success) {
alert(data.msg);
window.location.reload(); // Quickest way to refresh badges
showFlash(data.msg, 'success');
// Remove the failed attempt badges and reset button from the row in-place
const row = btn.closest('tr');
if (row) {
row.querySelectorAll('.status-badge[style*="ffcc00"], .status-badge[style*="ff4d4d"]').forEach(el => el.remove());
}
btn.remove();
} else {
alert(data.msg || 'Failed to reset attempts');
}
@@ -435,18 +496,22 @@
window.adminBulkDeleteHalls = async (btn) => {
const id = btn.dataset.id;
const name = btn.dataset.name;
if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return;
try {
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
ModAction.confirm(
'Delete All Halls',
'Permanently delete <strong>ALL halls</strong> for <strong>' + escHTML(name) + '</strong>? <strong>This cannot be undone.</strong>',
async () => {
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
if (data.success) {
alert(data.msg);
showFlash(data.msg, 'success');
} else {
alert(data.msg || 'Failed to delete halls');
}
} catch (err) {
alert('Network error');
throw new Error(data.msg || 'Failed to delete halls');
}
},
{ hideReason: true, confirmText: 'Delete Everything' }
);
};
window.adminReassignUploads = async (btn) => {
@@ -473,7 +538,7 @@
throw new Error(res.msg || 'Reassignment failed');
}
},
{ hideReason: false, confirmText: 'Reassign', placeholder: 'target username' }
{ hideReason: false, singleLine: true, confirmText: 'Reassign', placeholder: 'target username' }
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -533,7 +533,7 @@ class Danmaku {
const raw = msg.dataset.rawText;
if (!raw) return;
// Only re-render if the content is still plain text (no img children)
if (!msg.querySelector('img.dpill-emoji')) {
if (!msg.querySelector('.dpill-emoji')) {
msg.textContent = '';
msg.appendChild(this._renderContent(raw));
}
@@ -600,11 +600,25 @@ class Danmaku {
const code = match[3];
const url = emojiCache[code];
if (url) {
if (url.endsWith('.webm')) {
const vid = document.createElement('video');
vid.src = url;
vid.className = 'dpill-emoji';
vid.muted = true;
vid.loop = true;
vid.autoplay = true;
vid.playsInline = true;
vid.play().catch(() => {
vid.addEventListener('canplay', () => vid.play().catch(() => {}), { once: true });
});
parent.appendChild(vid);
} else {
const img = document.createElement('img');
img.src = url;
img.alt = `:${code}:`;
img.className = 'dpill-emoji';
parent.appendChild(img);
}
} else {
parent.appendChild(document.createTextNode(match[0]));
}

View File

@@ -46,11 +46,19 @@
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
window.addEventListener('dragenter', (e) => {
e.preventDefault();
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++;
dropOverlay.classList.add('active');
}
@@ -59,10 +67,17 @@
window.addEventListener('dragover', (e) => {
e.preventDefault();
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) => {
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--;
if (dragCounter <= 0) {
dragCounter = 0;
@@ -72,6 +87,8 @@
window.addEventListener('drop', (e) => {
if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('active');

File diff suppressed because it is too large Load Diff

View File

@@ -634,8 +634,12 @@
// Visual state: strike-through when disabled
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
// Handle floating badge visibility
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
// Handle floating badge visibility — only show as fallback when on an item page
// with the primary player present but no in-player SWF button (e.g. v0ck not loaded).
// Never show it just because sidebar .webm stickers exist.
const primaryVideo = document.getElementById('my-video') ||
document.querySelector('video.viewer, video.v0ck_video');
ui.floatingBadge.style.display = (swfButtons.length === 0 && !!primaryVideo) ? 'block' : 'none';
// Style both (if they exist)
[ui.floatingBadge, ...swfButtons].forEach(b => {

View File

@@ -23,6 +23,22 @@
let chatFocused = document.hasFocus();
const ytOembedCache = new Map(); // videoId → {title, author_name}
// Shared IntersectionObserver for lazy-loading embedded images.
// Images are rendered with data-lazy-src; this observer sets the real src
// when the image is within 200px of the visible scroll area.
const lazyImgObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
const src = img.dataset.lazySrc;
if (src) { img.src = src; delete img.dataset.lazySrc; }
lazyImgObserver.unobserve(img);
}
}, {
rootMargin: '200px', // start loading 200px before entering viewport
threshold: 0
});
function updateBadge() {
const badge = document.getElementById('gchat-badge');
const bubble = document.getElementById('gchat-reopen-bubble');
@@ -187,7 +203,7 @@
'gi'
);
html = html.replace(imageRegex, url =>
`<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
`<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
);
// 6b. Raw video URLs from allowed hosts → <video>
@@ -270,7 +286,7 @@
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
}
@@ -310,11 +326,73 @@
</div>`;
}
function scrollToBottom(force = false) {
function scrollToBottom(force = false, smooth = false) {
const el = document.getElementById('gchat-messages');
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (force || nearBottom) el.scrollTop = el.scrollHeight;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (!force && !nearBottom) return;
if (smooth) {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} else {
// Double rAF ensures layout is committed before reading scrollHeight
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
}
}
/**
* Watch the message container and keep snapping to bottom for durationMs.
* Only stops if the user actively scrolls up via wheel / touch / keyboard.
* Same logic as the DM snapToBottomSticky.
*/
function startStickyScroll(durationMs = 8000) {
const el = document.getElementById('gchat-messages');
if (!el) return;
let userScrolledUp = false;
const onWheel = (e) => { if (e.deltaY < 0) userScrolledUp = true; };
const onKey = (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) userScrolledUp = true; };
let touchStartY = 0;
const onTouchStart = (e) => { touchStartY = e.touches[0]?.clientY ?? 0; };
const onTouchMove = (e) => { if ((e.touches[0]?.clientY ?? 0) > touchStartY + 10) userScrolledUp = true; };
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('keydown', onKey, { passive: true });
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: true });
let ro;
const cleanup = () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('keydown', onKey);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
if (ro) ro.disconnect();
};
if (typeof ResizeObserver !== 'undefined') {
// Debounce: batch rapid layout changes (e.g. progressive image renders)
// into a single smooth scroll instead of many jarring instant jumps.
let debounceTimer = null;
ro = new ResizeObserver(() => {
if (userScrolledUp) { cleanup(); return; }
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, 80);
});
ro.observe(el);
} else {
setTimeout(() => scrollToBottom(true), 300);
setTimeout(() => scrollToBottom(true), 800);
setTimeout(() => scrollToBottom(true), 2000);
}
setTimeout(cleanup, durationMs);
// First snap is instant (no animation — the panel just opened)
scrollToBottom(true);
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
}
async function fetchYtOembed(cardEl) {
@@ -417,9 +495,19 @@
s.addEventListener('click', () => s.classList.toggle('revealed'));
});
// Embedded images: scroll to bottom when loaded + open modal on click
node.querySelectorAll('.gchat-embed-img img').forEach(img => {
img.addEventListener('load', () => scrollToBottom(scrollForce));
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
// Only snap to bottom on image load for NEW incoming messages, not history.
// History already scrolls once at the end of loadHistory; an extra scroll
// here is what causes the double jump.
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
img.addEventListener('click', () => openImgModal(img.src));
img.style.cursor = 'zoom-in';
lazyImgObserver.observe(img);
});
// Already-src'd images (avatars etc.) — same rule
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
img.addEventListener('click', () => openImgModal(img.src));
img.style.cursor = 'zoom-in';
});
@@ -444,11 +532,14 @@
const data = await res.json();
if (!data.success) return;
const container = document.getElementById('gchat-messages');
if (container) container.innerHTML = '';
(data.messages || []).forEach(m => appendMsg(m));
scrollToBottom(true);
// Also scroll after images have had time to paint
setTimeout(() => scrollToBottom(true), 600);
if (!container) return;
container.innerHTML = '';
(data.messages || []).forEach(m => appendMsg(m, false));
// Double rAF: wait for the browser to commit the layout (panel just became
// visible from display:none) before reading scrollHeight.
requestAnimationFrame(() => requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}));
} catch (e) {
console.error('[Chat] Failed to load history:', e);
}
@@ -552,7 +643,9 @@
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
if (!isMinimized) {
clearUnread();
loadHistory();
// Wait one rAF so the panel transitions from display:none to its full
// height before loadHistory measures scrollHeight.
requestAnimationFrame(() => loadHistory());
if (!window.matchMedia('(pointer: coarse)').matches)
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
}

View File

@@ -18,8 +18,69 @@
let draggingLayer = null;
let hoveredLayer = null;
let img = new Image();
let hasLoadedImage = window.memeTemplate.id !== 'custom' && window.memeTemplate.category !== 'Custom';
// Show the local file picker only when no pre-selected template exists
if (!hasLoadedImage) {
const customSelector = document.getElementById('customTemplateSelector');
if (customSelector) customSelector.style.display = '';
}
const memeFont = 'Impact, Charcoal, sans-serif';
const memeLetterSpacing = '0.03em';
function wrapText(ctx, text, maxWidth) {
const paragraphs = text.split('\n');
const lines = [];
paragraphs.forEach(paragraph => {
if (paragraph.trim() === '') {
lines.push('');
return;
}
const words = paragraph.split(' ').filter(w => w !== '');
let currentLine = '';
words.forEach(word => {
const testLine = currentLine ? currentLine + ' ' + word : word;
let metrics = ctx.measureText(testLine);
if (metrics.width <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine !== '') {
lines.push(currentLine);
currentLine = '';
}
metrics = ctx.measureText(word);
if (metrics.width <= maxWidth) {
currentLine = word;
} else {
let charLine = '';
for (let i = 0; i < word.length; i++) {
const char = word[i];
const testCharLine = charLine + char;
if (ctx.measureText(testCharLine).width > maxWidth && charLine !== '') {
lines.push(charLine);
charLine = char;
} else {
charLine = testCharLine;
}
}
currentLine = charLine;
}
}
});
if (currentLine) {
lines.push(currentLine);
}
});
return lines;
}
// Image Setup
img.crossOrigin = "anonymous";
@@ -29,11 +90,23 @@
const defaultSize = 40;
// Initial layers
// Initial layers - only set if we don't have any layers yet and we have loaded an image
if (textLayers.length === 0 && hasLoadedImage) {
textLayers = [
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
];
} else if (hasLoadedImage) {
// Keep the text layers but adjust their coordinates to be in-bounds if they exceed new boundaries
textLayers.forEach(layer => {
if (layer.x > canvas.width) {
layer.x = canvas.width / 2;
}
if (layer.y > canvas.height) {
layer.y = canvas.height - 100;
}
});
}
renderInputs();
draw();
@@ -42,11 +115,65 @@
// Ensure font is loaded before first draw
if (document.fonts) {
// Force browser to load the Impact font
document.fonts.load('bold 40px Impact');
document.fonts.load('bold 80px Impact');
document.fonts.load('bold 150px Impact');
document.fonts.ready.then(() => {
draw();
});
}
function createSlider(container, min, max, initValue, onChange) {
container.style.cssText = 'position:relative;height:28px;display:flex;align-items:center;flex:1;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;';
const track = document.createElement('div');
track.style.cssText = 'position:absolute;left:8px;right:8px;height:4px;background:#333;border-radius:2px;top:50%;transform:translateY(-50%);';
const fill = document.createElement('div');
fill.style.cssText = 'position:absolute;left:0;height:100%;background:var(--accent,#9f0);border-radius:2px;pointer-events:none;';
const thumb = document.createElement('div');
thumb.style.cssText = 'position:absolute;width:18px;height:18px;background:var(--accent,#9f0);border-radius:50%;top:50%;transform:translate(-50%,-50%);box-shadow:0 0 6px rgba(0,0,0,.6);pointer-events:none;transition:transform .1s;';
const setRatio = (r) => {
fill.style.width = (r * 100) + '%';
thumb.style.left = (r * 100) + '%';
};
setRatio((initValue - min) / (max - min));
track.appendChild(fill);
track.appendChild(thumb);
container.appendChild(track);
const valueFromClientX = (clientX) => {
const rect = track.getBoundingClientRect();
const r = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
setRatio(r);
return Math.round(min + r * (max - min));
};
container.addEventListener('pointerdown', (e) => {
e.preventDefault(); // block keyboard + scroll takeover
container.setPointerCapture(e.pointerId);
thumb.style.transform = 'translate(-50%,-50%) scale(1.25)';
onChange(valueFromClientX(e.clientX));
}, { passive: false });
container.addEventListener('pointermove', (e) => {
if (!container.hasPointerCapture(e.pointerId)) return;
onChange(valueFromClientX(e.clientX));
});
const onEnd = (e) => {
if (!container.hasPointerCapture(e.pointerId)) return;
container.releasePointerCapture(e.pointerId);
thumb.style.transform = 'translate(-50%,-50%) scale(1)';
};
container.addEventListener('pointerup', onEnd);
container.addEventListener('pointercancel', onEnd);
}
function renderInputs() {
layersContainer.innerHTML = '';
textLayers.forEach((layer, index) => {
@@ -64,7 +191,7 @@
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
<input type="range" class="layer-fs-input" min="10" max="200" value="${layer.fontSize}" style="flex: 1;">
<div class="layer-fs-slider" style="flex: 1;"></div>
</div>
`;
@@ -74,11 +201,11 @@
draw();
});
const fsInput = div.querySelector('.layer-fs-input');
const fsSlider = div.querySelector('.layer-fs-slider');
const fsVal = div.querySelector('.layer-fs-val');
fsInput.addEventListener('input', (e) => {
layer.fontSize = parseInt(e.target.value);
fsVal.textContent = layer.fontSize;
createSlider(fsSlider, 10, 200, layer.fontSize, (val) => {
layer.fontSize = val;
fsVal.textContent = val;
draw();
});
@@ -94,6 +221,10 @@
}
addTextBtn.addEventListener('click', () => {
if (!hasLoadedImage) {
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
return;
}
textLayers.push({
id: Date.now(),
text: 'NEW TEXT',
@@ -125,10 +256,11 @@
const fontSize = layer.fontSize || 40;
ctx.font = `bold ${fontSize}px ${memeFont}`;
ctx.letterSpacing = memeLetterSpacing;
let displayStr = layer.text.toUpperCase();
const lines = displayStr.split('\n');
const h = lines.length * fontSize * 1.1;
const lines = wrapText(ctx, displayStr, canvas.width * 0.9);
const h = lines.length * fontSize * 1.25;
const w = canvas.width * 0.9;
// Box for the dragged/hovered layer (top-most layer gets preference)
@@ -142,12 +274,13 @@
}
lines.forEach((line, i) => {
const yOffset = i * (fontSize * 1.1);
const yOffset = i * (fontSize * 1.25);
const renderX = Math.round(layer.x);
const renderY = Math.round(layer.y + yOffset);
ctx.save();
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
ctx.letterSpacing = memeLetterSpacing;
ctx.strokeStyle = 'black';
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
ctx.strokeText(line, renderX, renderY);
@@ -173,9 +306,13 @@
const isInsideText = (pt, layer) => {
if (!layer.text) return false;
const fontSize = layer.fontSize || 40;
const lines = layer.text.split('\n');
ctx.save();
ctx.font = `bold ${fontSize}px ${memeFont}`;
ctx.letterSpacing = memeLetterSpacing;
const lines = wrapText(ctx, layer.text.toUpperCase(), canvas.width * 0.9);
ctx.restore();
const w = canvas.width * 0.95;
const h = lines.length * fontSize * 1.2;
const h = lines.length * fontSize * 1.25;
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
@@ -232,16 +369,22 @@
canvas.addEventListener('mousedown', onStart);
// Upload
uploadBtn.addEventListener('click', async () => {
if (!hasLoadedImage) {
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
return;
}
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
const templateId = (window.memeTemplate && window.memeTemplate.id) ? window.memeTemplate.id.toLowerCase() : '';
const isOrakelVon10 = subCategory === 'von10';
const isOrakelUser = subCategory === 'user';
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10;
const isOrakelBingoApu = subCategory === 'bingoapu' || templateId === 'bingoapu';
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10 && !isOrakelBingoApu;
let uploadCanvas = canvas;
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
if (isOrakelNormal || isOrakelUser || isOrakelVon10 || isOrakelBingoApu) {
// Create an off-screen canvas to apply the orakel answer silently
uploadCanvas = document.createElement('canvas');
uploadCanvas.width = canvas.width;
@@ -252,6 +395,7 @@
uCtx.drawImage(canvas, 0, 0);
let result = '';
let selectedCorner = null;
if (isOrakelNormal) {
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
result = outcomes[Math.floor(Math.random() * outcomes.length)];
@@ -265,11 +409,35 @@
}
} else if (isOrakelVon10) {
result = Math.floor(Math.random() * 11).toString();
} else if (isOrakelBingoApu) {
const corners = [
{ x: 60, y: 100, label: 'YES' },
{ x: 573, y: 100, label: 'NO' },
{ x: 60, y: 615, label: 'NO' },
{ x: 573, y: 615, label: 'YES' }
];
selectedCorner = corners[Math.floor(Math.random() * corners.length)];
result = selectedCorner.label;
}
// Draw Orakel result on the hidden canvas
uCtx.save();
if (isOrakelBingoApu) {
// Draw "KOPS" in normal meme text style on the selected corner
uCtx.font = `bold 28px ${memeFont}`;
uCtx.letterSpacing = memeLetterSpacing;
uCtx.textAlign = 'center';
uCtx.textBaseline = 'middle';
uCtx.fillStyle = '#fff';
uCtx.strokeStyle = '#000';
uCtx.lineWidth = 4;
uCtx.miterLimit = 2;
uCtx.strokeText('KOPS', selectedCorner.x, selectedCorner.y);
uCtx.fillText('KOPS', selectedCorner.x, selectedCorner.y);
} else {
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
uCtx.letterSpacing = memeLetterSpacing;
uCtx.textAlign = 'center';
uCtx.textBaseline = 'middle';
@@ -312,11 +480,13 @@
let currentFontSize = 74;
uCtx.font = `bold ${currentFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
// First attempt: Shrink entire text on one line down to 58px if needed
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
currentFontSize -= 2;
uCtx.font = `bold ${currentFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
}
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
@@ -328,30 +498,36 @@
// Two lines — auto-fit just the name, ID below
let nameFontSize = 74;
uCtx.font = `bold ${nameFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
nameFontSize -= 2;
uCtx.font = `bold ${nameFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
}
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
const lineGap = Math.round(nameFontSize * 0.65);
const lineGap = Math.round(nameFontSize * 1.25);
const nameY = Math.round(yPos - lineGap / 2);
const idY = Math.round(yPos + lineGap / 2) + 2;
uCtx.font = `bold ${nameFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
uCtx.fillText(namePart, xPos, nameY);
if (idPart) {
uCtx.font = `bold ${idFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
uCtx.fillText(idPart, xPos, idY);
}
}
} else {
// Normal / von10 — single line as before
uCtx.font = `bold ${orakelFontSize}px Impact`;
uCtx.letterSpacing = memeLetterSpacing;
uCtx.strokeText(result, xPos, yPos);
uCtx.fillText(result, xPos, yPos);
}
}
uCtx.restore();
}
@@ -384,6 +560,14 @@
const result = await res.json();
if (result.success) {
if (result.manual_approval) {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
if (window.loadPageAjax) {
window.loadPageAjax('/');
} else {
window.location.href = '/';
}
} else {
const dest = result.redirect || '/meme';
if (window.loadItemAjax) {
window.loadItemAjax(dest);
@@ -393,6 +577,7 @@
window.location.href = dest;
}
}
}
else {
window.flashMessage('Error: ' + result.msg, 3000, 'error');
uploadBtn.disabled = false;
@@ -404,6 +589,79 @@
}
});
// Custom Local Image Selector logic
const fileInput = document.getElementById('customTemplateFile');
const selectFileBtn = document.getElementById('selectCustomFileBtn');
const canvasWrapper = document.querySelector('.canvas-wrapper');
const loadLocalImage = (file) => {
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
hasLoadedImage = true;
img.src = event.target.result;
// Update template metadata
window.memeTemplate.name = file.name.replace(/\.[^/.]+$/, "");
// Update header title dynamically
const headerTitle = document.querySelector('.meme-title');
if (headerTitle) {
const baseTitle = window.f0ckI18n?.meme?.create_meme || 'Create Meme:';
headerTitle.innerHTML = `${baseTitle} ${window.memeTemplate.name}`;
}
// Update tags input value if tags are present
const tagsInput = document.getElementById('tags');
if (tagsInput) {
tagsInput.value = `meme, Custom, ${window.memeTemplate.name}`;
}
};
reader.readAsDataURL(file);
}
};
if (selectFileBtn && fileInput) {
selectFileBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
loadLocalImage(file);
});
}
// HTML5 Drag & Drop Support
if (canvas && canvasWrapper) {
const preventDefaults = (e) => {
e.preventDefault();
e.stopPropagation();
};
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
canvasWrapper.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
canvasWrapper.addEventListener(eventName, () => {
canvasWrapper.classList.add('drag-hover');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
canvasWrapper.addEventListener(eventName, () => {
canvasWrapper.classList.remove('drag-hover');
}, false);
});
canvasWrapper.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const file = dt.files[0];
loadLocalImage(file);
}, false);
}
// Initial draw
setTimeout(draw, 300);
}

File diff suppressed because it is too large Load Diff

View File

@@ -209,6 +209,12 @@
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function decodeHtmlEntities(str) {
if (!str) return '';
const txt = document.createElement('textarea');
txt.innerHTML = String(str);
return txt.value;
}
function timeAgo(iso) {
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
const i = window.f0ckI18n || {};
@@ -229,28 +235,33 @@
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
}
function hashId() {
// Strip the leading '#' and allow numeric IDs or board/postid format
// Check path first /abyss/1234 or /abyss/gif/1234
const pathClean = location.pathname.replace(/\/$/, '');
const pathMatch = pathClean.match(/\/abyss\/([a-zA-Z0-9_\/-]+)$/);
if (pathMatch) return pathMatch[1];
// Fallback to hash
const raw = location.hash.replace(/^#/, '').trim();
if (/^\d+$/.test(raw)) return raw;
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
return '';
}
let lastPushedHash = location.hash;
let lastPushedUrl = location.pathname + location.hash;
function pushHash(id) {
if (!id) return;
const newHash = '#' + id;
if (newHash === lastPushedHash) return;
lastPushedHash = newHash;
history.pushState({ scrollerId: id }, '', '/abyss' + newHash);
const newUrl = '/abyss/' + id;
if (newUrl === lastPushedUrl) return;
lastPushedUrl = newUrl;
history.pushState({ scrollerId: id }, '', newUrl);
updateCacheActiveId(id);
}
// Handle back/forward within abyss — scroll to the target slide
window.addEventListener('popstate', (e) => {
if (!document.body.classList.contains('scroller-active')) return;
const id = e.state?.scrollerId || location.hash.replace('#', '');
const id = e.state?.scrollerId || hashId();
if (!id) return;
lastPushedHash = '#' + id;
lastPushedUrl = '/abyss/' + id;
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
if (slide) {
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -707,9 +718,12 @@
const id = contextLink.dataset.id;
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
if (target) {
// Clear any previous persistent highlight
commentsList.querySelectorAll('.comment-linked').forEach(el => el.classList.remove('comment-linked'));
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('highlight-comment');
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
// Flash the animation for attention, then keep the persistent highlight
target.classList.add('highlight-comment', 'comment-linked');
setTimeout(() => target.classList.remove('highlight-comment'), 2500);
}
}
});
@@ -1366,18 +1380,15 @@
${window.scrollerLoggedIn ? `
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
<span class="scroll-btn-label"></span>
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
</button>` : ''}
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
<span class="scroll-btn-label"></span>
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
</button>
${window.scrollerLoggedIn ? `
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
<span class="scroll-btn-label"></span>
</button>` : ''}
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
@@ -1385,7 +1396,7 @@
</button>
${item.is_external ? (
item.local_id
? `<a class="scroll-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
</a>`
@@ -1546,24 +1557,13 @@
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
if (rBadge) {
if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle');
rBadge.addEventListener('click', async e => {
rBadge.addEventListener('click', e => {
if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold
e.stopPropagation();
const slideEl = rBadge.closest('.scroll-slide');
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
try {
const resp = await fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
});
const data = await resp.json();
if (data.success) {
rBadge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
rBadge.textContent = data.rating_label;
rBadge.dataset.rating = data.rating_class;
}
} catch {}
cycleRatingOptimistic(rBadge, id, false);
});
}
@@ -1688,13 +1688,14 @@
avatar: '/a/default.png',
stamp: p.time,
timeago: timeAgo(p.time * 1000),
tags: `4chan, /${data.board}/`,
tags: ``,
is_video: isVideo,
is_image: isImage,
is_audio: false,
comment_count: p.replies || 0,
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged'),
original_filename: p.filename ? `${decodeHtmlEntities(p.filename)}${ext}` : null
};
})
.filter(Boolean);
@@ -1825,7 +1826,7 @@
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
const first = feed.querySelector('.scroll-slide:not([data-lock])');
const toActivate = target || first;
if (toActivate) setTimeout(() => { activateSlide(toActivate); hideLoader(); }, 200);
if (toActivate) setTimeout(() => { toActivate.scrollIntoView({ behavior: 'instant', block: 'start' }); activateSlide(toActivate); hideLoader(); }, 200);
}
} catch (err) {
console.error('[SCROLLER] Fetch error:', err);
@@ -1858,8 +1859,9 @@
body: new URLSearchParams({
url: item.external_media_url || item.dest,
rating: rating,
tags: '4chan',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`
tags: '',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {})
})
});
const data = await resp.json();
@@ -1885,7 +1887,7 @@
// Update button to link to the new site-internal post
setTimeout(() => {
btn.outerHTML = `
<a href="/${data.item_id}" target="_blank" class="scroll-btn success" style="text-decoration:none;">
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
<span class="scroll-btn-label">View</span>
</a>
@@ -1925,6 +1927,73 @@
}
}
function cycleRatingOptimistic(badge, id, showToast = false) {
if (!badge || !id || isNaN(id)) return;
let currentRating = badge.dataset.rating || '';
if (!currentRating) {
if (badge.classList.contains('sfw')) currentRating = 'sfw';
else if (badge.classList.contains('nsfw')) currentRating = 'nsfw';
else if (badge.classList.contains('nsfl')) currentRating = 'nsfl';
}
let nextRating = 'sfw';
if (currentRating === 'sfw') nextRating = 'nsfw';
else if (currentRating === 'nsfw') nextRating = 'nsfl';
const mapping = {
sfw: { label: 'SFW', cls: 'sfw', toast: '🛡 SFW' },
nsfw: { label: 'NSFW', cls: 'nsfw', toast: '🔥 NSFW' },
nsfl: { label: 'NSFL', cls: 'nsfl', toast: '💀 NSFL' }
};
const info = mapping[nextRating];
const oldClassName = badge.className;
const oldTextContent = badge.textContent;
const oldDatasetRating = badge.dataset.rating;
// Track active request ID to ignore out-of-order race conditions on rapid keypresses
const reqId = (badge._lastCycleReqId || 0) + 1;
badge._lastCycleReqId = reqId;
// Optimistically apply new state
badge.className = `scroll-rating ${info.cls}${window.scrollerIsMod ? ' can-cycle' : ''}`;
badge.textContent = info.label;
badge.dataset.rating = info.cls;
if (showToast) {
showShareToast(info.toast);
}
fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
})
.then(r => r.json())
.then(data => {
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
if (!data.success) {
revert();
return;
}
// Verify we match actual server result
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
badge.textContent = data.rating_label;
badge.dataset.rating = data.rating_class;
})
.catch(() => {
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
revert();
});
function revert() {
badge.className = oldClassName;
badge.textContent = oldTextContent;
badge.dataset.rating = oldDatasetRating;
showShareToast('⚠️ Failed to update rating');
}
}
function reloadFeed() {
clearCache();
@@ -2736,7 +2805,7 @@
avatar: null,
username: window.scrollerUsername,
display_name: displayName,
content: content,
content: data.comment?.content ?? content,
created_at: null
}, !!window.scrollerLoggedIn);
// Set avatar from global
@@ -3136,26 +3205,12 @@
else if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
if (!currentSlide || !window.scrollerLoggedIn) return;
const itemId = currentSlide.dataset.id;
const itemId = currentSlide.dataset.localId || currentSlide.dataset.id;
if (!itemId) return;
fetch(`/api/v2/tags/${itemId}/cycle-rating`, {
method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
})
.then(r => r.json())
.then(data => {
if (!data.success) return;
// Update the badge on the slide immediately
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
if (badge) {
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
badge.textContent = data.rating_label;
badge.dataset.rating = data.rating_class;
cycleRatingOptimistic(badge, itemId, true);
}
const labels = { sfw: '🛡 SFW', nsfw: '🔥 NSFW', nsfl: '💀 NSFL' };
showShareToast(labels[data.rating_class] ?? data.rating_label);
})
.catch(() => {});
}
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
@@ -3441,7 +3496,7 @@
// Tab type arrays
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
let sActiveTab = 'user';
let sCachedNotifs = [];
@@ -3545,6 +3600,9 @@
} else if (n.type === 'report') {
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
msg = i18n.notif_new_report || 'New user report';
} else if (n.type === 'warning') {
link = `/notifications?tab=system#notif-${n.id}`; user = i18n.notif_system || 'System';
msg = (i18n.account_warning && i18n.account_warning.title) || 'Account Warning';
} else {
link = `/${n.item_id}#c${n.reference_id}`;
if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you';
@@ -3552,8 +3610,13 @@
else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you';
else msg = i18n.notif_commented || 'commented';
}
let thumb;
if (n.type === 'warning') {
thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`;
} else {
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
const thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
}
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
${thumb}
<div class="notif-content">

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,19 @@
let currentPage = 1;
let hasMore = true;
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
let lastRenderedIds = ''; // track rendered comment IDs to skip needless re-renders
// Shared cache for activity across AJAX loads
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
const loadEmojis = async () => {
// Prefer CommentSystem's already-fetched cache to avoid a redundant request
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache)
|| null;
if (csCache && Object.keys(csCache).length > 0) {
Object.assign(customEmojis, csCache);
return;
}
// No shared cache yet — fetch directly
if (Object.keys(customEmojis).length > 0) return;
try {
const res = await fetch('/api/v2/emojis');
@@ -25,7 +34,11 @@
const renderEmoji = (match, name) => {
if (customEmojis[name]) {
return `<img class="sidebar-comment-img emoji" src="${customEmojis[name]}" alt="${name}" title=":${name}:" loading="lazy">`;
const url = customEmojis[name];
if (url.endsWith('.webm')) {
return `<video class="sidebar-comment-img emoji" src="${url}" title=":${name}:" autoplay loop muted playsinline></video>`;
}
return `<img class="sidebar-comment-img emoji" src="${url}" alt="${name}" title=":${name}:" loading="lazy">`;
}
return match;
};
@@ -37,9 +50,19 @@
return div.innerHTML;
};
const playSidebarEmojiVideos = (container) => {
if (!container) return;
container.querySelectorAll('video.emoji').forEach(v => {
v.play().catch(() => {
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
});
});
};
const ytOembedCache = new Map(); // videoId -> meta object
const ytOembedPending = new Map(); // videoId -> Promise
const fetchSidebarYoutubeTitles = async (container) => {
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
if (links.length === 0) return;
@@ -57,8 +80,40 @@
meta = await ytOembedPending.get(videoId);
} else {
const promise = (async () => {
try {
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
// 1. Try client-side oEmbed first (avoids Tor/proxy consent walls)
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(ytUrl)}&format=json`;
const clientResp = await fetch(oembedUrl);
if (clientResp.ok) {
const clientData = await clientResp.json();
if (clientData.title) {
const metaObj = {
title: clientData.title,
site_name: 'youtube.com',
author: clientData.author_name || 'Unknown'
};
// Cache client-side
ytOembedCache.set(videoId, metaObj);
// Push to server cache so other clients/server benefit
try {
const csrf = window.f0ckSession?.csrf_token;
fetch('/api/v2/meta/cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {})
},
body: JSON.stringify({ url: ytUrl, meta: metaObj })
}).catch(() => {});
} catch (_) {}
return metaObj;
}
}
} catch (e) { }
// 2. Fall back to backend meta fetch
try {
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
if (r.ok) {
const data = await r.json();
@@ -83,6 +138,12 @@
titleSpan.textContent = 'YouTube Video';
}
titleSpan.dataset.loaded = 'true';
// Re-check overflow since the new text might cause wrapping
const inner = link.closest('.comment-content-inner');
if (inner && typeof checkOverflow === 'function') {
checkOverflow(inner);
}
}
};
@@ -128,10 +189,10 @@
const hostsRegexPart = allowedHosts.join('|');
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?<!\\S)(?=\\/[a-zA-Z0-9_\\-]))`;
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
const renderer = new marked.Renderer();
renderer.blockquote = function (quote) {
@@ -222,9 +283,13 @@
let processedLine = line;
// Handle Mentions
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
const user = g1 || g2;
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
const mentionStore = [];
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
const user = g1 || g2 || g4;
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
const idx = mentionStore.length;
mentionStore.push(html);
return `\x02MNT${idx}\x03`;
});
// Handle Comment Context Links (>>ID)
@@ -247,13 +312,33 @@
return `[video](${fullUrl})`;
});
// Use marked for each line individually
let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_');
const bs = String.fromCharCode(92);
mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_');
// Use marked for each line individually.
// Protect URLs and already-formed Markdown link/image tokens from the
// italic-prevention pass so that underscores in query params
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
const mdProtected = [];
// Match [text](url) / ![alt](url) tokens AND bare http(s) URLs
let mdSafe = processedLine.replace(
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g,
(match) => {
const idx = mdProtected.length;
mdProtected.push(match);
return `\x02MDURL${idx}\x03`;
}
);
// Escape * and _ only in the non-URL portions
mdSafe = mdSafe
.replace(/\\/g, '\\\\')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_');
// Restore protected URLs/tokens
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
// Restore Mentions
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
if (!trimmed.startsWith('>') || quoteEmojis) {
@@ -290,6 +375,14 @@
}
);
// Abyss label replacement
md = md.replace(
/<a\s[^>]*href="(?:https?:\/\/[^\/]+)?\/abyss(?:#|\/)(\d+)"[^>]*>([\s\S]*?)<\/a>/gi,
(match, abyssId) => {
return `<a href="/abyss/${abyssId}" class="sidebar-abyss-link" data-abyss-id="${abyssId}"><i class="fa-solid fa-dice-d6"></i> /abyss/${abyssId}</a>`;
}
);
// Build regex for allowed media hosters (video/audio)
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mediaHosts = [escapedSiteHost];
@@ -367,9 +460,49 @@
const SIDEBAR_MAX_CHARS = 200;
const SIDEBAR_MAX_EMOJIS = 12;
const renderCommentAttachments = (files, content = '') => {
if (!files || files.length === 0) return '';
const items = files.map(f => {
const url = `/c/${f.dest}`;
if (content.includes(url)) return ''; // Skip if already rendered in content
if (f.mime.startsWith('image/')) {
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" class="sidebar-comment-img" alt="${escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
} else if (f.mime.startsWith('video/')) {
return `<div class="cf-attachment cf-video"><video src="${url}" class="sidebar-comment-img" controls preload="metadata"></video></div>`;
} else if (f.mime.startsWith('audio/')) {
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
}
return '';
}).join('');
return items ? `<div class="comment-attachments">${items}</div>` : '';
};
const renderSidebarPoll = (poll, commentId, itemId) => {
if (!poll) return '';
const href = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : '#');
return `<a class="sidebar-poll-preview" href="${href}"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(poll.question)}</a>`;
};
const renderActivityItem = (c) => {
const rawContent = c.content || c.body || '';
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
// Robust fallback for is_long if server didn't provide it, plus inline media checks
let isLong = c.is_long;
if (isLong === undefined) {
isLong = rawContent.length > 120
|| rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0);
}
// Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
isLong = true;
}
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
// Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png';
@@ -383,71 +516,151 @@
const timeStr = c.created_at
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
: (c.timeago || 'just now');
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}"` : '';
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}" data-iso="${escapeHtml(c.created_at)}"` : '';
const fullDate = c.created_at
? (window.f0ckFormatDateFull ? window.f0ckFormatDateFull(c.created_at) : new Date(c.created_at).toISOString())
: '';
let itemPreview = '';
if (c.item_id) {
let mediaHtml = '';
const rClass = c.item_rating_class || 'untagged';
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
const blurSfw = localStorage.getItem('blurSfw') === 'true';
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
let isBlurred = false;
if (rClass === 'nsfw' && blurNsfw) isBlurred = true;
else if (rClass === 'nsfl' && blurNsfl) isBlurred = true;
else if (rClass === 'sfw' && blurSfw) isBlurred = true;
else if (rClass === 'untagged' && blurUntagged) isBlurred = true;
let thumbUrl = `/t/${c.item_id}.webp`;
if (isBlurred) {
thumbUrl = `/t/${c.item_id}_blur.webp`;
}
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" loading="lazy" onerror="this.style.display='none'" />`;
itemPreview = `
<div class="item-preview">
<a href="/${c.item_id}">${mediaHtml}</a>
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
</div>`;
}
return `
<div class="comment" id="sc${c.id}">
<div class="comment-body">
<div class="comment-header">
<div class="comment-header-left">
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
<img src="${avatarSrc}" class="sidebar-avatar" alt="${c.username}" loading="lazy" />
<img src="${avatarSrc}" class="sidebar-avatar" loading="eager" onload="this.classList.add('loaded')" onerror="this.classList.add('loaded');this.src='/a/default.png'" />
</a>
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
</div>
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
<span class="comment-time timeago" tooltip="${fullDate}" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
</div>
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
<div class="comment-content${isLong ? ' has-overflow' : ''}"><div class="comment-content-inner">${displayContent}${attachmentsHtml}${pollHtml}</div><button class="read-more-btn"${isLong ? ' style="display:block"' : ''}>${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
${itemPreview}
</div>
</div>`;
};
const checkOverflow = () => {
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
const checkOverflow = (targetElement) => {
const targets = targetElement
? (targetElement.classList.contains('comment-content-inner') ? [targetElement] : targetElement.querySelectorAll('.comment-content-inner'))
: document.querySelectorAll('.sidebar-activity .comment-content-inner');
const results = [];
// Read phase: batch layout reads first
targets.forEach(inner => {
const container = inner.parentElement;
const btn = container.querySelector('.read-more-btn');
const btn = container ? container.querySelector('.read-more-btn') : null;
if (!btn) return;
// If expanded, always show "see less"
if (container.classList.contains('expanded')) {
btn.style.display = 'block';
const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
media => {
if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
}
if (media.tagName === 'VIDEO') {
return media.readyState < 1 && media.dataset.error !== 'true'; // HAVE_METADATA
}
return false;
}
);
results.push({
container,
btn,
isExpanded,
scrollHeight,
clientHeight,
hasUnloadedImages
});
});
// Write phase: perform DOM updates after all reads are completed.
// Visibility of the button is controlled entirely by the CSS rule on
// .comment-content.has-overflow > .read-more-btn — no btn.style.display here.
results.forEach(({ container, btn, isExpanded, scrollHeight, clientHeight, hasUnloadedImages }) => {
// Clear any leftover inline display style (e.g. from the HTML template's
// style="display:block") so the CSS class-based rule is the single source of truth.
btn.style.display = '';
if (isExpanded) {
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
return;
}
if (inner.scrollHeight > inner.clientHeight + 2) { // 2px buffer for rounding
btn.style.display = 'block';
if (scrollHeight > clientHeight + 2) { // 2px buffer for rounding
// Content overflows — ensure clamped state (may already be set from HTML default)
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
container.classList.add('has-overflow');
} else if (hasUnloadedImages) {
// Images haven't loaded yet — their height is 0 so we can't tell if content
// will overflow. Keep has-overflow set; attachMediaLoadListeners will
// re-run checkOverflow once images finish loading.
} else {
btn.style.display = 'none';
// Content fits and all images are loaded — safe to remove clamped state
container.classList.remove('has-overflow');
}
});
};
const attachMediaLoadListeners = (element) => {
element.querySelectorAll('img, video').forEach(media => {
// Only target images and videos inside comment-content-inner.
// Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true';
media.addEventListener('load', checkOverflow, { once: true });
media.addEventListener('loadedmetadata', checkOverflow, { once: true });
const inner = media.closest('.comment-content-inner');
if (inner) {
const handler = () => checkOverflow(inner);
const errorHandler = () => {
media.dataset.error = 'true';
checkOverflow(inner);
};
media.addEventListener('load', handler, { once: true });
media.addEventListener('error', errorHandler, { once: true });
media.addEventListener('loadedmetadata', handler, { once: true });
// Also use ResizeObserver to catch layout changes (e.g. cached image layout deferred)
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => checkOverflow(inner));
ro.observe(media);
}
}
});
};
@@ -459,7 +672,7 @@
const contentDiv = readBtn.closest('.comment-content');
if (contentDiv) {
contentDiv.classList.toggle('expanded');
checkOverflow(); // Re-sync button text and visibility
checkOverflow(contentDiv); // Re-sync button text and visibility for this element
}
return;
}
@@ -470,43 +683,28 @@
const showSkeletons = () => {
const container = document.getElementById('sidebar-activity-container');
if (!container) return;
const variants = [
`<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-medium"></div>
<div class="skeleton-line skeleton-text-short"></div>`,
`<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-short"></div>`,
`<div class="skeleton-line skeleton-text-medium"></div>
<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-short"></div>`,
`<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-medium"></div>`,
`<div class="skeleton-line skeleton-text-short"></div>
<div class="skeleton-line skeleton-text-long"></div>`,
];
let html = '';
for (let i = 0; i < SIDEBAR_SKELETON_COUNT; i++) {
html += `
<div class="sidebar-skeleton-item">
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-meta">
<div class="skeleton-line skeleton-name"></div>
<div class="skeleton-line skeleton-time"></div>
container.innerHTML = `
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>${window.f0ckI18n?.sidebar_loading_activity || 'Loading activity...'}</span>
</div>
</div>
${variants[i % 5]}
</div>`;
}
container.innerHTML = html;
// Auto-play converted GIF videos
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
`;
};
const renderFromCache = () => {
const renderFromCache = (force = false) => {
const container = document.getElementById('sidebar-activity-container');
if (!container || window._sidebarActivityCache.length === 0) return false;
const currentIds = window._sidebarActivityCache.map(c => String(c.id)).join(',');
// If the same comments are already rendered, leave the DOM completely untouched.
// This keeps video stickers playing and avoids any overflow state churn.
// force=true bypasses this guard for cases where content changed without ID changes
// (e.g. emojis just loaded and need to replace raw :codes: in existing comments).
if (!force && currentIds === lastRenderedIds && container.querySelector('.comment')) {
return true;
}
lastRenderedIds = currentIds;
let html = '';
window._sidebarActivityCache.forEach(c => {
html += renderActivityItem(c);
@@ -518,15 +716,35 @@
container.appendChild(ioSentinel);
}
attachMediaLoadListeners(container);
// has-overflow is now set per-comment by the server (via is_long) so the
// correct clamped/unclamped state is baked into the HTML on first paint.
// checkOverflow still runs deferred to handle two edge cases:
// 1. Comments with images whose heights are 0 at inject time — the server
// correctly marks them is_long:true, but images that load extra-tall
// still need a re-check after load (handled by attachMediaLoadListeners).
// 2. Any mis-estimates: content that the heuristic got wrong is corrected
// after layout so measurements are accurate.
// Run checkOverflow synchronously immediately after setting innerHTML.
// This forces a synchronous layout (reflow) but guarantees the browser
// will not paint the intermediate state, completely eliminating any "flash"
// of the button popping in or out.
checkOverflow();
// checkOverflow still runs deferred to handle image loads (attachMediaLoadListeners)
// and a fallback 1s timeout for any incredibly slow layout edge cases.
requestAnimationFrame(() => {
setTimeout(checkOverflow, 1000);
});
fetchSidebarYoutubeTitles(container);
// Auto-play converted GIF videos
// Auto-play converted GIF videos and webm emoji stickers
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
playSidebarEmojiVideos(container);
return true;
};
const SIDEBAR_PAGE_LIMIT = 15;
const SIDEBAR_INITIAL_LIMIT = 15;
const SIDEBAR_MAX_COMMENTS = 20;
const loadActivity = async (silent = false) => {
const container = document.getElementById('sidebar-activity-container');
@@ -552,22 +770,21 @@
const data = await res.json();
if (data.success && data.comments && data.comments.length > 0) {
window._sidebarActivityCache = data.comments.map(c => ({
window._sidebarActivityCache = data.comments.slice(0, SIDEBAR_MAX_COMMENTS).map(c => ({
...c,
body: c.content || c.body
}));
hasMore = data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT;
renderFromCache();
// Also check after a delay to account for image/emoji loading shifts
setTimeout(checkOverflow, 500);
hasMore = (data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT)
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
renderFromCache(); // no-op if IDs unchanged (renderFromCache self-guards)
} else if (!hasCache) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No recent activity.</div>';
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_no_activity || 'No recent activity.') + '</div>';
hasMore = false;
}
} catch (e) {
console.error("Sidebar Activity: Failed to load activity", e);
if (!hasCache) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Failed to load.</div>';
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.') + '</div>';
}
hasMore = false;
} finally {
@@ -589,7 +806,7 @@
const sentinel = document.createElement('div');
sentinel.id = 'sidebar-load-more-sentinel';
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
sentinel.textContent = 'Loading…';
sentinel.textContent = window.f0ckI18n?.sidebar_loading_more || 'Loading…';
container.appendChild(sentinel);
try {
@@ -605,40 +822,65 @@
if (data.success && data.comments && data.comments.length > 0) {
currentPage++;
hasMore = data.hasMore === true;
// Append only comments not already in the cache
// Append only comments not already in the cache, up to the global cap
const remaining = SIDEBAR_MAX_COMMENTS - window._sidebarActivityCache.length;
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
const newComments = data.comments.filter(c => !existingIds.has(String(c.id))).map(c => ({
...c,
body: c.content || c.body
}));
const newComments = data.comments
.filter(c => !existingIds.has(String(c.id)))
.slice(0, remaining)
.map(c => ({ ...c, body: c.content || c.body }));
window._sidebarActivityCache.push(...newComments);
// Stop loading more if the hard cap is reached
hasMore = data.hasMore === true
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
// Append new items to DOM
let html = '';
newComments.forEach(c => { html += renderActivityItem(c); });
if (html) {
const temp = document.createElement('div');
temp.innerHTML = html;
const addedNodes = [];
while (temp.firstElementChild) {
container.appendChild(temp.firstElementChild);
const node = temp.firstElementChild;
container.appendChild(node);
addedNodes.push(node);
}
// Keep the IO sentinel at the very end so it triggers on the next scroll
if (ioSentinel) container.appendChild(ioSentinel);
attachMediaLoadListeners(container);
checkOverflow();
fetchSidebarYoutubeTitles(container);
// Auto-play converted GIF videos
addedNodes.forEach(node => {
attachMediaLoadListeners(node);
fetchSidebarYoutubeTitles(node);
playSidebarEmojiVideos(node);
});
// Defer overflow checks to after layout so measurements are accurate.
addedNodes.forEach(node => checkOverflow(node));
requestAnimationFrame(() => {
// Deferred re-check for image comments — mirrors renderFromCache behaviour.
// Images in newly appended nodes may not be laid out yet at append time.
setTimeout(() => addedNodes.forEach(node => checkOverflow(node)), 1000);
});
// Auto-play converted GIF videos and webm emoji stickers
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
}
if (!hasMore) {
// Show end-of-feed indicator
const end = document.createElement('div');
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
container.appendChild(end);
}
} else {
hasMore = false;
// Show end-of-feed indicator
const end = document.createElement('div');
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
end.textContent = '─ end of activity ─';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
container.appendChild(end);
}
} catch (e) {
@@ -659,7 +901,7 @@
return;
}
// 2. Update cache (prepend, no hard cap — infinite scroll handles depth)
// 2. Update cache (prepend, capped at SIDEBAR_MAX_COMMENTS)
const newItem = {
...data,
body: data.body || data.content,
@@ -667,9 +909,22 @@
};
window._sidebarActivityCache.unshift(newItem);
// Trim cache to the hard cap and remove the evicted DOM node (if any)
if (window._sidebarActivityCache.length > SIDEBAR_MAX_COMMENTS) {
const evicted = window._sidebarActivityCache.pop();
if (container && evicted) {
const evictedEl = document.getElementById('sc' + evicted.id);
if (evictedEl) evictedEl.remove();
}
}
// Update DOM if visible
if (container) {
const html = renderActivityItem(newItem);
// is_long may be provided by the server (SSE payload) or absent for legacy events.
// Default to true so the button is always visible; checkOverflow will hide it
// if the rendered content is actually short.
const itemWithLong = { ...newItem, is_long: newItem.is_long !== false };
const html = renderActivityItem(itemWithLong);
const temp = document.createElement('div');
temp.innerHTML = html;
const node = temp.firstElementChild;
@@ -677,15 +932,26 @@
node.classList.add('new-item-fade');
container.prepend(node);
attachMediaLoadListeners(node);
checkOverflow();
checkOverflow(node);
fetchSidebarYoutubeTitles(container);
playSidebarEmojiVideos(node);
}
}
};
const init = async () => {
await loadEmojis();
loadActivity();
// Run emoji loading and activity fetching in parallel — avatars appear
// immediately without waiting for the emoji API to respond first.
// After both settle, if emojis finished after the initial render, re-render
// from cache so custom emoji images show on first page load.
const emojiPromise = loadEmojis();
const activityPromise = loadActivity();
await activityPromise;
await emojiPromise;
// If emojis were not yet available when activity first rendered, re-render now.
if (Object.keys(customEmojis).length > 0 && window._sidebarActivityCache.length > 0) {
renderFromCache(true); // force: emojis are now loaded, content changed
}
};
// Listen for live activity from f0ckm.js
@@ -718,7 +984,7 @@
void el.offsetWidth;
el.classList.add('new-item-fade');
attachMediaLoadListeners(inner);
checkOverflow();
requestAnimationFrame(() => checkOverflow(inner));
fetchSidebarYoutubeTitles(el);
// Auto-play converted GIF videos
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
@@ -732,6 +998,20 @@
handleLiveEdit(e.detail);
});
// When emojis are refreshed (e.g. after sticker pack import), sync the sidebar
// cache and re-render so newly imported stickers appear in existing entries.
window.addEventListener('f0ck:emojis_ready', () => {
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null;
if (!csCache || Object.keys(csCache).length === 0) return;
// Merge new emojis into local cache (additive — never removes)
const hadBefore = Object.keys(customEmojis).length;
Object.assign(customEmojis, csCache);
const hasNew = Object.keys(customEmojis).length > hadBefore;
if (hasNew && window._sidebarActivityCache.length > 0) {
renderFromCache(true); // force: new emoji images available, re-render
}
});
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
// Handle AJAX item loads
@@ -744,6 +1024,7 @@
if (modeChanged) {
window._sidebarActivityCache = [];
lastRenderedIds = '';
currentPage = 1;
hasMore = true;
loadActivity(false); // Force reload with loading state
@@ -765,6 +1046,7 @@
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
lastBoundMode = e.detail.mode;
window._sidebarActivityCache = [];
lastRenderedIds = '';
currentPage = 1;
hasMore = true;
loadActivity(false);

View File

@@ -71,11 +71,13 @@ window.TagAutocomplete = (() => {
// Flag to prevent focusout from destroying dropdown while touching it
let dropdownTouching = false;
// Flag set when we intentionally blur to dismiss the keyboard on mobile
let keyboardDismissed = false;
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
dropdown.addEventListener('touchend', () => {
dropdownTouching = false;
// Re-focus input so user can keep typing after scrolling
input.focus();
// Note: do NOT re-focus input here — that would reopen the mobile keyboard.
// The keyboard only comes back when the user explicitly taps the input.
}, { passive: true });
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
@@ -267,18 +269,51 @@ window.TagAutocomplete = (() => {
open(opts);
});
// Close when clicking/tapping outside
const onDocClick = (e) => {
// Close when clicking/tapping outside.
// Desktop (mousedown): close immediately.
// Mobile (touchstart): first tap outside dismisses the keyboard only (blur),
// leaving suggestions visible so the user can scroll up to see them.
// A second tap outside within 500 ms actually closes the dropdown.
let outsideTapCount = 0;
let outsideTapTimer = null;
const onDocMousedown = (e) => {
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('touchstart', onDocClick);
document.removeEventListener('mousedown', onDocMousedown);
destroy();
}
};
// Delay attaching to avoid capturing the opening click
const onDocTouchstart = (e) => {
if (wrapper.contains(e.target) || e.target === anchorEl) return;
outsideTapCount++;
if (outsideTapCount === 1) {
// First outside tap — just blur to dismiss the keyboard.
// Suggestions remain visible so the user can scroll up to see them.
keyboardDismissed = true;
input.blur();
// Reset the flag after the blur event has fired.
setTimeout(() => { keyboardDismissed = false; }, 100);
// Reset the counter after the double-tap window expires.
outsideTapTimer = setTimeout(() => {
outsideTapCount = 0;
}, 500);
} else {
// Second outside tap — close the dropdown.
clearTimeout(outsideTapTimer);
outsideTapCount = 0;
document.removeEventListener('touchstart', onDocTouchstart);
destroy();
}
};
// Delay attaching to avoid capturing the opening touch.
setTimeout(() => {
document.addEventListener('mousedown', onDocClick);
document.addEventListener('touchstart', onDocClick, { passive: true });
document.addEventListener('mousedown', onDocMousedown);
document.addEventListener('touchstart', onDocTouchstart, { passive: true });
}, 0);
// Click on the wrapper area should refocus the input
@@ -293,6 +328,7 @@ window.TagAutocomplete = (() => {
// Delay to allow suggestion tap/scroll to complete first
setTimeout(() => {
if (dropdownTouching) return; // user is interacting with dropdown
if (keyboardDismissed) return; // intentional blur to hide mobile keyboard
// Don't close if focus is still within the wrapper
if (activeInstance && wrapper.contains(document.activeElement)) return;
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@
a.textContent = tag.tag;
const span = document.createElement("span");
span.classList.add("badge", "mr-2");
span.classList.add("badge");
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
span.classList.add('new-tag-glow');
}
@@ -120,7 +120,13 @@
postid,
existingTags: tags,
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
});
};
@@ -189,7 +195,6 @@
a.appendChild(img);
favcontainer.appendChild(a);
favcontainer.appendChild(document.createTextNode('\u00A0'));
});
favcontainer.hidden = false;
} else {

View File

@@ -1,4 +1,5 @@
class UserCommentSystem {
if (!window.UserCommentSystem) {
window.UserCommentSystem = class UserCommentSystem {
constructor() {
this.container = document.getElementById('user-comments-container');
this.username = this.container ? this.container.dataset.user : null;
@@ -8,6 +9,8 @@ class UserCommentSystem {
this.userColor = null;
this.customEmojis = UserCommentSystem.emojiCache || {};
this.icons = {
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
link: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`
@@ -23,12 +26,16 @@ class UserCommentSystem {
}
handleLiveEdit(data) {
if (!this.container) return;
if (!this.container || !document.body.contains(this.container)) {
window.removeEventListener('f0ck:comment_edited', this.editListener);
return;
}
const el = document.getElementById('c' + data.comment_id);
if (el && this.container.contains(el)) {
const contentEl = el.querySelector('.comment-content');
if (contentEl) {
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
this.playEmojiVideos(contentEl);
el.classList.remove('new-item-fade');
void el.offsetWidth;
el.classList.add('new-item-fade');
@@ -37,7 +44,7 @@ class UserCommentSystem {
}
async init() {
this.loadEmojis();
await this.loadEmojis();
this.loadMore();
this.loadMore();
this.bindEvents();
@@ -112,6 +119,7 @@ class UserCommentSystem {
const html = this.renderComment(c);
this.container.insertAdjacentHTML('beforeend', html);
});
this.playEmojiVideos(this.container);
this.page++;
} else {
this.finished = true;
@@ -127,13 +135,89 @@ class UserCommentSystem {
}
}
playEmojiVideos(container) {
if (!container) return;
container.querySelectorAll('video.emoji').forEach(v => {
v.play().catch(() => {
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
});
});
}
renderEmoji(match, name) {
if (this.customEmojis && this.customEmojis[name]) {
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
const url = this.customEmojis[name];
if (url.endsWith('.webm')) {
return `<video src="${url}" class="emoji" autoplay loop muted playsinline title=":${name}:"></video>`;
}
return `<img src="${url}" class="emoji" alt="${match}" title="${match}">`;
}
return match;
}
renderCommentAttachments(files, content = '') {
if (!files || files.length === 0) return '';
const items = files.map(f => {
const url = `/c/${f.dest}`;
if (content && content.includes(url)) return ''; // Skip if already rendered in content
if (f.mime && f.mime.startsWith('image/')) {
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
} else if (f.mime && f.mime.startsWith('video/')) {
return `<div class="cf-attachment cf-video"><video src="${url}" controls preload="metadata"></video></div>`;
} else if (f.mime && f.mime.startsWith('audio/')) {
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
}
return '';
}).join('');
return items ? `<div class="comment-attachments">${items}</div>` : '';
}
renderCommentPoll(poll, commentId) {
if (!poll) return '';
const i18n = window.f0ckI18n || {};
const session = window.f0ckSession || {};
const total = poll.total_votes || 0;
const voted = !!poll.user_vote_option_id;
const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
const isAnon = poll.is_anonymous !== false;
const optionsHtml = (poll.options || []).map(opt => {
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
const isVoted = poll.user_vote_option_id === opt.id;
const clickable = session.logged_in && !expired && !voted;
const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
? `<div class="poll-option-voters">${opt.voters.map(v => {
const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null };
const name = String(u.username || '');
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
return name ? `<a href="/user/${this.escapeHtml(name)}" title="${this.escapeHtml(name)}"><img class="poll-voter-avatar" src="${src}" alt="${this.escapeHtml(name)}" loading="lazy"></a>` : '';
}).join('')}</div>`
: '';
return `<div class="poll-option ${isVoted ? 'poll-option-voted' : ''} ${clickable ? 'poll-option-clickable' : ''}"
data-option-id="${opt.id}" data-poll-id="${poll.id}" data-comment-id="${commentId}">
<div class="poll-option-bar" style="width:${pct}%"></div>
<span class="poll-option-text">${this.escapeHtml(opt.text)}</span>
<span class="poll-option-pct">${pct}%</span>
${isVoted ? `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>` : ''}
${voterAvatars}
</div>`;
}).join('');
const anonBadge = isAnon
? `<span class="poll-anon-badge" title="${i18n.poll_anonymous || 'Anonymous'}"><i class="fa-solid fa-user-secret"></i></span>`
: `<span class="poll-anon-badge poll-public-badge" title="${i18n.poll_public || 'Public votes'}"><i class="fa-solid fa-eye"></i></span>`;
return `<div class="comment-poll" data-poll-id="${poll.id}" data-is-anonymous="${isAnon ? '1' : '0'}">
<div class="poll-question">${this.escapeHtml(poll.question)}</div>
<div class="poll-options">${optionsHtml}</div>
<div class="poll-footer">
<span class="poll-total">${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}</span>
${anonBadge}
${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
</div>
</div>`;
}
renderCommentContent(content, itemId = null) {
if (!content) return '';
@@ -152,7 +236,7 @@ class UserCommentSystem {
let escaped = this.escapeHtml(content).replace(/&gt;/g, ">");
// 2. Mentions
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
const siteOrigin = window.location.origin;
const renderer = new marked.Renderer();
@@ -191,6 +275,32 @@ class UserCommentSystem {
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
};
renderer.image = (href, title, text) => {
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
const imgHtml = `<img src="${src}" alt="${text || ''}"${title ? ` title="${title}"` : ''} onerror="this.onerror=null; this.outerHTML='<span class=\\'broken-image-text\\'>[image not found]</span>';">`;
if (window.f0ckSession?.is_admin && src && src.startsWith('/c/')) {
const filename = src.substring(3); // Remove '/c/'
return `<span class="image-embed-wrap">${imgHtml}<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[x]</button></span>`;
}
return imgHtml;
};
// Pre-compile regexes for image/video/audio embeds matching comments.js
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const allowedHosts = [escapedSiteHost];
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
window.f0ckAllowedImages.forEach(h => {
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
});
}
const hostsRegexPart = allowedHosts.join('|');
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?(?:#gif)?))(?![\\)\\]])`, 'gi');
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
@@ -203,7 +313,17 @@ class UserCommentSystem {
if (line.length > 10000) return line;
if (!line.trim()) return '&nbsp;';
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
let processedLine = line;
// Handle Mentions
const mentionStore = [];
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
const user = g1 || g2 || g4;
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
const idx = mentionStore.length;
mentionStore.push(html);
return `\x02MNT${idx}\x03`;
});
// Handle Comment Context Links (>>ID)
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
@@ -211,9 +331,34 @@ class UserCommentSystem {
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
});
// Handle Image Embeds
processedLine = processedLine.replace(imageRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
fullUrl = '//' + url;
}
return `![image](${fullUrl})`;
});
// Handle Raw Video/Audio links so Marked converts them to <a>
processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
return `[video](${fullUrl})`;
});
processedLine = processedLine.replace(rawAudioRegex, (match, url) => {
let fullUrl = url;
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
return `[audio](${fullUrl})`;
});
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
// Restore Mentions
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
if (!trimmed.startsWith('>') || quoteEmojis) {
@@ -264,35 +409,7 @@ class UserCommentSystem {
const fullDate = new Date(c.created_at).toISOString();
const content = this.renderCommentContent(c.content, c.item_id);
// Replicating the structure of comments.js but adapting for the list view
// We add a header indicating which item this comment belongs to
return `
<div class="comment" id="c${c.id}">
<div class="comment-avatar">
<a href="/${c.item_id}">
<img src="/t/${c.item_id}.webp" alt="">
</a>
</div>
<div class="comment-body">
<div class="comment-header">
<div class="comment-header-left">
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
</div>
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
</div>
<div class="comment-content">${content}</div>
<div class="comment-footer">
<div class="comment-footer-right">
<div class="comment-actions">
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
</div>
</div>
</div>
</div>
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
</div>
`;
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
}
startLiveTimestamps() {
@@ -336,15 +453,21 @@ class UserCommentSystem {
return div.innerHTML;
}
}
}
// Initializer for AJAX and standard load
window.initUserComments = () => {
// Prevent multiple instances if already running on this container
if (document.getElementById('user-comments-container')) {
const container = document.getElementById('user-comments-container');
if (container && !container.dataset.initialized) {
container.dataset.initialized = 'true';
new UserCommentSystem();
}
};
window.addEventListener('DOMContentLoaded', () => {
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', () => {
window.initUserComments();
});
});
} else {
window.initUserComments();
}

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_seek_marker"></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;">
<use id="v0ck_svg_play" href="${svg}#play"></use>
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
</svg>
</button>
<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;">
<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_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
</svg>
</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>
<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>
<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;">
<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>
</button>
<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">
<span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
@@ -44,22 +44,32 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<span class="v0ck_switch_label">Danmaku</span>
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></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>
<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>
</button>
</div>
<div class="v0ck_loader v0ck_hidden"><div></div></div>
<div class="v0ck_speed_indicator v0ck_hidden">
<svg viewBox="0 0 24 24" style="width: 16px; height: 16px; fill: currentColor; display: inline-block; vertical-align: middle; margin-right: 6px;">
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/>
</svg>
<span>2X Speed</span>
</div>
<div class="v0ck_overlay">
<svg style="width: 60px; height: 60px;">
<use href="${svg}#play"></use>
</svg>
</div>
<div class="v0ck_hud v0ck_hidden">
<svg><use class="v0ck_hud_icon" href="${svg}#volume_full"></use></svg>
<svg>
<use class="v0ck_hud_icon v0ck_hud_volume_full" href="${svg}#volume_full"></use>
<use class="v0ck_hud_icon v0ck_hud_volume_mid v0ck_hidden" href="${svg}#volume_mid"></use>
<use class="v0ck_hud_icon v0ck_hud_volume_mute v0ck_hidden" href="${svg}#volume_mute"></use>
</svg>
<div class="v0ck_hud_bar_container">
<div class="v0ck_hud_bar"></div>
</div>
@@ -111,10 +121,7 @@ class v0ck {
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
}
if (!isMobile) {
parent.addEventListener('mouseenter', () => parent.classList.add("v0ck_hover"));
parent.addEventListener('mouseleave', () => parent.classList.remove("v0ck_hover"));
}
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
@@ -157,18 +164,35 @@ class v0ck {
const playtime = player.querySelector('.v0ck_playtime');
const overlay = player.querySelector('.v0ck_overlay');
const volumeButton = player.querySelector('.v0ck_volume');
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
const volumeSymbols = volumeButton.querySelectorAll('use');
const defaultVolume = 0.5;
let mousedown = false;
let _volume;
// Hold to speedup (2x) states
let speedUpTimeout;
let isSpeedingUp = false;
let restorePlaybackRate = 1;
let ignoreNextClick = false;
let wasPausedWhenStarted = false;
// Mobile tap-to-show-controls: true when this touch revealed the controls bar
let controlsJustShown = false;
const speedIndicator = player.querySelector('.v0ck_speed_indicator');
// (mouse position is now tracked via docMouseX/docMouseY in resetControlsTimer block)
function handleVolumeButton(vol) {
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
switch (true) {
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
[...volumeSymbols].forEach(s => s.classList.add('v0ck_hidden'));
let targetId = 'v0ck_svg_volume_full';
if (vol === 0) {
targetId = 'v0ck_svg_volume_mute';
} else if (vol <= 0.5) {
targetId = 'v0ck_svg_volume_mid';
}
const activeSymbol = [...volumeSymbols].find(s => s.id === targetId);
if (activeSymbol) {
activeSymbol.classList.remove('v0ck_hidden');
}
localStorage.setItem("volume", vol);
}
@@ -262,14 +286,31 @@ class v0ck {
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
}
// Mobile: on touchstart, record whether controls were hidden so the
// subsequent click can decide whether to show controls or toggle play.
player.addEventListener('touchstart', () => {
if (isMobile) {
controlsJustShown = !player.classList.contains('v0ck_hover');
}
}, { passive: true, capture: true });
player.addEventListener('click', e => {
if (ignoreNextClick) {
e.stopPropagation();
e.preventDefault();
ignoreNextClick = false;
return;
}
const path = e.path || (e.composedPath && e.composedPath());
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
if (!isControls) {
if (isMobile && !player.classList.contains('v0ck_hover')) {
if (isMobile && controlsJustShown) {
// First tap: controls were just revealed by this touch — don't toggle play
controlsJustShown = false;
player.classList.add('v0ck_hover');
return;
}
controlsJustShown = false;
togglePlay(e);
}
});
@@ -301,9 +342,18 @@ class v0ck {
}
});
// Loader events
const showLoader = () => loader.classList.remove('v0ck_hidden');
const hideLoader = () => loader.classList.add('v0ck_hidden');
// Loader events with debounce to prevent flicker on fast starts/seeking
let loaderTimeout = null;
const showLoader = () => {
if (loaderTimeout) clearTimeout(loaderTimeout);
loaderTimeout = setTimeout(() => {
loader.classList.remove('v0ck_hidden');
}, 250);
};
const hideLoader = () => {
if (loaderTimeout) clearTimeout(loaderTimeout);
loader.classList.add('v0ck_hidden');
};
video.addEventListener('waiting', showLoader);
video.addEventListener('stalled', showLoader);
video.addEventListener('canplay', hideLoader);
@@ -325,11 +375,21 @@ class v0ck {
hud.classList.remove('v0ck_hidden');
hudBar.style.width = `${vol * 100}%`;
// Update HUD icon based on volume
let icon = 'volume_full';
if (vol === 0) icon = 'volume_mute';
else if (vol <= 0.5) icon = 'volume_mid';
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
// Update HUD icon based on volume by toggling hidden class
const hudSymbols = hud.querySelectorAll('.v0ck_hud_icon');
hudSymbols.forEach(s => s.classList.add('v0ck_hidden'));
let targetClass = 'v0ck_hud_volume_full';
if (vol === 0) {
targetClass = 'v0ck_hud_volume_mute';
} else if (vol <= 0.5) {
targetClass = 'v0ck_hud_volume_mid';
}
const activeSymbol = [...hudSymbols].find(s => s.classList.contains(targetClass));
if (activeSymbol) {
activeSymbol.classList.remove('v0ck_hidden');
}
clearTimeout(hudTimer);
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
@@ -348,7 +408,7 @@ class v0ck {
startY = touch.clientY;
startVol = video.volume;
}
}, { passive: true });
}, { passive: false });
player.addEventListener('touchmove', e => {
if (!isMobile || !isRightSide || gestureType === 'other') return;
@@ -361,6 +421,8 @@ class v0ck {
if (gestureType === 'none') {
if (dy > dx && dy > 5) {
gestureType = 'volume';
clearTimeout(speedUpTimeout);
endSpeedUp();
} else if (dx > dy && dx > 5) {
gestureType = 'other'; // Probably seeking or horizontal swipe
return;
@@ -370,6 +432,9 @@ class v0ck {
}
if (gestureType === 'volume') {
clearTimeout(speedUpTimeout);
endSpeedUp();
const deltaY = startY - touch.clientY; // swipe up is positive
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
let newVol = startVol + (deltaY / sensitivity);
@@ -384,9 +449,86 @@ class v0ck {
if (e.cancelable) e.preventDefault();
}
}, { passive: false });
// Desktop mouse volume gesture support (clicking and dragging vertically on the player)
let activeMouseGesture = false;
player.addEventListener('mousedown', e => {
if (isMobile) return;
if (e.button !== 0) return;
const path = e.path || (e.composedPath && e.composedPath());
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
if (isControls) return;
gestureType = 'none';
startX = e.clientX;
startY = e.clientY;
startVol = video.volume;
activeMouseGesture = true;
});
window.addEventListener('mousemove', e => {
if (!activeMouseGesture || gestureType === 'other') return;
const dx = Math.abs(e.clientX - startX);
const dy = Math.abs(e.clientY - startY);
if (gestureType === 'none') {
if (dy > dx && dy > 5) {
gestureType = 'volume';
clearTimeout(speedUpTimeout);
endSpeedUp();
} else if (dx > dy && dx > 5) {
gestureType = 'other';
return;
} else {
return;
}
}
if (gestureType === 'volume') {
clearTimeout(speedUpTimeout);
endSpeedUp();
ignoreNextClick = true;
const deltaY = startY - e.clientY; // swipe up is positive
const sensitivity = 200;
let newVol = startVol + (deltaY / sensitivity);
newVol = Math.max(0, Math.min(1, newVol));
video.volume = newVol;
volumeSlider.value = newVol;
_volume = newVol;
handleVolumeButton(newVol);
showHUD(newVol);
e.preventDefault();
}
});
window.addEventListener('mouseup', () => {
if (activeMouseGesture) {
activeMouseGesture = false;
setTimeout(() => {
ignoreNextClick = false;
}, 100);
}
});
skipButtons.forEach(button => button.addEventListener('click', skip));
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
ranges.forEach(range => range.addEventListener('input', handleRangeUpdate));
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
// Prevent touch events on the volume slider from bubbling to the player container (avoiding gesture conflicts and page scrolls)
if (volumeSlider) {
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(evt => {
volumeSlider.addEventListener(evt, e => {
e.stopPropagation();
}, { passive: false });
});
}
progress.addEventListener('mousedown', scrub);
progress.addEventListener('touchstart', scrub, { passive: false });
progress.addEventListener('touchmove', scrub, { passive: false });
@@ -394,11 +536,197 @@ class v0ck {
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
toggleFullScreenClasses(); // Check initial state (important for transitions)
let keyHoldTimer = null;
let isHoldingKey = false;
let heldKey = null;
let reverseInterval = null;
function handleKeyDown(e) {
if (!document.body.contains(video)) {
cleanupKeyboardListeners();
return;
}
// Block interaction if content warning is visible
const cwModal = document.getElementById('content-warning-modal');
if (cwModal && cwModal.style.display !== 'none') return;
// Don't fire if user is typing in an input, textarea or editing content
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable) return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
let newVol = video.volume;
if (e.key === "ArrowUp") {
newVol = Math.min(1, newVol + 0.05);
} else {
newVol = Math.max(0, newVol - 0.05);
}
video.volume = newVol;
if (volumeSlider) {
volumeSlider.value = newVol;
}
_volume = newVol;
handleVolumeButton(newVol);
showHUD(newVol);
}
if (e.key === "." || e.key === ",") {
if (e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
if (!Number.isFinite(video.currentTime)) return;
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
const direction = e.key === "." ? 1 : -1;
if (heldKey && heldKey !== e.key) return;
const isCurrentHold = heldKey === e.key;
if (video.paused || isCurrentHold) {
if (!e.repeat) {
heldKey = e.key;
isHoldingKey = false;
if (keyHoldTimer) clearTimeout(keyHoldTimer);
keyHoldTimer = setTimeout(() => {
isHoldingKey = true;
if (heldKey === ".") {
video.play();
} else if (heldKey === ",") {
if (reverseInterval) clearInterval(reverseInterval);
reverseInterval = setInterval(() => {
if (video.currentTime > 0) {
video.currentTime = Math.max(0, video.currentTime - 0.04);
} else {
clearInterval(reverseInterval);
reverseInterval = null;
}
}, 40);
}
}, 200);
} else {
if (keyHoldTimer && !isHoldingKey) {
clearTimeout(keyHoldTimer);
keyHoldTimer = null;
isHoldingKey = true;
if (heldKey === ".") {
video.play();
} else if (heldKey === ",") {
if (reverseInterval) clearInterval(reverseInterval);
reverseInterval = setInterval(() => {
if (video.currentTime > 0) {
video.currentTime = Math.max(0, video.currentTime - 0.04);
} else {
clearInterval(reverseInterval);
reverseInterval = null;
}
}, 40);
}
}
}
} else {
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * 5));
}
showControlsAndReset();
}
}
function handleKeyUp(e) {
if (!document.body.contains(video)) {
cleanupKeyboardListeners();
return;
}
if (e.key === "." || e.key === ",") {
if (heldKey === e.key) {
if (keyHoldTimer) {
clearTimeout(keyHoldTimer);
keyHoldTimer = null;
}
if (isHoldingKey) {
if (heldKey === ".") {
video.pause();
} else if (heldKey === ",") {
if (reverseInterval) {
clearInterval(reverseInterval);
reverseInterval = null;
}
}
} else {
if (Number.isFinite(video.currentTime)) {
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
const direction = heldKey === "." ? 1 : -1;
const frameTime = 1 / 30;
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * frameTime));
}
}
heldKey = null;
isHoldingKey = false;
showControlsAndReset();
}
}
}
function handleBlur() {
if (!document.body.contains(video)) {
cleanupKeyboardListeners();
return;
}
if (keyHoldTimer) clearTimeout(keyHoldTimer);
if (reverseInterval) clearInterval(reverseInterval);
keyHoldTimer = null;
reverseInterval = null;
if (isHoldingKey) {
if (heldKey === ".") {
video.pause();
}
}
heldKey = null;
isHoldingKey = false;
}
function cleanupKeyboardListeners() {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
if (keyHoldTimer) clearTimeout(keyHoldTimer);
if (reverseInterval) clearInterval(reverseInterval);
}
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
handleVolumeButton(video.volume);
const mediaObj = player.closest('.media-object');
let isBlurredDetail = false;
if (mediaObj && localStorage.getItem('blurDetail') !== 'false') {
const mode = mediaObj.getAttribute('data-mode');
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
const blurSfw = localStorage.getItem('blurSfw') === 'true';
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
let shouldBlurThis = false;
if (mode === 'nsfw') shouldBlurThis = blurNsfw;
else if (mode === 'nsfl') shouldBlurThis = blurNsfl;
else if (mode === 'sfw') shouldBlurThis = blurSfw;
else if (mode === 'untagged') shouldBlurThis = blurUntagged;
if (shouldBlurThis && !mediaObj.classList.contains('revealed')) {
isBlurredDetail = true;
}
}
// Upgrade preload from 'metadata' → 'auto' for video elements so the browser
// starts buffering data immediately during player setup. With just 'metadata',
// play() triggers a brand-new range request and the user sees the loader spinner
// until enough data arrives. 'auto' closes that gap for both autoplay and manual play.
if (video.tagName === 'VIDEO' && video.preload !== 'auto') {
video.preload = 'auto';
}
// Attempt autoplay and show overlay if blocked
const shouldAutoplay = window.f0ckSession?.disable_autoplay !== true;
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
if (shouldAutoplay) {
const playPromise = togglePlay();
if (playPromise !== undefined) {
@@ -541,6 +869,162 @@ class v0ck {
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
}
}
// Controls auto-hide logic (auto hide controls after 2.5 seconds of inactivity)
let controlsTimer;
// True while the cursor is physically inside .v0ck_player_controls
let mouseIsOverControls = false;
// Track real mouse position at document level — completely independent of
// any element animation or synthetic events.
let docMouseX = -1;
let docMouseY = -1;
const onDocMouseMove = (e) => {
docMouseX = e.clientX;
docMouseY = e.clientY;
};
document.addEventListener('mousemove', onDocMouseMove, { passive: true });
function resetControlsTimer() {
clearTimeout(controlsTimer);
// Never schedule auto-hide while the user is mousing over the controls bar
if (mouseIsOverControls) return;
const isFullscreen = player.classList.contains('v0ck_fullscreen');
if (!video.paused || isFullscreen) {
controlsTimer = setTimeout(() => {
player.classList.remove('v0ck_hover');
if (settingsMenu && !settingsMenu.classList.contains('v0ck_hidden')) {
settingsMenu.classList.add('v0ck_hidden');
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
}
}, 2500);
}
}
function showControlsAndReset(e) {
// Ignore synthetic pointer events fired by the browser during CSS animations
// (element shifts under stationary cursor). We compare against docMouseX/Y
// which is only ever updated by genuine user mouse movement.
if (e && e.clientX !== undefined && e.clientY !== undefined) {
if (e.clientX === docMouseX && e.clientY === docMouseY &&
player.classList.contains('v0ck_hover')) {
// Coordinates unchanged and controls already visible — synthetic event, skip.
return;
}
docMouseX = e.clientX;
docMouseY = e.clientY;
}
player.classList.add('v0ck_hover');
resetControlsTimer();
}
// Events that should show controls and reset/extend the auto-hide timer
const resetEvents = ['touchstart', 'touchmove', 'touchend', 'click', 'mousemove', 'mouseenter'];
resetEvents.forEach(evt => {
player.addEventListener(evt, showControlsAndReset, { capture: true, passive: true });
});
// While hovering the controls bar: freeze the auto-hide timer completely.
const controlsBar = player.querySelector('.v0ck_player_controls');
if (controlsBar) {
controlsBar.addEventListener('mouseenter', () => {
mouseIsOverControls = true;
clearTimeout(controlsTimer); // cancel any countdown already in progress
}, { passive: true });
controlsBar.addEventListener('mouseleave', (e) => {
mouseIsOverControls = false;
// Only restart the timer if the cursor re-entered the player area
// (not when it left the player entirely — the player mouseleave handles that)
const r = player.getBoundingClientRect();
const stillInPlayer = e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom;
if (stillInPlayer) resetControlsTimer();
}, { passive: true });
}
// Hide when cursor leaves the player entirely.
// NO capture:true — that would incorrectly intercept mouseleave events from
// child elements (e.g. the progress bar animating away from the cursor).
// Without capture, this only fires when the mouse truly leaves .v0ck itself.
player.addEventListener('mouseleave', (e) => {
const r = player.getBoundingClientRect();
// Grace zone: the controls bar peeks ~3px below the player when hidden.
// If the cursor is still in that strip, don't hide.
if (e.clientX >= r.left && e.clientX <= r.right &&
e.clientY > r.bottom && e.clientY <= r.bottom + 8) {
return;
}
// If the cursor moved into the controls bar itself (or any child of it),
// keep the controls visible — the user is interacting with them.
const controls = player.querySelector('.v0ck_player_controls');
if (controls && e.relatedTarget && controls.contains(e.relatedTarget)) {
return;
}
docMouseX = -1;
docMouseY = -1;
player.classList.remove('v0ck_hover');
clearTimeout(controlsTimer);
});
video.addEventListener('play', resetControlsTimer);
video.addEventListener('playing', resetControlsTimer);
video.addEventListener('pause', () => clearTimeout(controlsTimer));
// Speedup 2x on Hold logic
function startSpeedUp(e) {
if (e.type === 'mousedown' && isMobile) return;
// Only left mouse click or touch triggers speedup
if (e.type === 'mousedown' && e.button !== 0) return;
// Don't speed up if clicking on controls or settings panel
const path = e.path || (e.composedPath && e.composedPath());
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
if (isControls) return;
clearTimeout(speedUpTimeout);
speedUpTimeout = setTimeout(() => {
isSpeedingUp = true;
ignoreNextClick = true;
wasPausedWhenStarted = video.paused;
restorePlaybackRate = video.playbackRate;
video.playbackRate = 2.0;
if (wasPausedWhenStarted) {
video.play();
}
if (speedIndicator) {
speedIndicator.classList.remove('v0ck_hidden');
}
}, 500);
}
function endSpeedUp(e) {
clearTimeout(speedUpTimeout);
if (isSpeedingUp) {
isSpeedingUp = false;
video.playbackRate = restorePlaybackRate;
if (wasPausedWhenStarted) {
video.pause();
wasPausedWhenStarted = false;
}
if (speedIndicator) {
speedIndicator.classList.add('v0ck_hidden');
}
// Brief timeout before allowing normal clicking again to bypass the immediate click event
setTimeout(() => {
ignoreNextClick = false;
}, 100);
}
}
player.addEventListener('mousedown', startSpeedUp);
player.addEventListener('touchstart', startSpeedUp, { passive: true });
player.addEventListener('mouseup', endSpeedUp);
player.addEventListener('mouseleave', endSpeedUp);
player.addEventListener('touchend', endSpeedUp);
player.addEventListener('touchcancel', endSpeedUp);
this.toggleFullScreen = toggleFullScreen;
this.enterFullScreen = enterFullScreen;

View File

@@ -0,0 +1,162 @@
/**
* Backfill script: populate width/height for existing image and video items.
*
* Usage:
* node scripts/backfill_dimensions.mjs
* node scripts/backfill_dimensions.mjs --dry-run (print without writing)
* node scripts/backfill_dimensions.mjs --limit 500 (process first N items)
*
* Skips: audio, flash, PDF, YouTube items (width/height left as NULL).
* Skips: items where the physical file is missing.
* Safe to run multiple times — only processes rows where width IS NULL.
*/
import { spawn as _spawn } from 'child_process';
import db from '../src/inc/sql.mjs';
import cfg from '../src/inc/config.mjs';
import path from 'path';
import fs from 'fs/promises';
const isDryRun = process.argv.includes('--dry-run');
const limitArg = process.argv.indexOf('--limit');
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
const BATCH = 50;
// ---- tiny spawn helper (no shell) ----
const spawn = (cmd, args, opts = {}) =>
new Promise((resolve, reject) => {
const child = _spawn(cmd, args, opts);
let out = '';
let err = '';
child.stdout?.on('data', d => { out += d; });
child.stderr?.on('data', d => { err += d; });
child.on('close', code => {
if (code !== 0 && !opts.ignoreExitCode) {
const e = new Error(`${cmd} exited ${code}`);
e.stderr = err;
return reject(e);
}
resolve(out);
});
child.on('error', reject);
});
// ---- probe helpers ----
async function getDimsImage(filePath) {
try {
// magick identify -format "%wx%h" reports raw pixel dimensions
const out = await spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { ignoreExitCode: true });
// Take the first line (multi-frame GIF etc. may have many)
const line = out.trim().split('\n')[0];
const match = line.match(/^(\d+)x(\d+)$/);
if (match) return { w: parseInt(match[1], 10), h: parseInt(match[2], 10) };
} catch (_) {}
return null;
}
async function getDimsVideo(filePath) {
try {
const out = await spawn('ffprobe', [
'-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0', filePath
], { ignoreExitCode: true });
const parts = out.trim().split(',');
if (parts.length < 2) return null;
const w = parseInt(parts[0], 10);
const h = parseInt(parts[1], 10);
if (w > 0 && h > 0) return { w, h };
} catch (_) {}
return null;
}
// ---- main ----
async function run() {
console.log(`[BACKFILL] Starting dimension backfill${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (limit: ${limit})` : ''}`);
// Count pending
const [{ count }] = await db`
SELECT count(*) FROM items
WHERE width IS NULL
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
`;
const total = parseInt(count, 10);
const toProcess = limit ? Math.min(total, limit) : total;
console.log(`[BACKFILL] ${total} items need backfill${limit ? `, processing up to ${toProcess}` : ''}`);
let processed = 0;
let updated = 0;
let skipped = 0;
let failed = 0;
// NOTE: Always query at OFFSET 0 — updated rows leave the WHERE width IS NULL set,
// so the result set shrinks naturally each batch. Using a moving OFFSET would skip
// as many rows as were just updated (pagination-with-mutation bug).
while (processed < toProcess) {
const batchSize = limit ? Math.min(BATCH, toProcess - processed) : BATCH;
const rows = await db`
SELECT id, dest, mime FROM items
WHERE width IS NULL
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
ORDER BY id DESC
LIMIT ${batchSize}
`;
if (rows.length === 0) break;
for (const row of rows) {
if (processed >= toProcess) break;
processed++;
const filePath = path.join(cfg.paths.b, row.dest);
// Check file exists (may be deleted/purged)
try {
await fs.access(filePath);
} catch {
console.log(`[BACKFILL] SKIP #${row.id} — file missing: ${row.dest}`);
skipped++;
// Mark with 0 so it doesn't re-appear in future runs
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
continue;
}
let dims = null;
try {
if (row.mime.startsWith('image/')) {
dims = await getDimsImage(filePath);
} else if (row.mime.startsWith('video/')) {
dims = await getDimsVideo(filePath);
}
} catch (e) {
console.warn(`[BACKFILL] PROBE ERROR #${row.id}: ${e.message}`);
failed++;
continue;
}
if (!dims) {
console.log(`[BACKFILL] SKIP #${row.id} — no dimensions found (${row.mime})`);
skipped++;
// Mark with 0 so it doesn't re-appear in future runs and cause infinite loops
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
continue;
}
console.log(`[BACKFILL] ${isDryRun ? '[DRY]' : 'UPDATE'} #${row.id}${dims.w}×${dims.h}`);
if (!isDryRun) {
await db`UPDATE items SET width = ${dims.w}, height = ${dims.h} WHERE id = ${row.id}`;
}
updated++;
}
console.log(`[BACKFILL] Progress: ${processed} / ${toProcess} (updated=${updated}, skipped=${skipped}, failed=${failed})`);
}
console.log(`[BACKFILL] Done. updated=${updated}, skipped=${skipped}, failed=${failed}`);
process.exit(0);
}
run().catch(err => {
console.error('[BACKFILL] Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,248 @@
import db from '../src/inc/sql.mjs';
import cfg from '../src/inc/config.mjs';
import path from 'path';
import fs from 'fs/promises';
import crypto from 'crypto';
const isDryRun = process.argv.includes('--dry-run');
const limitArg = process.argv.indexOf('--limit');
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
const BATCH_SIZE = 1000;
async function run() {
console.log(`[BACKFILL] Starting long UUID migration & backfill script${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (Limit: ${limit})` : ''}`);
if (!isDryRun) {
console.log('[MIGRATION] Applying database schema migrations...');
// Drop views first because they depend on columns we want to alter
await db`DROP VIEW IF EXISTS public.items_sfw CASCADE`;
await db`DROP VIEW IF EXISTS public.items_li CASCADE`;
// Alter dest columns in items and comment_files
await db`ALTER TABLE public.items ALTER COLUMN dest TYPE character varying(255)`;
await db`ALTER TABLE public.comment_files ALTER COLUMN dest TYPE character varying(255)`;
// Recreate public.items_li view
await db`
CREATE OR REPLACE VIEW public.items_li AS
SELECT items.id,
items.src,
items.dest,
items.mime,
items.size,
items.checksum,
items.username,
items.userchannel,
items.usernetwork,
items.stamp
FROM ((public.items
JOIN public.tags_assign ta1 ON (((ta1.tag_id = 1) AND (ta1.item_id = items.id))))
JOIN public.tags_assign ta2 ON (((NOT (ta2.tag_id IN ( SELECT tags_nsfp.id
FROM public.tags_nsfp))) AND (ta2.item_id = items.id))))
WHERE items.active
GROUP BY items.id;
`;
// Recreate public.items_sfw view
await db`
CREATE OR REPLACE VIEW public.items_sfw AS
SELECT ( SELECT
CASE
WHEN (tags_assign.tag_id > 0) THEN tags_assign.tag_id
ELSE 0
END AS "case"
FROM public.tags_assign
WHERE ((tags_assign.tag_id = ANY (ARRAY[1, 2])) AND (tags_assign.item_id = items.id))) AS sfw,
( SELECT
CASE
WHEN (tags_assign.tag_id > 0) THEN 1
ELSE 0
END AS "case"
FROM public.tags_assign
WHERE ((tags_assign.tag_id IN ( SELECT tags_nsfp.id
FROM public.tags_nsfp)) AND (tags_assign.item_id = items.id))
LIMIT 1) AS nsfp,
id,
src,
dest,
mime,
size,
checksum,
username,
userchannel,
usernetwork,
stamp,
active
FROM public.items;
`;
console.log('[MIGRATION] Schema changes applied and views successfully recreated.');
}
// Map to keep track of all renamed files: old_filename -> new_filename
const renameMap = new Map();
// 1. Process items
console.log('[BACKFILL] Fetching items to process...');
const items = await db`SELECT id, dest, mime FROM items`;
console.log(`[BACKFILL] Found ${items.length} items in DB.`);
let itemsProcessed = 0;
let itemsRenamedCount = 0;
for (const item of items) {
if (limit && itemsProcessed >= limit) break;
itemsProcessed++;
// YouTube embeds store dest as "yt:VIDEO_ID" — not a real file, skip entirely
if (item.mime === 'video/youtube') {
continue;
}
const ext = path.extname(item.dest);
const base = path.basename(item.dest, ext);
// If name is already 48 characters long, skip
if (base.length === 48) {
continue;
}
const newUuid = crypto.randomBytes(24).toString('hex');
const newDest = `${newUuid}${ext}`;
renameMap.set(item.dest, newDest);
// Physical files could be in active, pending or deleted folders
const pathsToCheck = [
path.join(cfg.paths.b, item.dest),
path.join(cfg.paths.pending, 'b', item.dest),
path.join(cfg.paths.deleted, 'b', item.dest)
];
let foundPath = null;
for (const p of pathsToCheck) {
try {
const stat = await fs.lstat(p);
if (!stat.isSymbolicLink()) {
foundPath = p;
break;
}
} catch (e) {}
}
if (foundPath) {
const newPath = path.join(path.dirname(foundPath), newDest);
console.log(`[BACKFILL] Renaming item file: ${foundPath} -> ${newPath}`);
if (!isDryRun) {
await fs.rename(foundPath, newPath);
}
} else {
console.log(`[BACKFILL] [WARNING] Item physical file not found/is symlink for: ${item.dest}`);
}
if (!isDryRun) {
await db`UPDATE items SET dest = ${newDest} WHERE id = ${item.id}`;
}
itemsRenamedCount++;
if (itemsRenamedCount % BATCH_SIZE === 0) {
console.log(`[BACKFILL] Processed ${itemsRenamedCount} item renames...`);
}
}
// 2. Process comment_files
console.log('[BACKFILL] Fetching comment files to process...');
const commentFiles = await db`SELECT id, dest FROM comment_files`;
console.log(`[BACKFILL] Found ${commentFiles.length} comment files in DB.`);
let commentsProcessed = 0;
let commentsRenamedCount = 0;
for (const cf of commentFiles) {
if (limit && commentsProcessed >= limit) break;
commentsProcessed++;
const ext = path.extname(cf.dest);
const base = path.basename(cf.dest, ext);
if (base.length === 48) {
continue;
}
const newUuid = crypto.randomBytes(24).toString('hex');
const newDest = `${newUuid}${ext}`;
renameMap.set(cf.dest, newDest);
const oldPath = path.join(cfg.paths.c, cf.dest);
const newPath = path.join(cfg.paths.c, newDest);
try {
const stat = await fs.lstat(oldPath);
if (!stat.isSymbolicLink()) {
console.log(`[BACKFILL] Renaming comment file: ${oldPath} -> ${newPath}`);
if (!isDryRun) {
await fs.rename(oldPath, newPath);
}
}
} catch (e) {}
// Rename corresponding thumbnail in t/ (cf_${old_uuid}.webp -> cf_${new_uuid}.webp)
const oldThumbPath = path.join(cfg.paths.t, `cf_${base}.webp`);
const newThumbPath = path.join(cfg.paths.t, `cf_${newUuid}.webp`);
try {
await fs.access(oldThumbPath);
console.log(`[BACKFILL] Renaming comment thumbnail: ${oldThumbPath} -> ${newThumbPath}`);
if (!isDryRun) {
await fs.rename(oldThumbPath, newThumbPath);
}
} catch (e) {}
if (!isDryRun) {
await db`UPDATE comment_files SET dest = ${newDest} WHERE id = ${cf.id}`;
}
commentsRenamedCount++;
if (commentsRenamedCount % BATCH_SIZE === 0) {
console.log(`[BACKFILL] Processed ${commentsRenamedCount} comment file renames...`);
}
}
// 3. Update Symlinks in c/
console.log('[BACKFILL] Scanning /c/ directory for symlinks to update...');
try {
const filesInC = await fs.readdir(cfg.paths.c);
for (const f of filesInC) {
const filePath = path.join(cfg.paths.c, f);
try {
const stat = await fs.lstat(filePath);
if (stat.isSymbolicLink()) {
const target = await fs.readlink(filePath);
const targetBasename = path.basename(target);
if (renameMap.has(targetBasename)) {
const newTargetBasename = renameMap.get(targetBasename);
// Re-construct the symlink target path, pointing to public/b or public/c
const resolvedTargetAbs = path.resolve(path.dirname(filePath), target);
const resolvedTargetNewAbs = path.join(path.dirname(resolvedTargetAbs), newTargetBasename);
const relativeNewTarget = path.relative(path.dirname(filePath), resolvedTargetNewAbs);
console.log(`[BACKFILL] Updating symlink: ${filePath} -> ${relativeNewTarget}`);
if (!isDryRun) {
await fs.unlink(filePath);
await fs.symlink(relativeNewTarget, filePath);
}
}
}
} catch (e) {
console.error(`[BACKFILL] [ERROR] Failed processing file/symlink ${filePath}:`, e.message);
}
}
} catch (e) {
console.error('[BACKFILL] Error reading /c/ directory:', e.message);
}
console.log(`[BACKFILL] Completed. Items renamed: ${itemsRenamedCount}. Comment files renamed: ${commentsRenamedCount}.`);
process.exit(0);
}
run().catch(err => {
console.error('[BACKFILL] Fatal error:', err);
process.exit(1);
});

View File

@@ -1,7 +1,6 @@
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
import cfg from "../src/inc/config.mjs";
import { getDefaultLayout } from "../src/inc/settings.mjs";
const [username, password] = process.argv.slice(2);
@@ -48,7 +47,7 @@ async function createAdmin() {
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${getDefaultLayout() === 'modern'}, false, false)
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${(cfg.websrv.default_layout ?? 'modern') !== 'legacy'}, false, false)
`;
console.log(`--- Admin User ${username} Created Successfully ---`);

View File

@@ -0,0 +1,83 @@
/**
* fix_youtube_dest.mjs
*
* One-time fix: the backfill_uuid_filenames.mjs script failed to exclude
* YouTube items, which store dest as "yt:VIDEO_ID" rather than a real file.
* As a result, those dest values were replaced with random UUIDs, breaking
* embed URLs like youtube.com/embed/<uuid>.
*
* This script:
* 1. Finds all items with mime = 'video/youtube'
* 2. For each, re-extracts the video ID from the src column
* (src holds the original YouTube watch URL: https://www.youtube.com/watch?v=VIDEO_ID)
* 3. Restores dest to "yt:VIDEO_ID"
*
* Usage:
* node scripts/fix_youtube_dest.mjs # live run
* node scripts/fix_youtube_dest.mjs --dry-run # preview only, no DB changes
*/
import db from '../src/inc/sql.mjs';
const isDryRun = process.argv.includes('--dry-run');
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
async function run() {
console.log(`[FIX-YT-DEST] Starting${isDryRun ? ' (DRY RUN)' : ''}...`);
const rows = await db`
SELECT id, dest, src
FROM items
WHERE mime = 'video/youtube'
ORDER BY id
`;
console.log(`[FIX-YT-DEST] Found ${rows.length} YouTube item(s) to check.`);
let fixed = 0;
let skipped = 0;
let failed = 0;
for (const row of rows) {
// Already correct format
if (row.dest && row.dest.startsWith('yt:')) {
skipped++;
continue;
}
// Try to extract video ID from src URL
const match = row.src && row.src.match(ytRegex);
if (!match) {
console.warn(`[FIX-YT-DEST] [WARN] Item ${row.id}: cannot extract video ID from src="${row.src}", dest="${row.dest}" — skipping`);
failed++;
continue;
}
const videoId = match[1];
const newDest = `yt:${videoId}`;
console.log(`[FIX-YT-DEST] Item ${row.id}: "${row.dest}" → "${newDest}" (src: ${row.src})`);
if (!isDryRun) {
await db`UPDATE items SET dest = ${newDest} WHERE id = ${row.id}`;
}
fixed++;
}
console.log(`\n[FIX-YT-DEST] Done.`);
console.log(` Fixed: ${fixed}`);
console.log(` Skipped: ${skipped} (already correct)`);
console.log(` Failed: ${failed} (no video ID recoverable from src)`);
if (isDryRun) {
console.log('\n[FIX-YT-DEST] DRY RUN — no changes were written to the database.');
}
process.exit(0);
}
run().catch(err => {
console.error('[FIX-YT-DEST] Fatal error:', err);
process.exit(1);
});

View File

@@ -8,6 +8,10 @@
* node regen.mjs --all - Regenerate ALL items
* node regen.mjs --audio - Regenerate all audio items
* node regen.mjs --pdf - Regenerate all PDF items
* node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items
*
* Flash (SWF) items are always excluded — their thumbnails are set via the
* Ruffle snapshot mechanism and must never be touched by this script.
*/
import db from "../src/inc/sql.mjs";
@@ -26,14 +30,40 @@ if (args.length === 0) {
console.log(' node regen.mjs --audio - Regenerate all audio items');
console.log(' node regen.mjs --pdf - Regenerate all PDF items');
console.log(' node regen.mjs --youtube - Regenerate all YouTube thumbnails');
console.log(' node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items');
process.exit(0);
}
// Flash mime types — never regenerate these
const FLASH_MIMES = [
'application/x-shockwave-flash',
'application/vnd.adobe.flash.movie',
];
const isFlash = (mime) => FLASH_MIMES.includes(mime?.toLowerCase());
const THUMB_SIZE = 512;
const blurOnly = args.includes('--blur');
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
const regen = async (item) => {
const { id, dest, mime, src } = item;
if (isFlash(mime)) {
console.log(`[${id}] Skipped (Flash/SWF — thumbnail managed by Ruffle snapshot)`);
return;
}
if (blurOnly) {
console.log(`[${id}] Regenerating blurred thumbnail only: ${dest}`);
try {
await queue.genBlurredThumbnail(id, false);
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
} catch (err) {
console.error(`[${id}] ✗ FAILED:`, err.message || err);
}
return;
}
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
try {
@@ -49,23 +79,23 @@ const regen = async (item) => {
console.log(`[${id}] ✓ Thumbnail regenerated`);
}
// Regenerate blurred thumbnail if item has NSFW tag
const nsfw = await db`SELECT 1 FROM tags_assign WHERE item_id = ${id} AND tag_id = 2 LIMIT 1`;
if (nsfw.length > 0) {
// Regenerate blurred thumbnail unconditionally
await queue.genBlurredThumbnail(id, false);
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
}
} catch (err) {
console.error(`[${id}] ✗ FAILED:`, err.message || err);
}
};
// Shared NOT IN clause for Flash exclusion
const flashExclude = db`mime NOT IN (${db(FLASH_MIMES)})`;
try {
let items;
if (args.includes('--all')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false ORDER BY id`;
console.log(`Regenerating ALL ${items.length} items...\n`);
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND ${flashExclude} ORDER BY id`;
console.log(`Regenerating ALL ${items.length} non-Flash items...\n`);
} else if (args.includes('--audio')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
console.log(`Regenerating ${items.length} audio items...\n`);
@@ -75,6 +105,14 @@ try {
} else if (args.includes('--youtube')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ORDER BY id`;
console.log(`Regenerating ${items.length} YouTube items...\n`);
} else if (blurOnly) {
items = await db`
SELECT id, dest, mime, src
FROM items
WHERE active = true AND is_deleted = false AND ${flashExclude}
ORDER BY id
`;
console.log(`Regenerating ONLY blurred thumbnails for all ${items.length} non-Flash items...\n`);
} else {
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
if (ids.length === 0) {
@@ -97,3 +135,4 @@ try {
console.error('Fatal error:', err);
process.exit(1);
}

View File

@@ -19,7 +19,8 @@ const sendJson = (res, data, code = 200) => {
// Generate UUID using the same method as video uploads
const genuuid = async () => {
return (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
return raw.substring(0, 48);
};
export const handleAvatarUpload = async (req, res) => {
@@ -161,6 +162,14 @@ export const handleAvatarUpload = async (req, res) => {
where user_id = ${+req.session.id}
`;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
avatar_file: finalFilename,
avatar: null
});
await db`select pg_notify('profile_update', ${payloadStr})`;
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
return sendJson(res, {
success: true,
@@ -224,6 +233,13 @@ export const handleAvatarDelete = async (req, res) => {
where user_id = ${+req.session.id}
`;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
avatar_file: null
});
await db`select pg_notify('profile_update', ${payloadStr})`;
console.log('[AVATAR HANDLER] Delete complete');
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
} catch (err) {

289
src/banner_handler.mjs Normal file
View File

@@ -0,0 +1,289 @@
import cfg from "./inc/config.mjs";
import path from "path";
import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
const execFile = promisify(_execFile);
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
// Generate UUID using the same method as video uploads
const genuuid = async () => {
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
return raw.substring(0, 48);
};
export const handleBannerUpload = async (req, res) => {
console.log('[BANNER HANDLER] Upload started');
// Manual Session Lookup
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
}
if (user.length === 0) {
console.log('[BANNER HANDLER] Unauthorized - No valid session found');
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
req.session = user[0];
console.log('[BANNER HANDLER] Authorized:', req.session.user);
// CSRF validation
if (req.session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== req.session.csrf_token) {
console.warn(`[CSRF] Blocked banner upload for user ${req.session.user}. Invalid token.`);
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
}
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
console.log('[BANNER HANDLER] No boundary');
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
console.log('[BANNER HANDLER] Collecting body...');
const body = await collectBody(req);
console.log('[BANNER HANDLER] Body collected, size:', body.length);
console.log('[BANNER HANDLER] Parsing multipart...');
const parts = parseMultipart(body, boundaryMatch[1]);
const file = parts.file;
console.log('[BANNER HANDLER] Parsed, file present:', !!file, 'keys:', Object.keys(parts));
if (!file || !file.data) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// Validate file size (5MB max)
const maxSize = 5 * 1024 * 1024;
if (file.data.length > maxSize) {
return sendJson(res, {
success: false,
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
}, 400);
}
// Allowed MIME types
const allowedMimes = [
'image/gif',
'image/jpeg',
'image/jpg',
'image/png',
'image/webp'
];
// Validate MIME type from content-type header
let mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
console.log('[BANNER HANDLER] File MIME from header:', mime);
if (!allowedMimes.includes(mime)) {
return sendJson(res, {
success: false,
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
}, 400);
}
// Save to tmp and verify with file magic
console.log('[BANNER HANDLER] Generating UUID...');
const uuid = await genuuid();
const tmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_tmp`);
const finalFilename = `banner_${uuid}.webp`;
const finalPath = path.join(cfg.paths.a, finalFilename);
await fs.mkdir(cfg.paths.tmp, { recursive: true });
await fs.mkdir(cfg.paths.a, { recursive: true });
console.log('[BANNER HANDLER] Writing tmp file:', tmpPath);
await fs.writeFile(tmpPath, file.data);
// Verify MIME with file magic
console.log('[BANNER HANDLER] Checking MIME with file magic...');
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
console.log('[BANNER HANDLER] Actual MIME:', actualMime.trim());
const allowedActualMimes = [
'image/gif',
'image/jpeg',
'image/png',
'image/webp'
];
if (!allowedActualMimes.includes(actualMime.trim())) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, {
success: false,
msg: `Invalid file type detected: ${actualMime.trim()}`
}, 400);
}
// Convert to webp using ImageMagick — landscape banner crop (1200x400)
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
console.log('[BANNER HANDLER] Running magick...');
try {
await execFile('magick', [`${tmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalPath]);
} catch (err) {
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: 'Failed to process image: ' + err.message }, 500);
}
console.log('[BANNER HANDLER] Magick done, output:', finalPath);
const file_orig = parts.file_orig;
if (file_orig && file_orig.data) {
console.log('[BANNER HANDLER] Processing original file...');
const origTmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_orig_tmp`);
const finalOrigPath = path.join(cfg.paths.a, `banner_${uuid}_orig.webp`);
await fs.writeFile(origTmpPath, file_orig.data);
try {
await execFile('magick', [`${origTmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalOrigPath]);
} catch (err) {
console.error('[BANNER HANDLER] Magick error on original:', err.message);
}
await fs.unlink(origTmpPath).catch(() => { });
}
// Get current banner_file to delete old one (after magick succeeds)
let currentBanner = null;
try {
currentBanner = (await db`
select banner_file from user_options where user_id = ${+req.session.id}
`)[0]?.banner_file;
} catch (dbErr) {
console.error('[BANNER HANDLER] Could not fetch current banner (column may not exist yet):', dbErr.message);
}
// Clean up tmp file
await fs.unlink(tmpPath).catch(() => { });
// Delete old banner file if exists
if (currentBanner) {
const oldPath = path.join(cfg.paths.a, currentBanner);
await fs.unlink(oldPath).catch(() => { });
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
await fs.unlink(oldOrigPath).catch(() => { });
}
// Update database
console.log('[BANNER HANDLER] Updating database...');
try {
await db`
update user_options
set banner_file = ${finalFilename},
banner_position = ${parts.banner_position || 'center'},
banner_size = ${parts.banner_size || 'cover'}
where user_id = ${+req.session.id}
`;
const payloadStr = JSON.stringify({
user_id: +req.session.id,
user: req.session.user,
banner_file: finalFilename,
banner_position: parts.banner_position || 'center',
banner_size: parts.banner_size || 'cover'
});
await db`select pg_notify('profile_update', ${payloadStr})`;
} catch (dbErr) {
console.error('[BANNER HANDLER] DB update failed:', dbErr.message);
await fs.unlink(finalPath).catch(() => {});
return sendJson(res, { success: false, msg: 'DB error — did you run the migration? ' + dbErr.message }, 500);
}
console.log('[BANNER HANDLER] Upload complete:', finalFilename);
return sendJson(res, {
success: true,
banner_file: finalFilename,
msg: 'Banner uploaded successfully'
}, 200);
} catch (err) {
if (err.code === 'BODY_TOO_LARGE') {
return sendJson(res, { success: false, msg: 'File too large (5 MB max for banners)' }, 413);
}
console.error('[BANNER HANDLER ERROR]', err.message, err.stack);
try {
return sendJson(res, { success: false, msg: err.message || 'Banner upload failed' }, 500);
} catch (_) {}
}
};
export const handleBannerDelete = async (req, res) => {
console.log('[BANNER HANDLER] Delete started');
// Manual Session Lookup
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
}
if (user.length === 0) {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
req.session = user[0];
// CSRF validation
if (req.session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== req.session.csrf_token) {
console.warn(`[CSRF] Blocked banner delete for user ${req.session.user}. Invalid token.`);
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
}
try {
const currentBanner = (await db`
select banner_file from user_options where user_id = ${+req.session.id}
`)[0]?.banner_file;
if (currentBanner) {
const oldPath = path.join(cfg.paths.a, currentBanner);
await fs.unlink(oldPath).catch(() => { });
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
await fs.unlink(oldOrigPath).catch(() => { });
}
await db`
update user_options
set banner_file = null
where user_id = ${+req.session.id}
`;
const payloadStr = JSON.stringify({
user_id: +req.session.id,
user: req.session.user,
banner_file: null
});
await db`select pg_notify('profile_update', ${payloadStr})`;
console.log('[BANNER HANDLER] Delete complete');
return sendJson(res, { success: true, msg: 'Custom banner removed' }, 200);
} catch (err) {
console.error('[BANNER DELETE ERROR]', err);
return sendJson(res, { success: false, msg: 'Failed to remove banner' }, 500);
}
};

View File

@@ -12,7 +12,6 @@ const sendJson = (res, data, code = 200) => {
res.end(JSON.stringify(data));
};
// One-time migration: ensure comment_files table exists
db`CREATE TABLE IF NOT EXISTS public.comment_files (
id SERIAL PRIMARY KEY,
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
@@ -25,8 +24,12 @@ db`CREATE TABLE IF NOT EXISTS public.comment_files (
original_filename TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`.catch(() => { });
db`CREATE SEQUENCE IF NOT EXISTS comment_files_id_seq`.catch(() => { });
db`ALTER TABLE comment_files ALTER COLUMN id SET DEFAULT nextval('comment_files_id_seq')`.catch(() => { });
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
db`ALTER TABLE public.comment_files ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id)`.catch(() => { });
db`ALTER TABLE public.comment_files REPLICA IDENTITY DEFAULT`.catch(() => { });
/**
* Parse multipart form data supporting multiple files with the same field name.
@@ -92,12 +95,19 @@ const parseMultipartFiles = (buffer, boundary) => {
};
/**
* Build the allowed MIME list for comment uploads (image/*, video/*, audio/*).
* Filters from cfg.mimes, excluding PDF, SWF, etc.
* Build the allowed MIME list for comment uploads.
* Respects cfg.websrv.fileupload_comments_mimes (e.g. ["image", "video", "audio"]) to
* allow a different set of categories than the global allowedMimes used for page uploads.
* Falls back to image/video/audio if the setting is absent.
*/
const getAllowedCommentMimes = () => {
const allowedCats = Array.isArray(cfg.websrv.fileupload_comments_mimes)
? cfg.websrv.fileupload_comments_mimes.map(c => c.toLowerCase())
: ['image', 'video', 'audio'];
return Object.keys(cfg.mimes).filter(mime =>
mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')
allowedCats.some(cat =>
cat.includes('/') ? mime === cat : mime.startsWith(`${cat}/`)
)
);
};
@@ -174,6 +184,7 @@ export const handleCommentUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
}
const allowedMimes = getAllowedCommentMimes();
const results = [];
@@ -374,14 +385,10 @@ export const handleCommentUpload = async (req, res) => {
try {
phash = await queue.generatePHash(tmpPath);
if (phash && !linkedToExisting) {
// Check comment_files for visual duplicate
const cfItems = await db`
SELECT id, phash, dest FROM comment_files
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
`;
for (const cf of cfItems) {
if (isPhashMatch(phash, cf.phash)) {
const existingAbsPath = path.join(cfg.paths.c, cf.dest);
// Check comment_files for visual duplicate using fast SQL query
const commentMatch = await queue.checkcommentrepostphash(phash);
if (commentMatch) {
const existingAbsPath = path.join(cfg.paths.c, commentMatch.dest);
try {
const realTarget = await fs.realpath(existingAbsPath);
const destPath = path.join(cfg.paths.c, filename);
@@ -392,11 +399,9 @@ export const handleCommentUpload = async (req, res) => {
} catch (e) {
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
}
break;
}
}
// Also check items table for visual duplicate
// Also check items table for visual duplicate using fast SQL query
if (!linkedToExisting) {
const phashMatch = await queue.checkrepostphash(phash);
if (phashMatch) {
@@ -478,6 +483,74 @@ export const handleCommentUpload = async (req, res) => {
}
};
/**
* DELETE /api/v2/comments/upload/:id
* Called by the client when the user removes a staged (not-yet-posted) attachment
* from the compose area. Only deletes if the row still has comment_id = NULL
* (i.e. it was never linked to a real comment) and belongs to the requesting user.
*/
export const handleCommentUploadCancel = async (req, res, fileId) => {
// Manual session lookup (same pattern as handleCommentUpload)
if (req.cookies?.session) {
try {
const user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
if (user.length > 0) {
req.session = user[0];
}
} catch (err) {
// Session lookup failed
}
}
if (!req.session) {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
const id = parseInt(fileId, 10);
if (!id || isNaN(id)) {
return sendJson(res, { success: false, msg: 'Invalid file ID' }, 400);
}
try {
// Only allow deletion of own unlinked files
const rows = await db`
SELECT id, dest FROM comment_files
WHERE id = ${id}
AND user_id = ${req.session.id}
AND comment_id IS NULL
`;
if (!rows.length) {
// Either doesn't exist, belongs to someone else, or already linked — silently OK
return sendJson(res, { success: true });
}
const { dest } = rows[0];
await db`DELETE FROM comment_files WHERE id = ${id}`;
// Delete file and thumbnail from disk
const filePath = path.join(cfg.paths.c, dest);
const uuid = dest.split('.')[0];
const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
await fs.unlink(filePath).catch(() => {});
await fs.unlink(thumbPath).catch(() => {});
console.log(`[COMMENT_UPLOAD] Cancelled (user-removed) attachment deleted: ${dest}`);
return sendJson(res, { success: true });
} catch (err) {
console.error('[COMMENT_UPLOAD] Cancel error:', err);
return sendJson(res, { success: false, msg: 'Delete failed' }, 500);
}
};
/**
* Generate thumbnail for a comment file.
* Outputs to /t/cf_<uuid>.webp
@@ -501,7 +574,33 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
const ffThumbSize = Math.max(size, 512);
const seeks = ['20%', '40%', '60%', '80%'];
for (const seek of seeks) {
try {
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
} catch (err) {
console.warn(`[COMMENT_UPLOAD] ffmpegthumbnailer failed at ${seek} for ${filename}, trying ffmpeg fallback: ${err.message}`);
let seekSeconds = 0;
try {
const durationStr = (await queue.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', realSource])).stdout.trim();
const duration = parseFloat(durationStr);
if (!isNaN(duration) && duration > 0) {
const pct = parseFloat(seek) / 100;
seekSeconds = duration * pct;
}
} catch (probeErr) {
seekSeconds = seek === '20%' ? 2 : seek === '40%' ? 5 : seek === '60%' ? 8 : 10;
}
// Fallback to ffmpeg, overriding the color transfer characteristic to standard bt709 (1) in case of unsupported trc properties (e.g. log316)
await queue.spawn('ffmpeg', [
'-y',
'-ss', String(seekSeconds),
'-color_trc', '1',
'-i', realSource,
'-frames:v', '1',
'-update', '1',
'-vf', `scale=${ffThumbSize}:${ffThumbSize}:force_original_aspect_ratio=increase,crop=${ffThumbSize}:${ffThumbSize}`,
tmpFile
]);
}
try {
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
if (parseFloat(stdout.trim()) > 0.05) break;
@@ -541,41 +640,4 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
await fs.unlink(tmpFile).catch(() => { });
}
/**
* PHash matching helper (same logic as queue.checkrepostphash)
*/
function isPhashMatch(newHash, dbHash) {
if (!newHash || !dbHash) return false;
const newHashes = newHash.split('_');
const dbHashes = dbHash.split('_');
const THRESHOLD = 15;
const getHammingDistance = (h1, h2) => {
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
let distance = 0;
for (let i = 0; i < h1.length; i += 2) {
const v1 = parseInt(h1.substr(i, 2), 16);
const v2 = parseInt(h2.substr(i, 2), 16);
let xor = v1 ^ v2;
while (xor) {
distance += xor & 1;
xor >>= 1;
}
}
return distance;
};
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
let matches = 0;
for (let i = 0; i < framesToCompare; i++) {
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
if (dist <= THRESHOLD) matches++;
}
if (framesToCompare >= 3 && matches >= 2) return true;
if (framesToCompare === 1 && matches === 1) return true;
if (framesToCompare === 2 && matches >= 2) return true;
return false;
}

View File

@@ -0,0 +1,262 @@
/**
* dm_attachment_handler.mjs — Server-side handler for encrypted DM attachments.
*
* All uploaded content is opaque AES-GCM ciphertext — the server cannot read it.
* Files are stored at /e/<id> (configured via DM_ATTACHMENT_DIR env or /e/).
*
* Routes (registered as bypass middlewares in index.mjs):
* POST /api/dm/attachment/upload/:recipientId — receive ciphertext blob, store on disk
* GET /api/dm/attachment/:id — stream ciphertext back (auth required)
* DELETE /api/dm/attachment/:id — delete (sender only or admin)
*/
import { promises as fs } from 'fs';
import path from 'path';
import db from './inc/sql.mjs';
import lib from './inc/lib.mjs';
import cfg from './inc/config.mjs';
import { collectBody } from './inc/multipart.mjs';
import { getDmAttachments, getDmAttachmentExpiryDays } from './inc/settings.mjs';
// ─── Config ──────────────────────────────────────────────────────────────────
const ATTACHMENT_DIR = process.env.DM_ATTACHMENT_DIR || cfg.paths.e;
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB plaintext; ciphertext is ~1.33× due to base64url
// Ensure storage dir exists at startup
fs.mkdir(ATTACHMENT_DIR, { recursive: true }).catch(e =>
console.error('[DM_ATT] Failed to create attachment dir:', e.message)
);
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
export async function cleanupExpiredAttachments() {
try {
const expired = await db`
SELECT id, file_path FROM dm_attachments
WHERE expires_at < now()
`;
if (!expired.length) return;
let deleted = 0;
for (const row of expired) {
await fs.unlink(row.file_path).catch(() => {}); // ignore already-missing files
deleted++;
}
const ids = expired.map(r => r.id);
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
console.log(`[DM_ATT] Cleanup: removed ${deleted} expired attachment(s)`);
} catch (e) {
console.error('[DM_ATT] Cleanup error:', e.message);
}
}
// Run once at startup, then every 6 hours
cleanupExpiredAttachments();
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
// ─── Session helper ───────────────────────────────────────────────────────────
async function resolveSession(req) {
if (!req.cookies?.session) return null;
try {
const rows = await db`
SELECT u.id, u.login, u.user, u.admin, u.is_moderator, s.csrf_token
FROM user_sessions s
LEFT JOIN "user" u ON u.id = s.user_id
WHERE s.session = ${lib.sha256(req.cookies.session)}
LIMIT 1
`;
return rows.length ? rows[0] : null;
} catch { return null; }
}
function sendJson(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function validateCsrf(req, session) {
const token = req.headers['x-csrf-token'];
return session?.csrf_token && token && token === session.csrf_token;
}
// ─── Multipart parser (minimal — only parses fields + one binary file part) ───
function parseAttachmentMultipart(buffer, boundary) {
const boundaryBuf = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0, idx;
while ((idx = buffer.indexOf(boundaryBuf, start)) !== -1) {
if (start !== 0) segments.push(buffer.slice(start, idx - 2));
start = idx + boundaryBuf.length + 2;
}
const result = { fields: {}, file: null };
for (const seg of segments) {
const hdrEnd = seg.indexOf('\r\n\r\n');
if (hdrEnd === -1) continue;
const headers = seg.slice(0, hdrEnd).toString();
const body = seg.slice(hdrEnd + 4);
const nameM = headers.match(/name="([^"]+)"/);
if (!nameM) continue;
const hasFile = headers.includes('filename=') || headers.includes('filename*=');
if (hasFile && !result.file) {
const ctM = headers.match(/Content-Type:\s*([^\r\n]+)/i);
result.file = {
data: body,
contentType: ctM ? ctM[1].trim() : 'application/octet-stream'
};
} else if (!hasFile) {
result.fields[nameM[1]] = body.toString().trim();
}
}
return result;
}
// ─── Upload handler ───────────────────────────────────────────────────────────
export async function handleDmAttachmentUpload(req, res, recipientId) {
if (!getDmAttachments()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const session = await resolveSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
const rid = parseInt(recipientId, 10);
if (!rid || rid === session.id) return sendJson(res, { success: false, msg: 'Invalid recipient' }, 400);
// Check recipient exists
const recip = await db`SELECT id FROM "user" WHERE id = ${rid} LIMIT 1`;
if (!recip.length) return sendJson(res, { success: false, msg: 'Recipient not found' }, 404);
const ct = req.headers['content-type'] || '';
const bndM = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/);
if (!ct.includes('multipart/form-data') || !bndM) {
return sendJson(res, { success: false, msg: 'Expected multipart/form-data' }, 400);
}
let rawBody;
try {
// MAX_BYTES * 1.4 overhead for base64url encoding + multipart framing
rawBody = await collectBody(req, Math.ceil(MAX_BYTES * 1.4) + 4096);
} catch (e) {
if (e.code === 'BODY_TOO_LARGE') return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
throw e;
}
const boundary = bndM[1] || bndM[2];
const { fields, file } = parseAttachmentMultipart(rawBody, boundary);
if (!file) return sendJson(res, { success: false, msg: 'No file part found' }, 400);
const iv = (fields.iv || '').trim();
const originalName = (fields.original_name || '').slice(0, 255);
const mimeHint = (fields.mime_hint || '').slice(0, 100);
const sizeBytes = parseInt(fields.size_bytes || '0', 10) || 0;
if (!iv || iv.length > 32) return sendJson(res, { success: false, msg: 'Missing or invalid iv' }, 400);
if (file.data.length > Math.ceil(MAX_BYTES * 1.4)) {
return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
}
try {
// Insert DB record first to get an ID
const expiresAt = new Date(Date.now() + getDmAttachmentExpiryDays() * 86400000);
const [row] = await db`
INSERT INTO dm_attachments ${db({
sender_id: session.id,
recipient_id: rid,
iv,
file_path: '',
original_name: originalName,
mime_hint: mimeHint,
size_bytes: sizeBytes,
expires_at: expiresAt
})}
RETURNING id
`;
const filePath = path.join(ATTACHMENT_DIR, String(row.id));
await fs.writeFile(filePath, file.data);
// Update file_path now that we know the ID
await db`UPDATE dm_attachments SET file_path = ${filePath} WHERE id = ${row.id}`;
return sendJson(res, { success: true, id: String(row.id) });
} catch (err) {
console.error('[DM_ATT] Upload error:', err);
return sendJson(res, { success: false, msg: 'Server error' }, 500);
}
}
// ─── Download handler ─────────────────────────────────────────────────────────
export async function handleDmAttachmentDownload(req, res, attachmentId) {
const session = await resolveSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
const id = parseInt(attachmentId, 10);
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
const rows = await db`
SELECT id, sender_id, recipient_id, iv, file_path, original_name, mime_hint
FROM dm_attachments
WHERE id = ${id}
LIMIT 1
`;
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const att = rows[0];
// Only sender or recipient may download
if (att.sender_id !== session.id && att.recipient_id !== session.id && !session.admin) {
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
}
try {
const data = await fs.readFile(att.file_path);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': String(data.length),
'Cache-Control': 'private, no-store',
'X-DM-IV': att.iv,
'X-Original-Name': encodeURIComponent(att.original_name || 'attachment'),
'X-Mime-Hint': att.mime_hint || ''
});
res.end(data);
} catch (err) {
console.error('[DM_ATT] Download error:', err);
return sendJson(res, { success: false, msg: 'File not found on disk' }, 404);
}
}
// ─── Delete handler ───────────────────────────────────────────────────────────
export async function handleDmAttachmentDelete(req, res, attachmentId) {
const session = await resolveSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
const id = parseInt(attachmentId, 10);
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
const rows = await db`SELECT id, sender_id, file_path FROM dm_attachments WHERE id = ${id} LIMIT 1`;
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const att = rows[0];
if (att.sender_id !== session.id && !session.admin) {
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
}
try {
await fs.unlink(att.file_path).catch(() => {});
await db`DELETE FROM dm_attachments WHERE id = ${id}`;
return sendJson(res, { success: true });
} catch (err) {
console.error('[DM_ATT] Delete error:', err);
return sendJson(res, { success: false, msg: 'Server error' }, 500);
}
}

View File

@@ -7,6 +7,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
import crypto from "crypto";
const execFile = promisify(_execFile);
@@ -80,11 +81,11 @@ export const handleEmojiUpload = async (req, res) => {
const file = parts.file;
if (file && file.data && file.data.length > 0) {
const randSuffix = Math.random().toString(36).substring(7);
const randSuffix = crypto.randomBytes(24).toString('hex');
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
const webpFilename = `${name}_${randSuffix}.webp`;
const webpFilename = `${randSuffix}.webp`;
const webpPath = path.join(cfg.paths.emojis, webpFilename);
if (originalExt === 'webp') {
@@ -135,3 +136,133 @@ export const handleEmojiUpload = async (req, res) => {
return sendJson(res, { success: false, message: err.message }, 500);
}
};
export const handleEmojiEdit = async (req, res) => {
// Manual Session Lookup
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
}
if (user.length === 0 || !user[0].admin) {
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
}
req.session = user[0];
// CSRF validation
if (req.session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== req.session.csrf_token) {
console.warn(`[CSRF] Blocked emoji edit for user ${req.session.user}. Invalid token.`);
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
}
}
const id = req.params.id;
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
if (!boundaryMatch) {
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
}
let boundary = boundaryMatch[1].trim();
if (boundary.startsWith('"') && boundary.endsWith('"')) {
boundary = boundary.substring(1, boundary.length - 1);
}
const bodyBuffer = await collectBody(req);
const parts = parseMultipart(bodyBuffer, boundary);
const name = (parts.name || '').trim().toLowerCase();
let url = (parts.url || '').trim();
if (!name) {
return sendJson(res, { success: false, message: 'Emoji name is required' }, 400);
}
if (!/^[a-z0-9_-]+$/.test(name)) {
return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400);
}
// Fetch the current emoji record
const current = await db`SELECT id, name, url FROM custom_emojis WHERE id = ${id} LIMIT 1`;
if (current.length === 0) {
return sendJson(res, { success: false, message: 'Emoji not found' }, 404);
}
// Check name collision (allow keeping the same name)
if (name !== current[0].name) {
const conflict = await db`SELECT id FROM custom_emojis WHERE name = ${name} AND id != ${id} LIMIT 1`;
if (conflict.length > 0) {
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
}
}
const file = parts.file;
if (file && file.data && file.data.length > 0) {
const randSuffix = crypto.randomBytes(24).toString('hex');
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
const webpFilename = `${randSuffix}.webp`;
const webpPath = path.join(cfg.paths.emojis, webpFilename);
if (originalExt === 'webp') {
await fs.writeFile(webpPath, file.data);
} else {
const tmpFilename = `${name}_${randSuffix}_tmp.${originalExt}`;
const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
await fs.writeFile(tmpPath, file.data);
try {
await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
} finally {
await fs.unlink(tmpPath).catch(() => {});
}
}
const stat = await fs.stat(webpPath);
if (!stat || stat.size === 0) throw new Error('File write/conversion verification failed');
// Delete the old local file if it was a hosted emoji
if (current[0].url && current[0].url.startsWith('/s/emojis/')) {
const oldFilename = path.basename(current[0].url);
const oldPath = path.join(cfg.paths.emojis, oldFilename);
await fs.unlink(oldPath).catch(() => {});
}
url = `/s/emojis/${webpFilename}`;
}
// If no new file and no new URL, keep the existing URL
if (!url) {
url = current[0].url;
}
const updated = await db`
UPDATE custom_emojis
SET name = ${name}, url = ${url}
WHERE id = ${id}
RETURNING id, name, url
`;
await db`NOTIFY emojis_updated, '{}'`;
return sendJson(res, { success: true, emoji: updated[0] });
} catch (err) {
if (err.code === '23505') {
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
}
console.error('[EMOJI EDIT ERROR]', err);
return sendJson(res, { success: false, message: err.message }, 500);
}
};

View File

@@ -269,6 +269,9 @@ export const handleHallUpdate = async (req, res) => {
// POST /api/v2/admin/halls — create a new hall
export const handleHallCreate = async (req, res) => {
const session = await lookupSession(req);
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
// CSRF check
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
if (!token || token !== session.csrf_token) {

Some files were not shown because too many files have changed in this diff Show More