Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66386213a1 | |||
| 06dd14712d | |||
| 3e8c7c03ee | |||
| cb1b91c4c9 | |||
| 665924c84e | |||
| 22dc1cd25b | |||
| a8a093450f | |||
| 3e20394c85 | |||
| f1611be50a | |||
| 2f9e0f19e4 | |||
| ff735f49fa | |||
| 6e01e6e36f | |||
| 40ce0ccbb1 | |||
| b9c7e24757 | |||
| 9ae1d55521 | |||
| 98ee76f61c | |||
| b10ddac6f3 | |||
| 3179d15b42 | |||
| 6dd70a0fc2 | |||
| 6ab0505e7e | |||
| 162f1b40d0 | |||
| 9d2b0137da | |||
| e179eb1955 | |||
| a80e6dab96 | |||
| c22ae5bde0 | |||
| c9f5dc02fa | |||
| 4fd15ec338 | |||
| feb408338a | |||
| c7414e4b21 | |||
| 1262b15a5b | |||
| 2cd8bd91ff | |||
| 7b186dcdb2 | |||
| 973753d231 | |||
| c30b3535ae | |||
| eed8b993e1 | |||
| 9c239769e6 | |||
| 6e07194b27 | |||
| 866a8003fe | |||
| 5ab1a9614a | |||
| e45df6a769 | |||
| 99eb9735a1 | |||
| b651621cee | |||
| d3ce2e38f0 | |||
| 096786516e | |||
| ec895a285b | |||
| 20aaf183d8 | |||
| 25ab4ba949 | |||
| 6a52445987 | |||
| 63c39ebe90 | |||
| 0a21b6c8f8 | |||
| a7aabac956 | |||
| 9f5510bc04 | |||
| 54e2e8871b | |||
| c99f3e11d8 | |||
| d14feaf3eb | |||
| 5e35fd48d4 | |||
| a07c21f22b | |||
| 65fb96f675 | |||
| 20a6ffcb04 | |||
| 8518e87c8d | |||
| a3148848f2 | |||
| abedf7a789 | |||
| e61e24dfe8 | |||
| 44af6ae061 | |||
| 0739d72334 | |||
| 848055446a | |||
| 88e6b6efb9 | |||
| eea70ed62e | |||
| b6c906636c | |||
| 55b5a0ba9f | |||
| 5d60d80fc9 | |||
| 4dfb80b149 | |||
| b319f21cd4 | |||
| 28ed8d985c | |||
| 0f5e795793 | |||
| 78d14a1b56 | |||
| 3f149531da | |||
| 7a4432277d | |||
| 84eee7e13d | |||
| 2206b3eb86 | |||
| 5529724982 | |||
| 9c6070afff | |||
| 5bfa336773 | |||
| 517444f9ce | |||
| edb92ffa2a |
@@ -1,9 +1,9 @@
|
|||||||
POSTGRES_USER=f0ckm
|
POSTGRES_USER=f0ckm
|
||||||
POSTGRES_DB=f0ckm
|
POSTGRES_DB=f0ckm
|
||||||
POSTGRES_PASSWORD=f0ckm
|
POSTGRES_PASSWORD=f0ckm
|
||||||
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
# --- Nginx & Tor Profiles (Optional) ---
|
||||||
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
|
||||||
# COMPOSE_PROFILES=f0ckm-nginx
|
# COMPOSE_PROFILES=tor
|
||||||
#
|
#
|
||||||
# VIRTUAL_HOST=yourdomain.com
|
# VIRTUAL_HOST=yourdomain.com
|
||||||
# LETSENCRYPT_HOST=yourdomain.com
|
# LETSENCRYPT_HOST=yourdomain.com
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ public/tag_cache
|
|||||||
config.json
|
config.json
|
||||||
bundle.css
|
bundle.css
|
||||||
public/s/fonts/impact.woff
|
public/s/fonts/impact.woff
|
||||||
|
f0ck-svelte/*
|
||||||
20
README.md
20
README.md
@@ -78,3 +78,23 @@ now visit http://localhost:1337 in your browser, you can develop without needing
|
|||||||
## NGINX
|
## NGINX
|
||||||
|
|
||||||
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
||||||
|
|
||||||
|
## Tor Hidden Service (.onion)
|
||||||
|
|
||||||
|
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
|
||||||
|
|
||||||
|
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ./f0ckm-data/tor/f0ckm_hs/hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic Onion-Location Header
|
||||||
|
|
||||||
|
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"main": {
|
||||||
|
"onion": "http://yourgeneratedaddress.onion"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
client_max_body_size 10000M;
|
client_max_body_size 10000M;
|
||||||
client_body_timeout 120s;
|
client_body_timeout 120s;
|
||||||
client_header_timeout 120s;
|
client_header_timeout 120s;
|
||||||
|
|
||||||
|
|||||||
9
config/tor/torrc
Normal file
9
config/tor/torrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Tor configuration for f0ckm
|
||||||
|
|
||||||
|
# Enable SOCKS proxy for internal container & external connections
|
||||||
|
SocksPort 0.0.0.0:9050
|
||||||
|
|
||||||
|
# Tor Hidden Service (v3 Onion Service) configuration
|
||||||
|
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
|
||||||
|
HiddenServiceDir /var/lib/tor/f0ckm_hs/
|
||||||
|
HiddenServicePort 80 f0ckm:1337
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"domain": "example.com",
|
"domain": "example.com",
|
||||||
"regex": "example\\.com"
|
"regex": "example\\.com"
|
||||||
},
|
},
|
||||||
|
"onion": "http://your-onion-address.onion",
|
||||||
"socks": "socks5://127.0.0.1:9050",
|
"socks": "socks5://127.0.0.1:9050",
|
||||||
"mail": "admin@example.com",
|
"mail": "admin@example.com",
|
||||||
"maxfilesize": 104857600,
|
"maxfilesize": 104857600,
|
||||||
@@ -28,6 +29,11 @@
|
|||||||
],
|
],
|
||||||
"enable_pdf": false,
|
"enable_pdf": false,
|
||||||
"enable_nsfl": false,
|
"enable_nsfl": false,
|
||||||
|
"enable_private_uploads": true,
|
||||||
|
"enable_expiring_uploads": true,
|
||||||
|
"default_upload_visibility": 0,
|
||||||
|
"allow_user_upload_visibility": true,
|
||||||
|
"enable_item_slugs": true,
|
||||||
"nsfl_tag_id": 4,
|
"nsfl_tag_id": 4,
|
||||||
"allowedMimes": [
|
"allowedMimes": [
|
||||||
"audio",
|
"audio",
|
||||||
@@ -61,6 +67,7 @@
|
|||||||
"show_koepfe": false,
|
"show_koepfe": false,
|
||||||
"hide_sidebar_default": true,
|
"hide_sidebar_default": true,
|
||||||
"koepfe": [],
|
"koepfe": [],
|
||||||
|
"enable_tor_hs": true,
|
||||||
"enable_global_chat": true,
|
"enable_global_chat": true,
|
||||||
"enable_danmaku": true,
|
"enable_danmaku": true,
|
||||||
"private_messages": true,
|
"private_messages": true,
|
||||||
@@ -122,6 +129,7 @@
|
|||||||
"user_alternative_infobox": false,
|
"user_alternative_infobox": false,
|
||||||
"user_banner_enabled": true,
|
"user_banner_enabled": true,
|
||||||
"user_alternative_steuerung": false,
|
"user_alternative_steuerung": false,
|
||||||
|
"expose_repost_links_to_guests": false,
|
||||||
"enable_swf": false,
|
"enable_swf": false,
|
||||||
"swf_thumb": "/s/img/swf.png",
|
"swf_thumb": "/s/img/swf.png",
|
||||||
"enable_archive": true,
|
"enable_archive": true,
|
||||||
@@ -132,6 +140,8 @@
|
|||||||
"open_registration_require_mail_andor_token": false,
|
"open_registration_require_mail_andor_token": false,
|
||||||
"private_society": false,
|
"private_society": false,
|
||||||
"private_society_gate": "cloudflare",
|
"private_society_gate": "cloudflare",
|
||||||
|
"private_society_gate_location": "Frankfurt",
|
||||||
|
"private_society_gate_template": "_gate_template",
|
||||||
"public_nsfw": false,
|
"public_nsfw": false,
|
||||||
"paths": {
|
"paths": {
|
||||||
"images": "/b",
|
"images": "/b",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
|
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
|
||||||
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
|
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
|
||||||
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
|
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
|
||||||
|
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -49,9 +50,14 @@ services:
|
|||||||
tor:
|
tor:
|
||||||
image: dockurr/tor
|
image: dockurr/tor
|
||||||
container_name: tor
|
container_name: tor
|
||||||
|
profiles:
|
||||||
|
- tor
|
||||||
ports:
|
ports:
|
||||||
- "9050:9050"
|
- "127.0.0.1:9050:9050"
|
||||||
- "8118:8118"
|
- "127.0.0.1:8118:8118"
|
||||||
|
volumes:
|
||||||
|
- ./config/tor:/etc/tor:ro
|
||||||
|
- ./f0ckm-data/tor:/var/lib/tor:Z
|
||||||
networks:
|
networks:
|
||||||
- f0ckm-net
|
- f0ckm-net
|
||||||
restart: always
|
restart: always
|
||||||
@@ -97,19 +103,17 @@ services:
|
|||||||
f0ckm-nginx:
|
f0ckm-nginx:
|
||||||
image: nginxproxy/nginx-proxy:latest
|
image: nginxproxy/nginx-proxy:latest
|
||||||
container_name: f0ckm-nginx
|
container_name: f0ckm-nginx
|
||||||
|
network_mode: "host"
|
||||||
profiles:
|
profiles:
|
||||||
- f0ckm-nginx
|
- f0ckm-nginx
|
||||||
ports:
|
environment:
|
||||||
- "80:80"
|
- ENABLE_IPV6=true
|
||||||
- "443:443"
|
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
- certs:/etc/nginx/certs:rw
|
- certs:/etc/nginx/certs:rw
|
||||||
- vhost:/etc/nginx/vhost.d:rw
|
- vhost:/etc/nginx/vhost.d:rw
|
||||||
- html:/usr/share/nginx/html:rw
|
- html:/usr/share/nginx/html:rw
|
||||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||||
networks:
|
|
||||||
- f0ckm-net
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
acme-companion:
|
acme-companion:
|
||||||
|
|||||||
0
f0ckm-data/koepfe/.gitkeep
Normal file
0
f0ckm-data/koepfe/.gitkeep
Normal file
3
migrations/add_expiring_uploads.sql
Normal file
3
migrations/add_expiring_uploads.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Migration: Add expires_at column to items table for expiring uploads
|
||||||
|
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;
|
||||||
5
migrations/add_favorites_private.sql
Normal file
5
migrations/add_favorites_private.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add favorites_private column to user_options table
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';
|
||||||
5
migrations/add_hide_fav_badge.sql
Normal file
5
migrations/add_hide_fav_badge.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add hide_fav_badge column to user_options table
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';
|
||||||
38
migrations/add_private_unlisted_uploads.sql
Normal file
38
migrations/add_private_unlisted_uploads.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Add visibility and slug columns to items table
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
|
||||||
|
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||||
|
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||||
|
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||||
|
|
||||||
|
-- Populate missing slugs for existing items
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||||
|
new_slug text;
|
||||||
|
i integer;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
|
||||||
|
LOOP
|
||||||
|
new_slug := '';
|
||||||
|
FOR i IN 1..11 LOOP
|
||||||
|
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
|
||||||
|
END LOOP;
|
||||||
|
BEGIN
|
||||||
|
UPDATE items SET slug = new_slug WHERE id = r.id;
|
||||||
|
EXIT;
|
||||||
|
EXCEPTION WHEN unique_violation THEN
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Fix any items with NULL visibility (should default to 0 = public)
|
||||||
|
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||||
|
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
|
||||||
|
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;
|
||||||
@@ -114,6 +114,7 @@ DROP INDEX IF EXISTS public.idx_items_username;
|
|||||||
DROP INDEX IF EXISTS public.idx_items_pinned_id;
|
DROP INDEX IF EXISTS public.idx_items_pinned_id;
|
||||||
DROP INDEX IF EXISTS public.idx_items_is_purged;
|
DROP INDEX IF EXISTS public.idx_items_is_purged;
|
||||||
DROP INDEX IF EXISTS public.idx_items_is_deleted;
|
DROP INDEX IF EXISTS public.idx_items_is_deleted;
|
||||||
|
DROP INDEX IF EXISTS public.idx_items_slug;
|
||||||
DROP INDEX IF EXISTS public.idx_items_active_id;
|
DROP INDEX IF EXISTS public.idx_items_active_id;
|
||||||
DROP INDEX IF EXISTS public.idx_global_chat_created_at;
|
DROP INDEX IF EXISTS public.idx_global_chat_created_at;
|
||||||
DROP INDEX IF EXISTS public.idx_discord_queue_sent;
|
DROP INDEX IF EXISTS public.idx_discord_queue_sent;
|
||||||
@@ -910,12 +911,18 @@ CREATE TABLE public.items (
|
|||||||
original_filename text,
|
original_filename text,
|
||||||
title text,
|
title text,
|
||||||
width integer,
|
width integer,
|
||||||
height integer
|
height integer,
|
||||||
|
visibility smallint DEFAULT 0 NOT NULL,
|
||||||
|
slug character varying(16)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.items OWNER TO f0ckm;
|
ALTER TABLE public.items OWNER TO f0ckm;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||||
|
COMMENT ON COLUMN public.items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm
|
-- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -1498,12 +1505,25 @@ CREATE TABLE public.user_options (
|
|||||||
receive_user_notifications boolean DEFAULT true,
|
receive_user_notifications boolean DEFAULT true,
|
||||||
do_not_disturb boolean DEFAULT false,
|
do_not_disturb boolean DEFAULT false,
|
||||||
comment_display_mode integer DEFAULT 1,
|
comment_display_mode integer DEFAULT 1,
|
||||||
force_comment_display_mode integer DEFAULT 0
|
force_comment_display_mode integer DEFAULT 0,
|
||||||
|
favorites_private boolean DEFAULT false,
|
||||||
|
hide_fav_badge boolean DEFAULT false,
|
||||||
|
default_upload_visibility smallint DEFAULT 0,
|
||||||
|
banner_file character varying(255) DEFAULT NULL::character varying,
|
||||||
|
banner_position character varying(50) DEFAULT 'center'::character varying,
|
||||||
|
banner_size character varying(50) DEFAULT 'cover'::character varying
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.user_options OWNER TO f0ckm;
|
ALTER TABLE public.user_options OWNER TO f0ckm;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/';
|
||||||
|
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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm
|
-- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2162,6 +2182,13 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat
|
|||||||
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
|
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_items_slug; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_items_slug ON public.items USING btree (slug);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm
|
-- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2944,4 +2971,9 @@ 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);
|
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
|
||||||
|
|
||||||
|
-- User Banners
|
||||||
|
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||||
|
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
||||||
|
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
||||||
|
|
||||||
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
||||||
|
|||||||
2
migrations/fix_null_visibility.sql
Normal file
2
migrations/fix_null_visibility.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Fix items with NULL visibility (should default to 0 = public)
|
||||||
|
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -75,9 +75,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -492,9 +492,9 @@
|
|||||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||||
},
|
},
|
||||||
"brace-expansion": {
|
"brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1237,7 +1237,7 @@ html[theme='95'] .item-main-content .metadata {
|
|||||||
|
|
||||||
.notif-count {
|
.notif-count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 0;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
background: var(--badge-nsfw);
|
background: var(--badge-nsfw);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -1650,6 +1650,10 @@ body.layout-modern .global-sidebar-right {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.sidebar-right-hidden #sidebar-drag-zone {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
/* Always show the handle on touch devices (no hover state available) */
|
/* Always show the handle on touch devices (no hover state available) */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
#sidebar-drag-zone {
|
#sidebar-drag-zone {
|
||||||
@@ -1984,7 +1988,7 @@ body.layout-modern .global-sidebar-right {
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: linear-gradient(to bottom, transparent, rgba(28, 27, 27, 0.8));
|
background: linear-gradient(to bottom, transparent, var(--bg));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2119,43 +2123,29 @@ body.layout-modern .global-sidebar-right {
|
|||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Stacking for Legacy Mode (max-width: 999px) */
|
/* Mobile sidebar (max-width: 999px): sidebar fixed, capped to viewport width */
|
||||||
@media (max-width: 999px) {
|
@media (max-width: 999px) {
|
||||||
.item-layout-container {
|
/* Sidebar itself: cap width to viewport, always right-anchored */
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-layout-container .item-main-content {
|
|
||||||
order: 1;
|
|
||||||
/* Main content (video) on top */
|
|
||||||
/* padding: 10px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-sidebar-left {
|
|
||||||
order: 2;
|
|
||||||
/* Comments/Tags on bottom */
|
|
||||||
/* padding: 10px; */
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Overrides if necessary — most styles now universal */
|
|
||||||
.item-sidebar-right,
|
.item-sidebar-right,
|
||||||
.index-sidebar-right,
|
.index-sidebar-right,
|
||||||
.global-sidebar-right {
|
.global-sidebar-right {
|
||||||
width: 280px !important;
|
width: min(280px, 80vw) !important;
|
||||||
|
right: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.sidebar-right-hidden .item-sidebar-right,
|
body.sidebar-right-hidden .item-sidebar-right,
|
||||||
body.sidebar-right-hidden .index-sidebar-right,
|
body.sidebar-right-hidden .index-sidebar-right,
|
||||||
body.sidebar-right-hidden .global-sidebar-right {
|
body.sidebar-right-hidden .global-sidebar-right {
|
||||||
right: -280px !important;
|
right: calc(-1 * min(280px, 80vw)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: sidebar is an overlay, so pagination always spans full viewport */
|
/* Mobile: pagination centered in available space (left of sidebar) */
|
||||||
.pagination-container-fluid,
|
.pagination-container-fluid {
|
||||||
|
right: min(280px, 80vw) !important;
|
||||||
|
transition: right 0.3s ease-in-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar hidden: pagination expands to full viewport width */
|
||||||
body.sidebar-right-hidden .pagination-container-fluid {
|
body.sidebar-right-hidden .pagination-container-fluid {
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2156,99 @@ body.layout-modern .global-sidebar-right {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 600–999px: sidebar is attached to the grid, same as desktop but with mobile width.
|
||||||
|
Grid reserves space for the sidebar so content is never hidden behind it. */
|
||||||
|
@media (min-width: 600px) and (max-width: 999px) {
|
||||||
|
.index-layout-wrapper {
|
||||||
|
padding-right: min(280px, 80vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sidebar-right-hidden .index-layout-wrapper {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic pagewrapper (non-index, non-item pages) also reserves space */
|
||||||
|
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
|
||||||
|
padding-right: min(280px, 80vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sidebar-right-hidden
|
||||||
|
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restore layout-modern item stacking at 600–999px.
|
||||||
|
Its CSS grid only activates at ≥1000px; without flex-column here
|
||||||
|
the layout falls back to broken block flow. */
|
||||||
|
body.layout-modern .item-layout-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.layout-modern .item-layout-container .item-main-content {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.layout-modern .item-sidebar-left {
|
||||||
|
order: 2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy layout: 2-column grid with mobile sidebar width. */
|
||||||
|
body.layout-legacy .item-layout-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) min(280px, 80vw);
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.layout-legacy.sidebar-right-hidden .item-layout-container {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small screens (≤599px): sidebar is always a pure overlay — never reserve space for it.
|
||||||
|
Wrappers stay at full width regardless of sidebar state; no transitions needed here.
|
||||||
|
Item view also switches to stacked layout here (not at 999px). */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
/* Item view: stack content above comments/sidebar-left */
|
||||||
|
.item-layout-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-layout-container .item-main-content {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-sidebar-left {
|
||||||
|
order: 2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-layout-wrapper,
|
||||||
|
.item-layout-container,
|
||||||
|
.pagewrapper,
|
||||||
|
body.sidebar-right-hidden .index-layout-wrapper,
|
||||||
|
body.sidebar-right-hidden .item-layout-container,
|
||||||
|
body.sidebar-right-hidden .pagewrapper {
|
||||||
|
padding-right: 5px !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container-fluid,
|
||||||
|
body.sidebar-right-hidden .pagination-container-fluid {
|
||||||
|
right: 0 !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.item-layout-container .item-main-content>._204863,
|
.item-layout-container .item-main-content>._204863,
|
||||||
.item-layout-container .item-main-content>.item_title,
|
.item-layout-container .item-main-content>.item_title,
|
||||||
@@ -2958,7 +3041,8 @@ body.layout-legacy .scroll-nav-wrapper {
|
|||||||
right: -45px;
|
right: -45px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 0;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.layout-legacy .scroll-to-bottom {
|
body.layout-legacy .scroll-to-bottom {
|
||||||
@@ -3064,7 +3148,7 @@ body.layout-legacy .scroll-to-bottom svg {
|
|||||||
color: #888;
|
color: #888;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 2px 10px;
|
gap: 2px 10px;
|
||||||
@@ -3072,8 +3156,9 @@ body.layout-legacy .scroll-to-bottom svg {
|
|||||||
|
|
||||||
.comment-header-left {
|
.comment-header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 2px 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
@@ -3196,9 +3281,13 @@ body.layout-legacy .scroll-to-bottom svg {
|
|||||||
.comment-author {
|
.comment-author {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-right: 8px;
|
margin-right: 2px;
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
word-break: break-word;
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-time {
|
.comment-time {
|
||||||
@@ -3256,6 +3345,8 @@ body.layout-legacy .scroll-to-bottom svg {
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-context-link:hover,
|
.comment-context-link:hover,
|
||||||
@@ -3319,10 +3410,7 @@ body.layout-legacy .scroll-to-bottom svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment-backlinks {
|
.comment-backlinks {
|
||||||
display: inline-block;
|
display: contents;
|
||||||
font-size: 0.85em;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-header-actions {
|
.comments-header-actions {
|
||||||
@@ -3619,6 +3707,7 @@ html[theme="f0ck95"] img.avatar {
|
|||||||
html[theme="f0ck95"] ._204863 {
|
html[theme="f0ck95"] ._204863 {
|
||||||
content: " ";
|
content: " ";
|
||||||
background: -webkit-linear-gradient(left, #08216b, #a5cef7);
|
background: -webkit-linear-gradient(left, #08216b, #a5cef7);
|
||||||
|
background: linear-gradient(90deg, #08216b, #a5cef7);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -3628,6 +3717,31 @@ html[theme="f0ck95"] ._204863 {
|
|||||||
border-right: outset 2px silver;
|
border-right: outset 2px silver;
|
||||||
border-bottom: outset 2px silver;
|
border-bottom: outset 2px silver;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme="f0ck95"] ._204863 .location {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme="f0ck95"] ._204863 .location::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[theme="f0ck95"] .iconset#a_delete {
|
html[theme="f0ck95"] .iconset#a_delete {
|
||||||
@@ -3739,8 +3853,8 @@ html[theme='f0ck95d'] {
|
|||||||
|
|
||||||
html[theme="f0ck95d"] .item_title {
|
html[theme="f0ck95d"] .item_title {
|
||||||
background: black;
|
background: black;
|
||||||
border-left: inset 3px silver;
|
border-left: 2px solid gray;
|
||||||
border-right: outset 3px silver;
|
border-right: 2px solid gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[theme="f0ck95d"] .err {
|
html[theme="f0ck95d"] .err {
|
||||||
@@ -3755,15 +3869,35 @@ html[theme="f0ck95d"] ._error_topbar {
|
|||||||
html[theme="f0ck95d"] ._204863 {
|
html[theme="f0ck95d"] ._204863 {
|
||||||
content: " ";
|
content: " ";
|
||||||
background: -webkit-linear-gradient(left, #0e111a, #073c71);
|
background: -webkit-linear-gradient(left, #0e111a, #073c71);
|
||||||
|
background: linear-gradient(90deg, #0e111a, #073c71);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: white;
|
color: white;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-left: inset 2px silver;
|
border: 2px solid gray;
|
||||||
border-top: inset 2px silver;
|
|
||||||
border-right: outset 2px silver;
|
|
||||||
border-bottom: outset 2px silver;
|
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme="f0ck95d"] ._204863 .location {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme="f0ck95d"] ._204863 .location::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[theme="f0ck95d"] .embed-responsive-image {
|
html[theme="f0ck95d"] .embed-responsive-image {
|
||||||
@@ -3772,15 +3906,13 @@ html[theme="f0ck95d"] .embed-responsive-image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[theme="f0ck95d"] .media-object {
|
html[theme="f0ck95d"] .media-object {
|
||||||
border-left: inset 3px silver;
|
border-left: 2px solid gray;
|
||||||
border-right: outset 3px silver;
|
border-right: 2px solid gray;
|
||||||
|
background: black;
|
||||||
|
border-bottom: 2px solid gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
|
||||||
background: black;
|
|
||||||
border-left: inset 1px silver;
|
|
||||||
border-right: outset 1px silver;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck95d"] .embed-responsive-image {
|
html[theme="f0ck95d"] .embed-responsive-image {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -3896,6 +4028,11 @@ html[theme="f0ck95d"] .admin-search button {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
||||||
|
background: black;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/* fullscreen */
|
/* fullscreen */
|
||||||
html[res="fullscreen"] ._204863 {
|
html[res="fullscreen"] ._204863 {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
@@ -4488,16 +4625,17 @@ span.placeholder {
|
|||||||
transition: right 0.3s ease-in-out;
|
transition: right 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar hidden — reclaim the full width */
|
|
||||||
body.sidebar-right-hidden .pagination-container-fluid {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern layout: wider sidebar needs wider offset */
|
/* Modern layout: wider sidebar needs wider offset */
|
||||||
body.layout-modern .pagination-container-fluid {
|
body.layout-modern .pagination-container-fluid {
|
||||||
right: 400px;
|
right: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar hidden — reclaim the full width */
|
||||||
|
body.sidebar-right-hidden .pagination-container-fluid,
|
||||||
|
body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-pagination.fixed-pagination {
|
.bottom-pagination.fixed-pagination {
|
||||||
margin: 10px auto;
|
margin: 10px auto;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -4506,7 +4644,6 @@ body.layout-modern .pagination-container-fluid {
|
|||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
max-width: fit-content;
|
max-width: fit-content;
|
||||||
}
|
}
|
||||||
@@ -4719,7 +4856,7 @@ body.layout-modern .pagination-container-fluid {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1000px) {
|
||||||
|
|
||||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||||
.index-layout-wrapper {
|
.index-layout-wrapper {
|
||||||
@@ -5273,6 +5410,10 @@ html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-modal-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
html.image-expand-active .image-modal-btn {
|
html.image-expand-active .image-modal-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
@@ -7196,6 +7337,11 @@ span#favs {
|
|||||||
border-top: 1px solid var(--nav-border-color);
|
border-top: 1px solid var(--nav-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body.layout-modern span#favs {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
span#favs[hidden] {
|
span#favs[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -7702,6 +7848,7 @@ div.posts>a.thumb > p {
|
|||||||
bottom: 5px;
|
bottom: 5px;
|
||||||
left: 5px;
|
left: 5px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make individual item entry subtler and faster for infinite scroll */
|
/* Make individual item entry subtler and faster for infinite scroll */
|
||||||
@@ -9495,77 +9642,16 @@ body.layout-legacy .comment-content img.emoji {
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flashGreen {
|
|
||||||
0% {
|
|
||||||
background-color: rgba(var(--accent-rgb), 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-color: var(--item-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes newItemFlash {
|
|
||||||
0% {
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
background: rgba(var(--accent-rgb), 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
60% {
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
background: rgba(var(--accent-rgb), 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
border-left-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-item-fade {
|
.new-item-fade {
|
||||||
animation: newItemFlash 2.5s ease forwards;
|
box-shadow: inset 3px 0px 0px var(--accent);
|
||||||
border-left: 3px solid transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-activity .new-item-fade {
|
.sidebar-activity .new-item-fade {
|
||||||
border-left: none;
|
box-shadow: none;
|
||||||
animation: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comment posted by the current user — fades in + accent border that auto-disappears */
|
|
||||||
@keyframes commentEnter {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(6px);
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
background: rgba(var(--accent-rgb), 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
background: rgba(var(--accent-rgb), 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
60% {
|
|
||||||
background: rgba(var(--accent-rgb), 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
70% {
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
border-left-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-entering {
|
|
||||||
animation: commentEnter 7.5s ease forwards;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-highlighted {
|
.comment-highlighted {
|
||||||
box-shadow: inset 3px 0px 0px var(--accent) !important;
|
box-shadow: inset 3px 0px 0px var(--accent) !important;
|
||||||
@@ -9747,6 +9833,20 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* At 600–999px: center the button in the free space left of the sidebar.
|
||||||
|
Shift left by half the sidebar width so transform:translateX(-50%) lands
|
||||||
|
at the center of the available area rather than the full viewport. */
|
||||||
|
@media (min-width: 600px) and (max-width: 999px) {
|
||||||
|
.mobile-scroll-to-top {
|
||||||
|
left: calc(50% - min(140px, 40vw));
|
||||||
|
transition: left 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sidebar-right-hidden .mobile-scroll-to-top {
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.steuerung.steuerung-icon .fa-solid {
|
.steuerung.steuerung-icon .fa-solid {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
@@ -9888,14 +9988,6 @@ html[theme="orange"] .image-brand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
|
||||||
border-bottom: outset 2px silver;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
|
|
||||||
border-bottom: outset 2px silver;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fade in video on page load */
|
/* Fade in video on page load */
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -9923,7 +10015,7 @@ html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1000px) {
|
||||||
.pagewrapper {
|
.pagewrapper {
|
||||||
padding-right: 300px;
|
padding-right: 300px;
|
||||||
}
|
}
|
||||||
@@ -9941,7 +10033,7 @@ html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
|
|||||||
Since Index and Item pages have their own layout wrappers that handle space reservation,
|
Since Index and Item pages have their own layout wrappers that handle space reservation,
|
||||||
we disable the generic pagewrapper padding on those pages to avoid "double padding" or broken expansion.
|
we disable the generic pagewrapper padding on those pages to avoid "double padding" or broken expansion.
|
||||||
*/
|
*/
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1000px) {
|
||||||
|
|
||||||
.pagewrapper:has(.index-layout-wrapper),
|
.pagewrapper:has(.index-layout-wrapper),
|
||||||
.pagewrapper:has(.item-layout-container),
|
.pagewrapper:has(.item-layout-container),
|
||||||
@@ -10090,7 +10182,7 @@ ol {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 0 15px;
|
padding: 0 5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -10207,7 +10299,7 @@ ol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile: ≤768px ── */
|
/* ── Mobile: ≤768px ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 800px) {
|
||||||
|
|
||||||
/* Show hamburger */
|
/* Show hamburger */
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
@@ -11070,8 +11162,7 @@ div.posts>a::after {
|
|||||||
|
|
||||||
|
|
||||||
.pin-indicator {
|
.pin-indicator {
|
||||||
position: absolute;
|
position: relative;
|
||||||
top: 5px;
|
|
||||||
left: 5px;
|
left: 5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
@@ -11393,11 +11484,12 @@ body.layout-legacy .navbar-header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* layout-modern: restore sidebar right-padding AND block-flow for generic pages
|
/* layout-modern: restore sidebar right-padding AND block-flow for generic pages
|
||||||
(profile, settings, subscriptions, etc.) — identical behaviour to layout-legacy.
|
(profile, settings, subscriptions, tags, etc.) — identical behaviour to layout-legacy.
|
||||||
Specialized pages (index, item, meme) manage their own layout, so exclude them. */
|
Specialized pages (index, item, meme) manage their own layout, so exclude them.
|
||||||
@media (min-width: 1200px) {
|
Must be ≥1000px so the gap at 1000–1199px is covered. */
|
||||||
|
@media (min-width: 1000px) {
|
||||||
body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
|
body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
|
||||||
padding-right: 400px;
|
padding-right: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.layout-modern.sidebar-right-hidden>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
|
body.layout-modern.sidebar-right-hidden>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
|
||||||
@@ -12966,10 +13058,8 @@ body.layout-modern .tag-ac-input {
|
|||||||
Full-height chat layout — conversation page
|
Full-height chat layout — conversation page
|
||||||
══════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* ── DESKTOP (> 768px): same pattern as the sidebar ───────
|
/* ── UNIVERSAL: position:fixed takes it out of flow entirely ───────
|
||||||
position:fixed takes it out of flow entirely — no pagewrapper
|
No pagewrapper padding, no body scroll involvement, just viewport-anchored. */
|
||||||
padding, no body scroll involvement, just viewport-anchored. */
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.messages-convo-page {
|
.messages-convo-page {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: var(--navbar-h, 50px) !important;
|
top: var(--navbar-h, 50px) !important;
|
||||||
@@ -12982,26 +13072,21 @@ body.layout-modern .tag-ac-input {
|
|||||||
transition: right 0.3s ease-in-out;
|
transition: right 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
/* Desktop: 300px reserved space (both layouts) */
|
||||||
|
@media (min-width: 1000px) {
|
||||||
body:not(.sidebar-right-hidden) .messages-convo-page {
|
body:not(.sidebar-right-hidden) .messages-convo-page {
|
||||||
right: 300px !important;
|
right: 300px !important;
|
||||||
}
|
}
|
||||||
body.layout-modern:not(.sidebar-right-hidden) .messages-convo-page {
|
}
|
||||||
right: 400px !important;
|
|
||||||
|
/* Attached Mobile: mobile sidebar width reserved space */
|
||||||
|
@media (min-width: 600px) and (max-width: 999px) {
|
||||||
|
body:not(.sidebar-right-hidden) .messages-convo-page {
|
||||||
|
right: min(280px, 80vw) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kill the layout-legacy pagewrapper min-height:100vh that causes body overflow */
|
/* Kill the pagewrapper min-height and overflow so body doesn't scroll behind it */
|
||||||
body:has(.messages-convo-page) .pagewrapper {
|
|
||||||
min-height: 0 !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── MOBILE (≤ 768px): position:fixed pagewrapper ─────────
|
|
||||||
Works on mobile; locks the pagewrapper to the viewport. */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
|
|
||||||
html:has(.messages-convo-page),
|
html:has(.messages-convo-page),
|
||||||
body:has(.messages-convo-page) {
|
body:has(.messages-convo-page) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -13009,16 +13094,9 @@ body.layout-modern .tag-ac-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body:has(.messages-convo-page) .pagewrapper {
|
body:has(.messages-convo-page) .pagewrapper {
|
||||||
position: fixed;
|
min-height: 0 !important;
|
||||||
top: var(--navbar-h, 50px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0 !important;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared: convo pane is a flex column ──────────────────
|
/* ── Shared: convo pane is a flex column ──────────────────
|
||||||
@@ -13028,7 +13106,8 @@ body.layout-modern .tag-ac-input {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
width: 100%;
|
/* Removed width: 100% here because it overconstrains the fixed element
|
||||||
|
against left:0 and right:300px, causing negative margins and overlap. */
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -16060,12 +16139,21 @@ body.scroller-active #gchat-reopen-bubble {
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-table td a {
|
.info-table td a {
|
||||||
color: var(--accent, #9f0);
|
color: var(--accent, #9f0);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
#info-modal .modal-content .info-table td a {
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-table td a:hover {
|
.info-table td a:hover {
|
||||||
@@ -16082,6 +16170,26 @@ body.scroller-active #gchat-reopen-bubble {
|
|||||||
color: #eee;
|
color: #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-hash-codeblock {
|
||||||
|
margin: 2px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color, #eee);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-hash-codeblock code {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.info-table tr:last-child th,
|
.info-table tr:last-child th,
|
||||||
.info-table tr:last-child td {
|
.info-table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
/* Meme Creator Styles */
|
/* Meme Creator Styles */
|
||||||
|
|
||||||
.meme-creator-container {
|
.meme-creator-container {
|
||||||
|
box-sizing: border-box !important;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 1200px;
|
max-width: 1100px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
|
|
||||||
.canvas-wrapper {
|
.canvas-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
max-width: 650px;
|
||||||
min-width: 0; /* Allow shrinking */
|
min-width: 0; /* Allow shrinking */
|
||||||
background: #000;
|
background: #000;
|
||||||
border: 2px solid var(--nav-bg, #2b2b2b);
|
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||||
@@ -238,22 +240,49 @@ canvas#memeCanvas {
|
|||||||
color: var(--white, #fff);
|
color: var(--white, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Stacking - Simple 2-Row Layout */
|
/* Mobile Stacking - Clean Flexbox Column Layout */
|
||||||
@media (max-width: 950px) {
|
@media (max-width: 950px) {
|
||||||
.meme-editor-layout {
|
.meme-editor-layout {
|
||||||
display: grid !important;
|
display: flex !important;
|
||||||
grid-template-columns: 1fr !important;
|
flex-direction: column !important;
|
||||||
grid-template-rows: 0.6fr auto !important;
|
align-items: center !important;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.meme-controls {
|
.meme-controls {
|
||||||
|
box-sizing: border-box !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
grid-row: 2;
|
max-width: 500px !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
.canvas-wrapper {
|
.canvas-wrapper {
|
||||||
|
box-sizing: border-box !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
grid-row: 1;
|
max-width: 650px !important;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
|
||||||
|
This prevents the canvas from shrinking too much when the sidebar takes space. */
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
body:not(.sidebar-right-hidden) .meme-editor-layout {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
body:not(.sidebar-right-hidden) .meme-controls {
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 500px !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
body:not(.sidebar-right-hidden) .canvas-wrapper {
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 650px !important;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
@@ -294,20 +323,19 @@ canvas#memeCanvas {
|
|||||||
|
|
||||||
/* Sidebar space reservation for meme pages */
|
/* Sidebar space reservation for meme pages */
|
||||||
.meme-layout-wrapper {
|
.meme-layout-wrapper {
|
||||||
|
box-sizing: border-box !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: padding-right 0.3s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1000px) {
|
||||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||||
.meme-layout-wrapper {
|
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
|
||||||
padding-right: 300px;
|
padding-right: 300px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Collapse reserved space when sidebar is hidden */
|
/* Collapse reserved space when sidebar is hidden */
|
||||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||||
padding-right: 0;
|
padding-right: 0 !important;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .global-rating-section,
|
.upload-form.shitpost-mode-active .global-rating-section,
|
||||||
|
.upload-form.shitpost-mode-active .global-visibility-section,
|
||||||
|
.upload-form.shitpost-mode-active .global-expiry-section,
|
||||||
.upload-form.shitpost-mode-active .global-comment-section,
|
.upload-form.shitpost-mode-active .global-comment-section,
|
||||||
.upload-form.shitpost-mode-active .global-tag-section {
|
.upload-form.shitpost-mode-active .global-tag-section {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -419,6 +421,26 @@
|
|||||||
border-color: var(--badge-nsfl);
|
border-color: var(--badge-nsfl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Visibility Container - Only checked option is colored, unchecked options are gray */
|
||||||
|
.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw {
|
||||||
|
background: rgba(81, 207, 102, 0.2);
|
||||||
|
color: #51cf66;
|
||||||
|
border-color: rgba(81, 207, 102, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw {
|
||||||
|
background: rgba(255, 187, 51, 0.2);
|
||||||
|
color: #ffbb33;
|
||||||
|
border-color: rgba(255, 187, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl {
|
||||||
|
background: rgba(255, 68, 68, 0.2);
|
||||||
|
color: #ff4444;
|
||||||
|
border-color: rgba(255, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.item-rating-label:hover {
|
.item-rating-label:hover {
|
||||||
@@ -662,6 +684,32 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
|
||||||
|
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
|
||||||
|
background: rgba(81, 207, 102, 0.2);
|
||||||
|
border-color: #51cf66;
|
||||||
|
color: #51cf66;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
|
||||||
|
background: rgba(255, 187, 51, 0.2);
|
||||||
|
border-color: #ffbb33;
|
||||||
|
color: #ffbb33;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
|
||||||
|
background: rgba(255, 68, 68, 0.2);
|
||||||
|
border-color: #ff4444;
|
||||||
|
color: #ff4444;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Tags */
|
/* Tags */
|
||||||
.tag-input-container {
|
.tag-input-container {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|||||||
5
public/s/img/ghost_fav.svg
Normal file
5
public/s/img/ghost_fav.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<rect width="100" height="100" fill="#242526"/>
|
||||||
|
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
|
||||||
|
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
@@ -1,18 +1,24 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Helper to get dynamic context
|
// Helper to get dynamic context
|
||||||
const getContext = () => {
|
const getContext = () => {
|
||||||
const idLink = document.querySelector("a.id-link");
|
const commentsEl = document.querySelector("#comments-container");
|
||||||
if (!idLink) return null;
|
const favoEl = document.querySelector("#a_favo");
|
||||||
|
const infoEl = document.querySelector("#a_info");
|
||||||
|
const idLinkEl = document.querySelector("a.id-link");
|
||||||
|
|
||||||
|
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||||
|
if (!rawId) return null;
|
||||||
|
|
||||||
const tagsContainer = document.querySelector("#tags");
|
const tagsContainer = document.querySelector("#tags");
|
||||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||||
const usernameEl = document.querySelector("a#a_username");
|
const usernameEl = document.querySelector("a#a_username");
|
||||||
return {
|
return {
|
||||||
postid: +idLink.innerText,
|
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||||
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
|
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
|
||||||
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
||||||
poster: (usernameEl?.dataset?.username || '').trim() || null,
|
poster: (usernameEl?.dataset?.username || '').trim() || null,
|
||||||
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
|
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
|
||||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class CommentSystem {
|
|||||||
document.body.classList.remove('sidebar-left-hidden');
|
document.body.classList.remove('sidebar-left-hidden');
|
||||||
const layout = this.container.closest('.item-layout-container');
|
const layout = this.container.closest('.item-layout-container');
|
||||||
if (layout) layout.classList.remove('sidebar-hidden');
|
if (layout) layout.classList.remove('sidebar-hidden');
|
||||||
} else if (isHidden) {
|
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
|
||||||
this.container.classList.add('faded-out');
|
this.container.classList.add('faded-out');
|
||||||
this.container.style.display = 'none';
|
this.container.style.display = 'none';
|
||||||
document.body.classList.add('sidebar-left-hidden');
|
document.body.classList.add('sidebar-left-hidden');
|
||||||
@@ -596,7 +596,7 @@ class CommentSystem {
|
|||||||
if (cached) cached.content = fullContent;
|
if (cached) cached.content = fullContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
contentEl.dataset.raw = this.escapeHtml(fullContent);
|
contentEl.dataset.raw = fullContent;
|
||||||
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
||||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||||
CommentSystem.playEmojiVideos(contentEl);
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
@@ -622,6 +622,7 @@ class CommentSystem {
|
|||||||
|
|
||||||
const contentEl = el.querySelector('.comment-content');
|
const contentEl = el.querySelector('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
|
contentEl.dataset.raw = data.content;
|
||||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
|
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
|
||||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||||
CommentSystem.playEmojiVideos(contentEl);
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
@@ -857,26 +858,14 @@ class CommentSystem {
|
|||||||
* border-right) is fully restored after the effect completes.
|
* border-right) is fully restored after the effect completes.
|
||||||
*
|
*
|
||||||
* @param {Element} el - The .comment element to animate.
|
* @param {Element} el - The .comment element to animate.
|
||||||
* @param {boolean} entering - True to also add 'comment-entering' (own post);
|
* @param {boolean} entering - Unused (kept for compatibility).
|
||||||
* false for 'new-item-fade' only (live others).
|
|
||||||
*/
|
*/
|
||||||
static _animateNewComment(el, entering = false) {
|
static _animateNewComment(el, entering = false) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
el.classList.add('new-item-fade');
|
||||||
const classes = entering
|
setTimeout(() => {
|
||||||
? ['comment-entering', 'new-item-fade']
|
el.classList.remove('new-item-fade');
|
||||||
: ['new-item-fade'];
|
}, 2500);
|
||||||
|
|
||||||
// Reset in case the element already has a stale animation
|
|
||||||
classes.forEach(c => el.classList.remove(c));
|
|
||||||
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
|
|
||||||
|
|
||||||
classes.forEach(c => el.classList.add(c));
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
classes.forEach(c => el.classList.remove(c));
|
|
||||||
};
|
|
||||||
el.addEventListener('animationend', cleanup, { once: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -892,7 +881,6 @@ class CommentSystem {
|
|||||||
if (el) {
|
if (el) {
|
||||||
// Remove highlight from any previously highlighted or newly-posted comment
|
// Remove highlight from any previously highlighted or newly-posted comment
|
||||||
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
|
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
|
||||||
document.querySelectorAll('.comment-entering').forEach(c => c.classList.remove('comment-entering'));
|
|
||||||
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
|
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
|
||||||
|
|
||||||
// Always smooth-scroll so there's no jarring jump
|
// Always smooth-scroll so there's no jarring jump
|
||||||
@@ -1038,7 +1026,7 @@ class CommentSystem {
|
|||||||
preview.dataset.id = targetId;
|
preview.dataset.id = targetId;
|
||||||
|
|
||||||
// Remove temporary animation/highlight classes
|
// Remove temporary animation/highlight classes
|
||||||
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
|
preview.classList.remove('new-item-fade', 'comment-highlighted');
|
||||||
|
|
||||||
// Remove input forms or action buttons
|
// Remove input forms or action buttons
|
||||||
const actions = preview.querySelector('.comment-actions');
|
const actions = preview.querySelector('.comment-actions');
|
||||||
@@ -1152,6 +1140,7 @@ class CommentSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
quoteComment(id, openerEl, body) {
|
quoteComment(id, openerEl, body) {
|
||||||
|
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
|
||||||
// If no reply input open anywhere, open the local one
|
// If no reply input open anywhere, open the local one
|
||||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
@@ -1172,12 +1161,13 @@ class CommentSystem {
|
|||||||
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
||||||
const contentEl = body.querySelector('.comment-content');
|
const contentEl = body.querySelector('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
const LINE_MAX = 200;
|
let rawText = (contentEl.dataset.raw || '').trim();
|
||||||
const rawText = (contentEl.dataset.raw || '').trim();
|
if (rawText.includes('>') || rawText.includes('<') || rawText.includes('&')) {
|
||||||
// Preserve all lines but cap any single line exceeding LINE_MAX chars
|
const txt = document.createElement('textarea');
|
||||||
const lines = rawText.split('\n').map(line =>
|
txt.innerHTML = rawText;
|
||||||
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
|
rawText = txt.value;
|
||||||
);
|
}
|
||||||
|
const lines = rawText.split('\n');
|
||||||
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -1632,18 +1622,26 @@ class CommentSystem {
|
|||||||
syncLevel(roots, list, false);
|
syncLevel(roots, list, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum characters to fully render in the item view per comment
|
// Maximum characters & lines to fully render in the item view per comment
|
||||||
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
|
static get ITEM_VIEW_MAX_CHARS() { return 500; }
|
||||||
|
static get ITEM_VIEW_MAX_LINES() { return 6; }
|
||||||
|
|
||||||
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
// Truncate extremely long comments before any processing
|
// Truncate long comments before any processing
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
if (!bypassTruncation) {
|
||||||
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
|
const lines = content.split('\n');
|
||||||
|
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||||
|
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
|
||||||
truncated = true;
|
truncated = true;
|
||||||
}
|
}
|
||||||
|
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||||
|
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof marked === 'undefined') {
|
if (typeof marked === 'undefined') {
|
||||||
console.warn('Marked.js not loaded, falling back to plain text');
|
console.warn('Marked.js not loaded, falling back to plain text');
|
||||||
@@ -1791,9 +1789,10 @@ class CommentSystem {
|
|||||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
|
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||||
const renderedContent = quoteEmojis
|
const renderedContent = quoteEmojis
|
||||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||||
: quoteContent;
|
: escapedQuote;
|
||||||
return `<span class="greentext">>${renderedContent}</span>`;
|
return `<span class="greentext">>${renderedContent}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2199,7 +2198,7 @@ class CommentSystem {
|
|||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = unsafe;
|
div.textContent = unsafe;
|
||||||
return div.innerHTML;
|
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCommentAttachments(files, content = '') {
|
renderCommentAttachments(files, content = '') {
|
||||||
@@ -2407,10 +2406,50 @@ class CommentSystem {
|
|||||||
}
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
// Global click listener to close popups (useful for mobile dismissal)
|
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const isLink = e.target.closest('.comment-context-link');
|
const target = e.target;
|
||||||
const isPopup = e.target.closest('.comment-preview-popup');
|
|
||||||
|
// Load full comment (expand truncated)
|
||||||
|
const loadFullBtn = target.closest('.load-full-comment-btn');
|
||||||
|
if (loadFullBtn) {
|
||||||
|
const contentEl = loadFullBtn.closest('.comment-content');
|
||||||
|
if (contentEl) {
|
||||||
|
if (contentEl.querySelector('.collapse-comment-btn')) return;
|
||||||
|
const commentEl = contentEl.closest('.comment');
|
||||||
|
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||||
|
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||||
|
if (fullContent) {
|
||||||
|
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||||
|
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
|
||||||
|
contentEl.insertAdjacentHTML('beforeend',
|
||||||
|
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
||||||
|
);
|
||||||
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse full comment back to truncated view
|
||||||
|
const collapseBtn = target.closest('.collapse-comment-btn');
|
||||||
|
if (collapseBtn) {
|
||||||
|
const contentEl = collapseBtn.closest('.comment-content');
|
||||||
|
if (contentEl) {
|
||||||
|
if (contentEl.querySelector('.load-full-comment-btn')) return;
|
||||||
|
const commentEl = contentEl.closest('.comment');
|
||||||
|
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||||
|
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||||
|
if (fullContent) {
|
||||||
|
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||||
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLink = target.closest('.comment-context-link');
|
||||||
|
const isPopup = target.closest('.comment-preview-popup');
|
||||||
|
|
||||||
if (!isLink && !isPopup) {
|
if (!isLink && !isPopup) {
|
||||||
this.closePreviewsAboveLevel(-1);
|
this.closePreviewsAboveLevel(-1);
|
||||||
@@ -2825,7 +2864,10 @@ class CommentSystem {
|
|||||||
if (loadFullBtn) {
|
if (loadFullBtn) {
|
||||||
const contentEl = loadFullBtn.closest('.comment-content');
|
const contentEl = loadFullBtn.closest('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
const fullContent = contentEl.dataset.raw;
|
if (contentEl.querySelector('.collapse-comment-btn')) return;
|
||||||
|
const commentEl = contentEl.closest('.comment');
|
||||||
|
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||||
|
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||||
if (fullContent) {
|
if (fullContent) {
|
||||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||||
// Append "see less" button after full content
|
// Append "see less" button after full content
|
||||||
@@ -2833,6 +2875,7 @@ class CommentSystem {
|
|||||||
contentEl.insertAdjacentHTML('beforeend',
|
contentEl.insertAdjacentHTML('beforeend',
|
||||||
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
||||||
);
|
);
|
||||||
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -2843,9 +2886,13 @@ class CommentSystem {
|
|||||||
if (collapseBtn) {
|
if (collapseBtn) {
|
||||||
const contentEl = collapseBtn.closest('.comment-content');
|
const contentEl = collapseBtn.closest('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
const fullContent = contentEl.dataset.raw;
|
if (contentEl.querySelector('.load-full-comment-btn')) return;
|
||||||
|
const commentEl = contentEl.closest('.comment');
|
||||||
|
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||||
|
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||||
if (fullContent) {
|
if (fullContent) {
|
||||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||||
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3166,6 +3213,9 @@ class CommentSystem {
|
|||||||
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
|
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
|
||||||
const el = target.closest('.comment-permalink, .comment-time');
|
const el = target.closest('.comment-permalink, .comment-time');
|
||||||
if (el) {
|
if (el) {
|
||||||
|
if (!window.f0ckSession || !window.f0ckSession.logged_in) {
|
||||||
|
return; // Let standard anchor link behavior scroll/jump to #c{id}
|
||||||
|
}
|
||||||
const id = el.dataset.id;
|
const id = el.dataset.id;
|
||||||
const commentEl = el.closest('[id^="c"]');
|
const commentEl = el.closest('[id^="c"]');
|
||||||
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
||||||
@@ -3336,9 +3386,15 @@ class CommentSystem {
|
|||||||
params.append('has_poll', '1');
|
params.append('has_poll', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||||
|
if (csrfToken) params.append('csrf_token', csrfToken);
|
||||||
|
|
||||||
const res = await fetch('/api/comments', {
|
const res = await fetch('/api/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||||
|
},
|
||||||
body: params
|
body: params
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3604,9 +3660,9 @@ class CommentSystem {
|
|||||||
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
|
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
|
||||||
// optimistic DOM insert to guarantee the button regardless of rendering path.
|
// optimistic DOM insert to guarantee the button regardless of rendering path.
|
||||||
_ensureTruncationButton(commentEl, rawContent) {
|
_ensureTruncationButton(commentEl, rawContent) {
|
||||||
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
|
if (!rawContent) return;
|
||||||
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
const lineCount = rawContent.split('\n').length;
|
||||||
console.log('[ensureBtn] below threshold, skipping');
|
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contentEl = commentEl.querySelector('.comment-content');
|
const contentEl = commentEl.querySelector('.comment-content');
|
||||||
@@ -3808,10 +3864,13 @@ class CommentSystem {
|
|||||||
toggleComments() {
|
toggleComments() {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
|
if (document.body.classList.contains('layout-modern')) {
|
||||||
|
if (window.innerWidth < 1000) return;
|
||||||
|
if (typeof window.toggleSidebarLeft === 'function') {
|
||||||
window.toggleSidebarLeft();
|
window.toggleSidebarLeft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const layout = this.container.closest('.item-layout-container');
|
const layout = this.container.closest('.item-layout-container');
|
||||||
const sidebar = this.container.closest('.item-sidebar-left');
|
const sidebar = this.container.closest('.item-sidebar-left');
|
||||||
@@ -4457,20 +4516,71 @@ class CommentSystem {
|
|||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: reliable touch handling for tab buttons on mobile
|
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
||||||
const addTabTouch = (btn, cb) => {
|
let lastHoldTime = 0;
|
||||||
|
const addTabTouch = (btn, cb, onHold) => {
|
||||||
let ts = null;
|
let ts = null;
|
||||||
|
let holdTimer = null;
|
||||||
|
let holdFired = false;
|
||||||
btn.addEventListener('touchstart', (e) => {
|
btn.addEventListener('touchstart', (e) => {
|
||||||
|
holdFired = false;
|
||||||
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
if (onHold) {
|
||||||
|
holdTimer = setTimeout(() => {
|
||||||
|
holdFired = true;
|
||||||
|
lastHoldTime = Date.now();
|
||||||
|
try { navigator.vibrate?.(40); } catch(err) {}
|
||||||
|
onHold();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
|
btn.addEventListener('touchmove', (e) => {
|
||||||
|
if (!ts) return;
|
||||||
|
const dx = Math.abs(e.touches[0].clientX - ts.x);
|
||||||
|
const dy = Math.abs(e.touches[0].clientY - ts.y);
|
||||||
|
if (dx > 10 || dy > 10) {
|
||||||
|
clearTimeout(holdTimer);
|
||||||
|
ts = null;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
btn.addEventListener('touchend', (e) => {
|
btn.addEventListener('touchend', (e) => {
|
||||||
|
clearTimeout(holdTimer);
|
||||||
|
if (holdFired) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
holdFired = false;
|
||||||
|
ts = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!ts) return;
|
if (!ts) return;
|
||||||
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
||||||
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
||||||
ts = null;
|
ts = null;
|
||||||
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
btn.addEventListener('touchcancel', () => {
|
||||||
|
clearTimeout(holdTimer);
|
||||||
|
holdFired = false;
|
||||||
|
ts = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavDefault = (tabEl) => {
|
||||||
|
const KEY = 'f0ck_emoji_default_tab';
|
||||||
|
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||||
|
if (isDefault) {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
tabEl.classList.remove('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites unset as default tab');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(KEY, '__favs__');
|
||||||
|
tabEl.classList.add('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites set as default tab');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build tab buttons — Favorites first
|
// Build tab buttons — Favorites first
|
||||||
@@ -4482,23 +4592,13 @@ class CommentSystem {
|
|||||||
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
|
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
|
||||||
favTab.addEventListener('mousedown', e => e.preventDefault());
|
favTab.addEventListener('mousedown', e => e.preventDefault());
|
||||||
favTab.addEventListener('click', () => showTab('__favs__'));
|
favTab.addEventListener('click', () => showTab('__favs__'));
|
||||||
addTabTouch(favTab, () => showTab('__favs__'));
|
addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
|
||||||
// Right-click on fav tab → set/unset as default
|
// Right-click on fav tab → set/unset as default
|
||||||
favTab.addEventListener('contextmenu', (e) => {
|
favTab.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (navigator.maxTouchPoints > 0) return;
|
if (Date.now() - lastHoldTime < 1000) return;
|
||||||
const KEY = 'f0ck_emoji_default_tab';
|
toggleFavDefault(favTab);
|
||||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
|
||||||
if (isDefault) {
|
|
||||||
localStorage.removeItem(KEY);
|
|
||||||
favTab.classList.remove('ep-tab-default');
|
|
||||||
window._showEmojiToast?.('Favorites unset as default tab');
|
|
||||||
} else {
|
|
||||||
localStorage.setItem(KEY, '__favs__');
|
|
||||||
favTab.classList.add('ep-tab-default');
|
|
||||||
window._showEmojiToast?.('Favorites set as default tab');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Mark as default if already set
|
// Mark as default if already set
|
||||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||||
|
|||||||
@@ -965,7 +965,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
|
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
|
||||||
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
|
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
|
||||||
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
|
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
|
||||||
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal'
|
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal'
|
||||||
];
|
];
|
||||||
modalIds.forEach(id => {
|
modalIds.forEach(id => {
|
||||||
// Don't close the filter modal during a background mime-filter reload
|
// Don't close the filter modal during a background mime-filter reload
|
||||||
@@ -2307,9 +2307,49 @@ window.cancelAnimFrame = (function () {
|
|||||||
hidden = hiddenValue === 'true';
|
hidden = hiddenValue === 'true';
|
||||||
}
|
}
|
||||||
document.body.classList.toggle('sidebar-right-hidden', hidden);
|
document.body.classList.toggle('sidebar-right-hidden', hidden);
|
||||||
|
|
||||||
|
// Always force-hide on very small screens regardless of saved preference
|
||||||
|
if (window.innerWidth <= 599) {
|
||||||
|
document.body.classList.add('sidebar-right-hidden');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.initSidebarRightToggle();
|
window.initSidebarRightToggle();
|
||||||
|
|
||||||
|
// Auto-hide sidebar on very small screens (≤599px), restore on wider
|
||||||
|
(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 599px)');
|
||||||
|
const applyBreakpoint = (e) => {
|
||||||
|
// Freeze transitions so the class change is instant (no sliding during resize)
|
||||||
|
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
||||||
|
const wrappers = document.querySelectorAll('.index-layout-wrapper, .item-layout-container, .pagination-container-fluid, .pagewrapper');
|
||||||
|
if (sidebar) sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
|
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
|
||||||
|
|
||||||
|
if (e.matches) {
|
||||||
|
// Entering narrow: force-hide without touching localStorage
|
||||||
|
document.body.classList.add('sidebar-right-hidden');
|
||||||
|
} else {
|
||||||
|
// Leaving narrow: restore from saved preference
|
||||||
|
const saved = localStorage.getItem('sidebarRightHidden');
|
||||||
|
const hidden = saved === 'true';
|
||||||
|
document.body.classList.toggle('sidebar-right-hidden', hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force reflow then restore transitions
|
||||||
|
if (sidebar) void sidebar.offsetWidth;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (sidebar) sidebar.style.removeProperty('transition');
|
||||||
|
wrappers.forEach(w => {
|
||||||
|
w.style.removeProperty('transition');
|
||||||
|
w.style.removeProperty('padding-right');
|
||||||
|
w.style.removeProperty('right');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', applyBreakpoint);
|
||||||
|
applyBreakpoint(mq); // run once on init
|
||||||
|
})();
|
||||||
|
|
||||||
const loadPageAjax = async (url, replace = true, options = {}) => {
|
const loadPageAjax = async (url, replace = true, options = {}) => {
|
||||||
if (isNavigating) return;
|
if (isNavigating) return;
|
||||||
|
|
||||||
@@ -2402,8 +2442,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
|
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
|
||||||
const parts = pathname.split('/').filter(Boolean);
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
const isItem = !pathname.match(/\/p\//) && (
|
const isItem = !pathname.match(/\/p\//) && (
|
||||||
pathname.match(/^\/\d+/) ||
|
pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1]))
|
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||||
);
|
);
|
||||||
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
|
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
|
||||||
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
||||||
@@ -3316,19 +3356,19 @@ window.cancelAnimFrame = (function () {
|
|||||||
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
||||||
// Split path, filter numeric, pop last.
|
// Split path, filter numeric, pop last.
|
||||||
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
|
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
|
||||||
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
|
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
|
||||||
|
|
||||||
// Clear and Hide navbar pagination for Item View (Never show grid pagination on item view)
|
// Clear and Hide navbar pagination for Item View (Never show grid pagination on item view)
|
||||||
// document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = ''); // Don't destroy content (cached grid needs it)
|
// document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = ''); // Don't destroy content (cached grid needs it)
|
||||||
document.querySelectorAll('.pagination-container-fluid').forEach(el => el.style.display = 'none');
|
document.querySelectorAll('.pagination-container-fluid').forEach(el => el.style.display = 'none');
|
||||||
|
|
||||||
if (numericSegments.length === 0) {
|
if (keySegments.length === 0) {
|
||||||
console.warn("loadItemAjax: No ID match found in URL", url);
|
console.warn("loadItemAjax: No ID or slug match found in URL", url);
|
||||||
// fallback for weird/external links
|
// fallback for weird/external links
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const itemid = numericSegments.pop();
|
const itemid = keySegments.pop();
|
||||||
|
|
||||||
// Extract context from Target URL first
|
// Extract context from Target URL first
|
||||||
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
|
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
|
||||||
@@ -3511,18 +3551,19 @@ window.cancelAnimFrame = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const _hash = new URL(url, window.location.origin).hash;
|
const _hash = new URL(url, window.location.origin).hash;
|
||||||
let _pushUrl = `/${itemid}`;
|
const itemKey = _cachedItem.slug || itemid;
|
||||||
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
let _pushUrl = `/${itemKey}`;
|
||||||
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
|
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||||
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
|
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; }
|
||||||
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
|
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`;
|
||||||
|
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`;
|
||||||
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||||
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||||
if (_hash) _pushUrl += _hash;
|
if (_hash) _pushUrl += _hash;
|
||||||
|
|
||||||
if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl);
|
if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl);
|
||||||
|
|
||||||
document.title = `${window.f0ckDomain} - ${itemid}`;
|
document.title = `${window.f0ckDomain} - ${itemKey}`;
|
||||||
if (navbar) navbar.classList.remove('pbwork');
|
if (navbar) navbar.classList.remove('pbwork');
|
||||||
|
|
||||||
if (!options.keepMedia) {
|
if (!options.keepMedia) {
|
||||||
@@ -3547,14 +3588,14 @@ window.cancelAnimFrame = (function () {
|
|||||||
.then(r => r.ok ? r.text() : null)
|
.then(r => r.ok ? r.text() : null)
|
||||||
.then(freshText => {
|
.then(freshText => {
|
||||||
if (!freshText) return;
|
if (!freshText) return;
|
||||||
try { JSON.parse(freshText); } catch(_) {}
|
|
||||||
// Re-parse to get html string
|
|
||||||
let freshHtml = freshText;
|
let freshHtml = freshText;
|
||||||
|
let freshSlug = null;
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(freshText);
|
const d = JSON.parse(freshText);
|
||||||
if (d && typeof d.html === 'string') freshHtml = d.html;
|
if (d && typeof d.html === 'string') freshHtml = d.html;
|
||||||
|
if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug;
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
itemCacheMap.set(_itemCacheKey, { html: freshHtml, ts: Date.now() });
|
itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, ts: Date.now() });
|
||||||
window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey);
|
window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -3579,7 +3620,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
- Total Network: ${(tBody - tStart).toFixed(2)}ms
|
- Total Network: ${(tBody - tStart).toFixed(2)}ms
|
||||||
- Content Size: ${(rawText.length / 1024).toFixed(2)} KB`);
|
- Content Size: ${(rawText.length / 1024).toFixed(2)} KB`);
|
||||||
|
|
||||||
let html, paginationHtml;
|
let html, paginationHtml, responseSlug = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Optimistically try to parse as JSON first
|
// Optimistically try to parse as JSON first
|
||||||
@@ -3592,6 +3633,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (data && typeof data.html === 'string') {
|
if (data && typeof data.html === 'string') {
|
||||||
html = data.html;
|
html = data.html;
|
||||||
paginationHtml = data.pagination;
|
paginationHtml = data.pagination;
|
||||||
|
responseSlug = data.slug || data.item?.slug || null;
|
||||||
} else {
|
} else {
|
||||||
html = rawText;
|
html = rawText;
|
||||||
}
|
}
|
||||||
@@ -3602,7 +3644,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
|
|
||||||
// ── Store in item cache (stale-while-revalidate) ───────────────────────
|
// ── Store in item cache (stale-while-revalidate) ───────────────────────
|
||||||
if (html && !options.skipCache) {
|
if (html && !options.skipCache) {
|
||||||
itemCacheMap.set(_itemCacheKey, { html, ts: Date.now() });
|
itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, ts: Date.now() });
|
||||||
if (itemCacheMap.size > ITEM_CACHE_MAX) {
|
if (itemCacheMap.size > ITEM_CACHE_MAX) {
|
||||||
// Evict oldest entry
|
// Evict oldest entry
|
||||||
itemCacheMap.delete(itemCacheMap.keys().next().value);
|
itemCacheMap.delete(itemCacheMap.keys().next().value);
|
||||||
@@ -3709,26 +3751,25 @@ window.cancelAnimFrame = (function () {
|
|||||||
// Construct proper History URL (Context Aware)
|
// Construct proper History URL (Context Aware)
|
||||||
// If we inherited context, we should reflect it in the URL
|
// If we inherited context, we should reflect it in the URL
|
||||||
const hash = new URL(url, window.location.origin).hash;
|
const hash = new URL(url, window.location.origin).hash;
|
||||||
let pushUrl = `/${itemid}`;
|
const itemKey = responseSlug || itemid;
|
||||||
|
let pushUrl = `/${itemKey}`;
|
||||||
// Logic from ajax.mjs context reconstruction:
|
// Logic from ajax.mjs context reconstruction:
|
||||||
if (userHall && userHallOwner) {
|
if (userHall && userHallOwner) {
|
||||||
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||||
} else if (user) {
|
} else if (user) {
|
||||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
|
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
|
||||||
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
|
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`;
|
||||||
}
|
}
|
||||||
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
|
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`;
|
||||||
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
|
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`;
|
||||||
// Append tagger filter so item nav stays in tagger context
|
// Append tagger filter so item nav stays in tagger context
|
||||||
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||||
|
|
||||||
if (mime) {
|
if (mime) {
|
||||||
// If it already has itemid at the end, insert mime before it
|
// If it already has itemKey at the end, insert mime before it
|
||||||
pushUrl = pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Re-append hash if present
|
// Re-append hash if present
|
||||||
if (hash) pushUrl += hash;
|
if (hash) pushUrl += hash;
|
||||||
|
|
||||||
@@ -3748,8 +3789,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (window.initVisualizer) window.initVisualizer();
|
if (window.initVisualizer) window.initVisualizer();
|
||||||
}
|
}
|
||||||
// Try to extract ID from response if possible or just use itemid
|
// Try to extract ID from response if possible or just use itemid
|
||||||
document.title = `${window.f0ckDomain} - ${itemid}`;
|
document.title = `${window.f0ckDomain} - ${itemKey}`;
|
||||||
if (navbar) navbar.classList.remove("pbwork");
|
|
||||||
window.f0ckDebug("AJAX load complete");
|
window.f0ckDebug("AJAX load complete");
|
||||||
|
|
||||||
// Notify extensions — also triggers CommentSystem init which renders comments
|
// Notify extensions — also triggers CommentSystem init which renders comments
|
||||||
@@ -3781,6 +3821,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
console.error("AJAX load failed:", err);
|
console.error("AJAX load failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
|
if (navbar) navbar.classList.remove("pbwork");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3959,15 +4000,16 @@ window.cancelAnimFrame = (function () {
|
|||||||
fetch(randomUrl)
|
fetch(randomUrl)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.items && data.items.id) {
|
if (data.success && data.items && (data.items.slug || data.items.id)) {
|
||||||
|
const targetKey = data.items.slug || data.items.id;
|
||||||
// Navigate in the same context (user hall, favs, tag, etc.)
|
// Navigate in the same context (user hall, favs, tag, etc.)
|
||||||
if (wUserHall && wUserHallOwner) {
|
if (wUserHall && wUserHallOwner) {
|
||||||
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${data.items.id}`, true);
|
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true);
|
||||||
} else if (wFavsUser) {
|
} else if (wFavsUser) {
|
||||||
// Preserve /user/:name/favs/:id context so next/prev arrows stay within favs
|
// Preserve /user/:name/favs/:id context so next/prev arrows stay within favs
|
||||||
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${data.items.id}`, true);
|
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true);
|
||||||
} else {
|
} else {
|
||||||
loadItemAjax(`/${data.items.id}`, true);
|
loadItemAjax(`/${targetKey}`, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.href = link.href;
|
window.location.href = link.href;
|
||||||
@@ -4039,7 +4081,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
|
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
@@ -4052,9 +4094,10 @@ window.cancelAnimFrame = (function () {
|
|||||||
const parts = pathname.split('/').filter(Boolean);
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
const isItemLink = !pathname.match(/\/p\//) && (
|
const isItemLink = !pathname.match(/\/p\//) && (
|
||||||
pathname.match(/^\/\d+/) ||
|
pathname.match(/^\/\d+/) ||
|
||||||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
|
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) ||
|
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||||
(parts.length >= 3 && parts[0] === 'h' && /^\d+$/.test(parts[parts.length - 1]))
|
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||||
|
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||||
);
|
);
|
||||||
if (isItemLink) {
|
if (isItemLink) {
|
||||||
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
|
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
|
||||||
@@ -4122,6 +4165,25 @@ window.cancelAnimFrame = (function () {
|
|||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
|
} else if (target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const visTrigger = target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn');
|
||||||
|
const id = visTrigger.dataset.itemId || document.getElementById('visibility-item-id')?.value;
|
||||||
|
const visBtnMain = document.getElementById('a_visibility');
|
||||||
|
const rawVis = visTrigger.dataset.visibility ?? visBtnMain?.dataset.visibility ?? '0';
|
||||||
|
const currentVis = parseInt(rawVis, 10);
|
||||||
|
const modal = document.getElementById('visibility-modal');
|
||||||
|
if (modal) {
|
||||||
|
const inputId = document.getElementById('visibility-item-id');
|
||||||
|
if (inputId && id) inputId.value = id;
|
||||||
|
const radios = modal.querySelectorAll('input[name="visibility"]');
|
||||||
|
radios.forEach(r => {
|
||||||
|
r.checked = (parseInt(r.value, 10) === currentVis);
|
||||||
|
});
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
} else if (target.closest('#a_rethumb')) {
|
} else if (target.closest('#a_rethumb')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const reBtn = target.closest('#a_rethumb');
|
const reBtn = target.closest('#a_rethumb');
|
||||||
@@ -4427,6 +4489,10 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (window.renderTags) {
|
if (window.renderTags) {
|
||||||
window.renderTags(res.tags);
|
window.renderTags(res.tags);
|
||||||
}
|
}
|
||||||
|
// Evict the cached item HTML so navigating back fetches fresh content
|
||||||
|
if (window.invalidateItemCache) {
|
||||||
|
window.invalidateItemCache(postid);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
revert();
|
revert();
|
||||||
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
|
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
|
||||||
@@ -4486,8 +4552,10 @@ window.cancelAnimFrame = (function () {
|
|||||||
const parts = p.split('/').filter(Boolean);
|
const parts = p.split('/').filter(Boolean);
|
||||||
const isItem = !p.match(/\/p\//) && (
|
const isItem = !p.match(/\/p\//) && (
|
||||||
p.match(/^\/\d+/) ||
|
p.match(/^\/\d+/) ||
|
||||||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
|
p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1]))
|
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||||
|
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||||
|
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||||
);
|
);
|
||||||
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
|
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
|
||||||
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
|
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
|
||||||
@@ -6277,8 +6345,10 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|
||||||
// Freeze all transitions (override CSS !important) so the class toggle
|
// Freeze sidebar AND wrapper transitions so the class toggle
|
||||||
// doesn't trigger an additional right-property animation.
|
// doesn't trigger a second padding-right animation.
|
||||||
|
const wrappers = getSidebarWrappers();
|
||||||
|
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
|
||||||
sidebar.style.setProperty('transition', 'none', 'important');
|
sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
|
|
||||||
if (stateChanging) {
|
if (stateChanging) {
|
||||||
@@ -6296,6 +6366,11 @@ window.cancelAnimFrame = (function () {
|
|||||||
// Restore CSS-controlled transitions on the next frame
|
// Restore CSS-controlled transitions on the next frame
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
sidebar.style.removeProperty('transition');
|
sidebar.style.removeProperty('transition');
|
||||||
|
wrappers.forEach(w => {
|
||||||
|
w.style.removeProperty('transition');
|
||||||
|
w.style.removeProperty('padding-right');
|
||||||
|
w.style.removeProperty('right');
|
||||||
|
});
|
||||||
swipeRT.isDraggingSidebar = false;
|
swipeRT.isDraggingSidebar = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -6533,6 +6608,10 @@ window.cancelAnimFrame = (function () {
|
|||||||
const settle = () => {
|
const settle = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
// Freeze sidebar AND wrapper transitions so the class toggle
|
||||||
|
// doesn't trigger a second padding-right animation.
|
||||||
|
const wrappers = getSidebarWrappers();
|
||||||
|
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
|
||||||
sidebar.style.setProperty('transition', 'none', 'important');
|
sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
|
|
||||||
if (stateChanging) {
|
if (stateChanging) {
|
||||||
@@ -6545,6 +6624,11 @@ window.cancelAnimFrame = (function () {
|
|||||||
sidebar.style.transform = '';
|
sidebar.style.transform = '';
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
sidebar.style.removeProperty('transition');
|
sidebar.style.removeProperty('transition');
|
||||||
|
wrappers.forEach(w => {
|
||||||
|
w.style.removeProperty('transition');
|
||||||
|
w.style.removeProperty('padding-right');
|
||||||
|
w.style.removeProperty('right');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6612,15 +6696,55 @@ window.cancelAnimFrame = (function () {
|
|||||||
});
|
});
|
||||||
window.addEventListener('resize', syncEdgeZone, { passive: true });
|
window.addEventListener('resize', syncEdgeZone, { passive: true });
|
||||||
|
|
||||||
// Touchend on edgeZone: instant tap-to-toggle (no 300ms synthetic click delay)
|
// Returns true if (clientX, clientY) is within the pill's hit area.
|
||||||
|
// The pill is #sidebar-drag-zone::after — 4×40px, centered on pointer:fine,
|
||||||
|
// right-aligned with padding-right:6px on pointer:coarse.
|
||||||
|
const isWithinPillArea = (clientX, clientY) => {
|
||||||
|
const rect = edgeZone.getBoundingClientRect();
|
||||||
|
const pillH = 40;
|
||||||
|
const hitPad = 12; // generous padding so it's easy to hit
|
||||||
|
// Vertical: pill is always centered in the drag zone
|
||||||
|
const pillCenterY = rect.top + rect.height / 2;
|
||||||
|
if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false;
|
||||||
|
// Horizontal: right-aligned on touch, centered on mouse
|
||||||
|
const isCoarse = window.matchMedia('(pointer: coarse)').matches;
|
||||||
|
const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2;
|
||||||
|
return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe
|
||||||
edgeZone.addEventListener('touchend', e => {
|
edgeZone.addEventListener('touchend', e => {
|
||||||
// Only if it was a clean tap (no significant swipe movement)
|
|
||||||
if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
|
if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
|
||||||
e.preventDefault();
|
e.preventDefault(); // always prevent 300ms synthetic click
|
||||||
edgeZone.dispatchEvent(new Event('click'));
|
const touch = e.changedTouches[0];
|
||||||
|
if (touch && isWithinPillArea(touch.clientX, touch.clientY)) {
|
||||||
|
window.toggleSidebarRight();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Helper: all layout wrappers that have padding-right animated by sidebar state
|
||||||
|
const getSidebarWrappers = () => {
|
||||||
|
// At ≤999px: sidebar is a pure overlay — only pagination tracks it.
|
||||||
|
if (window.innerWidth <= 599) {
|
||||||
|
return document.querySelectorAll('.pagination-container-fluid');
|
||||||
|
}
|
||||||
|
// At ≥600px: exclude pagewrapper when a dedicated layout wrapper is present.
|
||||||
|
// CSS already zeroes pagewrapper.padding-right via :has on those pages,
|
||||||
|
// so including it here causes double-padding (pagewrapper + index-layout-wrapper both
|
||||||
|
// get 300px snapped, making the grid 600px too narrow, then jumping back on settle).
|
||||||
|
const hasDedicatedWrapper = !!(
|
||||||
|
document.querySelector('.index-layout-wrapper') ||
|
||||||
|
document.querySelector('.item-layout-container') ||
|
||||||
|
document.querySelector('.meme-layout-wrapper') ||
|
||||||
|
document.querySelector('.messages-convo-page')
|
||||||
|
);
|
||||||
|
const sel = hasDedicatedWrapper
|
||||||
|
? '.index-layout-wrapper, .meme-layout-wrapper, .messages-convo-page, .pagination-container-fluid'
|
||||||
|
: '.pagination-container-fluid, .pagewrapper';
|
||||||
|
return document.querySelectorAll(sel);
|
||||||
|
};
|
||||||
|
|
||||||
// Smoothly toggle sidebar open/closed
|
// Smoothly toggle sidebar open/closed
|
||||||
window.toggleSidebarRight = () => {
|
window.toggleSidebarRight = () => {
|
||||||
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
||||||
@@ -6630,6 +6754,29 @@ window.cancelAnimFrame = (function () {
|
|||||||
const sidebarWidth = sidebar.offsetWidth || 300;
|
const sidebarWidth = sidebar.offsetWidth || 300;
|
||||||
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
|
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
|
||||||
|
|
||||||
|
// At ≤599px the sidebar is a pure overlay — never animate wrapper padding
|
||||||
|
const animateWrappers = window.innerWidth > 599;
|
||||||
|
|
||||||
|
// Grid/pagewrapper: snap instantly in both directions (no animated reflow).
|
||||||
|
// Pagination always animates — it tracks sidebar position with no reflow cost.
|
||||||
|
const wrappers = getSidebarWrappers();
|
||||||
|
if (animateWrappers) {
|
||||||
|
wrappers.forEach(w => {
|
||||||
|
const isPagination = w.classList.contains('pagination-container-fluid');
|
||||||
|
const isMessages = w.classList.contains('messages-convo-page');
|
||||||
|
|
||||||
|
const prop = (isPagination || isMessages) ? 'right' : 'padding-right';
|
||||||
|
const targetVal = isHidden ? sidebarWidth + 'px' : '0px';
|
||||||
|
|
||||||
|
// Lightweight layouts (pagination, messages) animate smoothly.
|
||||||
|
// Heavy/reflow-intensive layouts snap instantly to avoid reflow lag.
|
||||||
|
const shouldAnimate = (isPagination || isMessages);
|
||||||
|
w.style.setProperty('transition', shouldAnimate ? `${prop} 0.3s ease-in-out` : 'none', 'important');
|
||||||
|
w.style.setProperty(prop, targetVal, 'important');
|
||||||
|
});
|
||||||
|
void sidebar.offsetWidth; // settle layout before sidebar transform begins
|
||||||
|
}
|
||||||
|
|
||||||
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
|
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
|
||||||
sidebar.style.transform = `translateX(${targetTranslate}px)`;
|
sidebar.style.transform = `translateX(${targetTranslate}px)`;
|
||||||
|
|
||||||
@@ -6637,13 +6784,22 @@ window.cancelAnimFrame = (function () {
|
|||||||
const settle = () => {
|
const settle = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
// Freeze wrapper transitions so the class commit doesn't re-trigger them
|
||||||
|
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
|
||||||
sidebar.style.setProperty('transition', 'none', 'important');
|
sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
const toHidden = !isHidden;
|
const toHidden = !isHidden;
|
||||||
document.body.classList.toggle('sidebar-right-hidden', toHidden);
|
document.body.classList.toggle('sidebar-right-hidden', toHidden);
|
||||||
localStorage.setItem('sidebarRightHidden', toHidden);
|
localStorage.setItem('sidebarRightHidden', toHidden);
|
||||||
void sidebar.offsetWidth;
|
void sidebar.offsetWidth;
|
||||||
sidebar.style.transform = '';
|
sidebar.style.transform = '';
|
||||||
requestAnimationFrame(() => sidebar.style.removeProperty('transition'));
|
requestAnimationFrame(() => {
|
||||||
|
sidebar.style.removeProperty('transition');
|
||||||
|
wrappers.forEach(w => {
|
||||||
|
w.style.removeProperty('transition');
|
||||||
|
w.style.removeProperty('padding-right');
|
||||||
|
w.style.removeProperty('right');
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnd = ev => {
|
const onEnd = ev => {
|
||||||
@@ -6655,9 +6811,11 @@ window.cancelAnimFrame = (function () {
|
|||||||
setTimeout(settle, 350);
|
setTimeout(settle, 350);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click on edgeZone: smoothly toggle sidebar open/closed
|
// Click on edgeZone: only toggle when the click lands on the pill
|
||||||
edgeZone.addEventListener('click', () => {
|
edgeZone.addEventListener('click', (e) => {
|
||||||
|
if (isWithinPillArea(e.clientX, e.clientY)) {
|
||||||
window.toggleSidebarRight();
|
window.toggleSidebarRight();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// </mouse-sidebar-drag>
|
// </mouse-sidebar-drag>
|
||||||
|
|
||||||
@@ -6677,7 +6835,9 @@ window.cancelAnimFrame = (function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isLSActive = () =>
|
const isLSActive = () =>
|
||||||
document.body.classList.contains('layout-modern') && !!document.querySelector('.item-sidebar-left');
|
document.body.classList.contains('layout-modern') &&
|
||||||
|
!!document.querySelector('.item-sidebar-left') &&
|
||||||
|
window.innerWidth >= 1000;
|
||||||
|
|
||||||
// Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX.
|
// Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX.
|
||||||
// OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
|
// OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
|
||||||
@@ -6936,6 +7096,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.toggleSidebarLeft = () => {
|
window.toggleSidebarLeft = () => {
|
||||||
|
if (window.innerWidth < 1000) return;
|
||||||
const { sb, layout } = getLS();
|
const { sb, layout } = getLS();
|
||||||
if (!sb || !layout) return;
|
if (!sb || !layout) return;
|
||||||
const isHidden = layout.classList.contains('sidebar-hidden');
|
const isHidden = layout.classList.contains('sidebar-hidden');
|
||||||
@@ -7430,30 +7591,49 @@ class NotificationSystem {
|
|||||||
|
|
||||||
// Helper: apply a display name change to all relevant DOM nodes
|
// Helper: apply a display name change to all relevant DOM nodes
|
||||||
this.applyDisplayNameUpdate = (display_name, user) => {
|
this.applyDisplayNameUpdate = (display_name, user) => {
|
||||||
|
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
|
||||||
|
|
||||||
// Capture old value before overwriting — needed for thumb matching below
|
// Capture old value before overwriting — needed for thumb matching below
|
||||||
const oldDisplayName = window.f0ckSession?.display_name || null;
|
const oldDisplayName = isSelf ? (window.f0ckSession?.display_name || null) : null;
|
||||||
if (window.f0ckSession) window.f0ckSession.display_name = display_name || null;
|
if (isSelf && window.f0ckSession) {
|
||||||
|
window.f0ckSession.display_name = display_name || null;
|
||||||
|
}
|
||||||
|
|
||||||
const userName = user || window.f0ckSession?.user;
|
const userName = user || window.f0ckSession?.user;
|
||||||
|
|
||||||
// Update ALL nav-display-name instances (handles the profile page having a duplicate ID)
|
// Update navbar display name only if this update is for the logged-in user
|
||||||
|
if (isSelf) {
|
||||||
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
|
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
|
||||||
// preserve the badges (Admin, Mod)
|
|
||||||
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
|
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
|
||||||
let node = nameSpan.childNodes[i];
|
let node = nameSpan.childNodes[i];
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
||||||
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
|
|
||||||
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
||||||
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
|
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update profile header display name if currently viewing this user's profile
|
||||||
|
const profileBox = document.getElementById('live-profile-preview-box');
|
||||||
|
if (profileBox && profileBox.dataset.bannerUser && userName && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
|
||||||
|
const profileNameSpan = document.getElementById('profile-display-name');
|
||||||
|
if (profileNameSpan) {
|
||||||
|
for (let i = profileNameSpan.childNodes.length - 1; i >= 0; i--) {
|
||||||
|
let node = profileNameSpan.childNodes[i];
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
||||||
|
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
||||||
|
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const bracket = document.getElementById('username-bracket');
|
const bracket = document.getElementById('username-bracket');
|
||||||
if (bracket) {
|
if (bracket) {
|
||||||
bracket.style.display = display_name ? 'inline' : 'none';
|
bracket.style.display = display_name ? 'inline' : 'none';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (userName) {
|
if (userName) {
|
||||||
const lowerHref = `/user/${userName.toLowerCase()}`;
|
const lowerHref = `/user/${userName.toLowerCase()}`;
|
||||||
@@ -7479,6 +7659,7 @@ class NotificationSystem {
|
|||||||
|
|
||||||
// Helper: apply a username color change to relevant DOM nodes
|
// Helper: apply a username color change to relevant DOM nodes
|
||||||
this.applyUsernameColorUpdate = (username_color, user) => {
|
this.applyUsernameColorUpdate = (username_color, user) => {
|
||||||
|
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
|
||||||
const userName = user || window.f0ckSession?.user;
|
const userName = user || window.f0ckSession?.user;
|
||||||
if (!userName) return;
|
if (!userName) return;
|
||||||
|
|
||||||
@@ -7525,22 +7706,21 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Special case for settings page preview block
|
// Profile header display name color (if currently viewing this user's profile)
|
||||||
if (window.f0ckSession && userName === window.f0ckSession.user) {
|
const profileBox = document.getElementById('live-profile-preview-box');
|
||||||
const previewBlock = document.getElementById('live-profile-preview-box');
|
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
|
||||||
if (previewBlock) {
|
const profileNameSpan = document.getElementById('profile-display-name');
|
||||||
|
if (profileNameSpan) {
|
||||||
if (username_color) {
|
if (username_color) {
|
||||||
previewBlock.style.setProperty('--author-accent', username_color);
|
profileNameSpan.style.color = username_color;
|
||||||
previewBlock.style.setProperty('--author-border', username_color);
|
|
||||||
} else {
|
} else {
|
||||||
previewBlock.style.setProperty('--author-accent', 'var(--accent)');
|
profileNameSpan.style.removeProperty('color');
|
||||||
previewBlock.style.setProperty('--author-border', 'var(--accent)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the nav-display-name span elements (profile header and top navbar)
|
// Navbar display name color (only for current user)
|
||||||
if (window.f0ckSession && userName === window.f0ckSession.user) {
|
if (isSelf) {
|
||||||
document.querySelectorAll('#nav-display-name').forEach(el => {
|
document.querySelectorAll('#nav-display-name').forEach(el => {
|
||||||
if (username_color) {
|
if (username_color) {
|
||||||
el.style.color = username_color;
|
el.style.color = username_color;
|
||||||
@@ -7553,14 +7733,21 @@ class NotificationSystem {
|
|||||||
|
|
||||||
// Helper: apply an avatar update to relevant DOM nodes
|
// Helper: apply an avatar update to relevant DOM nodes
|
||||||
this.applyAvatarUpdate = (avatar_file, user) => {
|
this.applyAvatarUpdate = (avatar_file, user) => {
|
||||||
|
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
|
||||||
const userName = user || window.f0ckSession?.user;
|
const userName = user || window.f0ckSession?.user;
|
||||||
if (!userName) return;
|
if (!userName) return;
|
||||||
|
|
||||||
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
|
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
|
||||||
|
|
||||||
// Update Nav Avatar & profile header Avatar (only for current user)
|
// Update Nav Avatar only for current user
|
||||||
if (window.f0ckSession && userName === window.f0ckSession.user) {
|
if (isSelf) {
|
||||||
document.querySelectorAll('.nav-avatar-img, .profile_head_avatar img').forEach(img => img.src = newSrc);
|
document.querySelectorAll('.nav-avatar-img').forEach(img => img.src = newSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update profile header avatar if currently viewing this user's profile
|
||||||
|
const profileBox = document.getElementById('live-profile-preview-box');
|
||||||
|
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
|
||||||
|
document.querySelectorAll('.profile_head_avatar img').forEach(img => img.src = newSrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update globally (comments, sidebar, polls, rank, infoboxes, favs)
|
// Update globally (comments, sidebar, polls, rank, infoboxes, favs)
|
||||||
@@ -7940,7 +8127,7 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.loadItemAjax === 'function' && href.match(/^\/\d+/)) {
|
if (typeof window.loadItemAjax === 'function' && (href.match(/^\/\d+/) || href.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || href.match(/\/(?:user|tag|h)\/.*?\/(?:\d+|[a-zA-Z0-9_-]{11})/))) {
|
||||||
window.loadItemAjax(href, false);
|
window.loadItemAjax(href, false);
|
||||||
} else if (typeof window.loadPageAjax === 'function') {
|
} else if (typeof window.loadPageAjax === 'function') {
|
||||||
window.loadPageAjax(href, true);
|
window.loadPageAjax(href, true);
|
||||||
@@ -8226,7 +8413,8 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderHistoryItem(n) {
|
renderHistoryItem(n) {
|
||||||
let link = `/${n.item_id}`;
|
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||||
|
let link = `/${itemKey}`;
|
||||||
let msg = '';
|
let msg = '';
|
||||||
let user = n.from_display_name || n.from_user || 'System';
|
let user = n.from_display_name || n.from_user || 'System';
|
||||||
|
|
||||||
@@ -8235,14 +8423,14 @@ class NotificationSystem {
|
|||||||
const isDeleted = n.type === 'item_deleted';
|
const isDeleted = n.type === 'item_deleted';
|
||||||
const label = isDeleted ? (i18n.notif_upload_deleted || 'A moderator deleted your upload') : (i18n.notif_upload_denied || 'Your Upload was denied');
|
const label = isDeleted ? (i18n.notif_upload_deleted || 'A moderator deleted your upload') : (i18n.notif_upload_denied || 'Your Upload was denied');
|
||||||
const userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System');
|
const userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System');
|
||||||
const itemLink = `/${n.item_id}`;
|
const itemLink = `/${itemKey}`;
|
||||||
return `
|
return `
|
||||||
<a href="${itemLink}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
<a href="${itemLink}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
|
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-user"><strong>${userLabel}</strong></div>
|
<div class="notif-user"><strong>${userLabel}</strong></div>
|
||||||
<div class="notif-msg">
|
<div class="notif-msg">
|
||||||
<strong>${label} #${n.item_id}</strong>
|
<strong>${label} #${itemKey}</strong>
|
||||||
<div class="notif-reason">${i18n.notif_reason_label || 'Reason:'} ${n.reason || (i18n.notif_no_reason || 'No reason provided')}</div>
|
<div class="notif-reason">${i18n.notif_reason_label || 'Reason:'} ${n.reason || (i18n.notif_no_reason || 'No reason provided')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||||
@@ -8262,7 +8450,7 @@ class NotificationSystem {
|
|||||||
if (n.data?.msg) msg += ` <br><span style="color: #ff6060; font-size: 0.9em;">${n.data.msg}</span>`;
|
if (n.data?.msg) msg += ` <br><span style="color: #ff6060; font-size: 0.9em;">${n.data.msg}</span>`;
|
||||||
if (n.data?.url) msg += ` <br><small style="opacity: 0.6; word-break: break-all;">${n.data.url}</small>`;
|
if (n.data?.url) msg += ` <br><small style="opacity: 0.6; word-break: break-all;">${n.data.url}</small>`;
|
||||||
user = (window.f0ckI18n && window.f0ckI18n.notif_system) || 'System';
|
user = (window.f0ckI18n && window.f0ckI18n.notif_system) || 'System';
|
||||||
link = n.item_id ? `/${n.item_id}` : '#';
|
link = n.item_id ? `/${itemKey}` : '#';
|
||||||
} else if (n.type === 'admin_pending') {
|
} else if (n.type === 'admin_pending') {
|
||||||
link = '/mod/approve';
|
link = '/mod/approve';
|
||||||
user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin';
|
user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin';
|
||||||
@@ -8278,7 +8466,7 @@ class NotificationSystem {
|
|||||||
if (n.reason) msg += `<br><div class="notif-reason" style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${n.reason}</div>`;
|
if (n.reason) msg += `<br><div class="notif-reason" style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${n.reason}</div>`;
|
||||||
} else {
|
} else {
|
||||||
// Comment notification
|
// Comment notification
|
||||||
link = `/${n.item_id}#c${n.comment_id || n.reference_id}`;
|
link = `/${itemKey}#c${n.comment_id || n.reference_id}`;
|
||||||
if (n.type === 'comment_reply') msg = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
|
if (n.type === 'comment_reply') msg = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
|
||||||
else if (n.type === 'subscription') msg = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
|
else if (n.type === 'subscription') msg = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
|
||||||
else if (n.type === 'mention') msg = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
|
else if (n.type === 'mention') msg = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
|
||||||
@@ -8319,8 +8507,9 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderItem(n) {
|
renderItem(n) {
|
||||||
|
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||||
if (n.type === 'approve') {
|
if (n.type === 'approve') {
|
||||||
const link = `/${n.item_id}`;
|
const link = `/${itemKey}`;
|
||||||
return `
|
return `
|
||||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}"></div>
|
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}"></div>
|
||||||
@@ -8335,7 +8524,7 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (n.type === 'upload_success') {
|
if (n.type === 'upload_success') {
|
||||||
const link = `/${n.item_id}`;
|
const link = `/${itemKey}`;
|
||||||
return `
|
return `
|
||||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none';"></div>
|
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none';"></div>
|
||||||
@@ -8354,7 +8543,7 @@ class NotificationSystem {
|
|||||||
const errMsg = n.data?.msg || '';
|
const errMsg = n.data?.msg || '';
|
||||||
const errDisplay = errMsg ? `<div style="color: #ffbaba; font-size: 0.85em; margin-top: 2px;">${errMsg}</div>` : '';
|
const errDisplay = errMsg ? `<div style="color: #ffbaba; font-size: 0.85em; margin-top: 2px;">${errMsg}</div>` : '';
|
||||||
const urlDisplay = url ? `<div style="font-size: 0.8em; opacity: 0.7; margin-top: 4px; word-break: break-all; max-height: 3.2em; overflow: hidden;">${url}</div>` : '';
|
const urlDisplay = url ? `<div style="font-size: 0.8em; opacity: 0.7; margin-top: 4px; word-break: break-all; max-height: 3.2em; overflow: hidden;">${url}</div>` : '';
|
||||||
const link = n.item_id ? `/${n.item_id}` : '#';
|
const link = n.item_id ? `/${itemKey}` : '#';
|
||||||
return `
|
return `
|
||||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
|
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
@@ -8378,7 +8567,7 @@ class NotificationSystem {
|
|||||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
|
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div>
|
<div>
|
||||||
<strong>${label} #${n.item_id}</strong>
|
<strong>${label} #${itemKey}</strong>
|
||||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${(window.f0ckI18n && window.f0ckI18n.notif_click_reason) || 'Click to see reason'}</div>
|
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${(window.f0ckI18n && window.f0ckI18n.notif_click_reason) || 'Click to see reason'}</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||||
@@ -8437,10 +8626,10 @@ class NotificationSystem {
|
|||||||
if (n.type === 'comment_reply') typeText = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
|
if (n.type === 'comment_reply') typeText = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
|
||||||
else if (n.type === 'subscription') typeText = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
|
else if (n.type === 'subscription') typeText = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
|
||||||
else if (n.type === 'mention') typeText = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
|
else if (n.type === 'mention') typeText = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
|
||||||
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${n.item_id}`;
|
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${itemKey}`;
|
||||||
|
|
||||||
const cid = n.comment_id || n.reference_id;
|
const cid = n.comment_id || n.reference_id;
|
||||||
const link = `/${n.item_id}#c${cid}`;
|
const link = `/${itemKey}#c${cid}`;
|
||||||
|
|
||||||
const thumb = n.item_id ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none'"></div>` : '';
|
const thumb = n.item_id ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none'"></div>` : '';
|
||||||
return `
|
return `
|
||||||
@@ -8605,6 +8794,22 @@ class NotificationSystem {
|
|||||||
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
|
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
data.favs.forEach(fav => {
|
data.favs.forEach(fav => {
|
||||||
|
const isSelf = window.f0ckSession && window.f0ckSession.user && (window.f0ckSession.user === fav.user);
|
||||||
|
if (fav.hide_fav_badge && !isSelf) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'ghost-fav';
|
||||||
|
a.setAttribute('tooltip', '?');
|
||||||
|
a.setAttribute('flow', 'up');
|
||||||
|
a.style.cursor = 'default';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = '/s/img/ghost_fav.svg';
|
||||||
|
img.style.height = '32px';
|
||||||
|
img.style.width = '32px';
|
||||||
|
|
||||||
|
a.appendChild(img);
|
||||||
|
fragment.appendChild(a);
|
||||||
|
} else {
|
||||||
const avatarUrl = fav.avatar_file
|
const avatarUrl = fav.avatar_file
|
||||||
? `/a/${fav.avatar_file}`
|
? `/a/${fav.avatar_file}`
|
||||||
: (fav.avatar ? `/t/${fav.avatar}.webp` : '/a/default.png');
|
: (fav.avatar ? `/t/${fav.avatar}.webp` : '/a/default.png');
|
||||||
@@ -8622,6 +8827,7 @@ class NotificationSystem {
|
|||||||
|
|
||||||
a.appendChild(img);
|
a.appendChild(img);
|
||||||
fragment.appendChild(a);
|
fragment.appendChild(a);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
favsContainer.innerHTML = '';
|
favsContainer.innerHTML = '';
|
||||||
@@ -8705,12 +8911,14 @@ class NotificationSystem {
|
|||||||
|
|
||||||
if (!isOwnUpload) return; // Silently drop as before for other users' uploads
|
if (!isOwnUpload) return; // Silently drop as before for other users' uploads
|
||||||
|
|
||||||
|
const itemKey = data.slug || data.id;
|
||||||
|
|
||||||
// Don't add duplicates
|
// Don't add duplicates
|
||||||
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
||||||
|
|
||||||
// Determine the link prefix from existing items
|
// Determine the link prefix from existing items
|
||||||
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
|
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
|
||||||
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
|
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
|
||||||
|
|
||||||
// Build filter-reason label
|
// Build filter-reason label
|
||||||
const i18n = window.f0ckI18n || {};
|
const i18n = window.f0ckI18n || {};
|
||||||
@@ -8725,7 +8933,7 @@ class NotificationSystem {
|
|||||||
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
|
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
|
||||||
|
|
||||||
const ghost = document.createElement('a');
|
const ghost = document.createElement('a');
|
||||||
ghost.href = `${linkBase}${data.id}`;
|
ghost.href = `${linkBase}${itemKey}`;
|
||||||
ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded';
|
ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded';
|
||||||
ghost.dataset.file = data.dest;
|
ghost.dataset.file = data.dest;
|
||||||
ghost.dataset.mime = data.mime;
|
ghost.dataset.mime = data.mime;
|
||||||
@@ -8756,19 +8964,21 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemKey = data.slug || data.id;
|
||||||
|
|
||||||
// Don't add duplicates
|
// Don't add duplicates
|
||||||
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
||||||
|
|
||||||
// Determine the link prefix from existing items
|
// Determine the link prefix from existing items
|
||||||
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
|
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
|
||||||
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
|
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
|
||||||
|
|
||||||
// Respect mode filter
|
// Respect mode filter
|
||||||
const nsflId = window.f0ckSession?.nsfl_tag_id;
|
const nsflId = window.f0ckSession?.nsfl_tag_id;
|
||||||
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
|
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
|
||||||
|
|
||||||
const thumb = document.createElement('a');
|
const thumb = document.createElement('a');
|
||||||
thumb.href = `${linkBase}${data.id}`;
|
thumb.href = `${linkBase}${itemKey}`;
|
||||||
thumb.className = 'thumb lazy-thumb';
|
thumb.className = 'thumb lazy-thumb';
|
||||||
thumb.dataset.file = data.dest;
|
thumb.dataset.file = data.dest;
|
||||||
thumb.dataset.mime = data.mime;
|
thumb.dataset.mime = data.mime;
|
||||||
@@ -8950,10 +9160,11 @@ class NotificationSystem {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||||
itemPreview = `
|
itemPreview = `
|
||||||
<div class="item-preview" style="margin-top: 10px; display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.2); padding: 5px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05);">
|
<div class="item-preview" style="margin-top: 10px; display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.2); padding: 5px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05);">
|
||||||
<a href="/${c.item_id}">${mediaHtml}</a>
|
<a href="/${itemKey}">${mediaHtml}</a>
|
||||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context »</a>
|
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context »</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11123,6 +11334,83 @@ document.addEventListener('click', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visCancelBtn = e.target.closest('#visibility-modal-cancel');
|
||||||
|
if (visCancelBtn || e.target.id === 'visibility-modal') {
|
||||||
|
const modal = document.getElementById('visibility-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visSaveBtn = e.target.closest('#visibility-modal-save');
|
||||||
|
if (visSaveBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const modal = document.getElementById('visibility-modal');
|
||||||
|
const inputId = document.getElementById('visibility-item-id');
|
||||||
|
const selectedRadio = modal ? modal.querySelector('input[name="visibility"]:checked') : null;
|
||||||
|
if (!inputId || !selectedRadio) return;
|
||||||
|
|
||||||
|
const id = inputId.value;
|
||||||
|
const nextVis = parseInt(selectedRadio.value, 10);
|
||||||
|
|
||||||
|
visSaveBtn.disabled = true;
|
||||||
|
const origText = visSaveBtn.textContent;
|
||||||
|
visSaveBtn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
fetch('/api/v2/item/visibility', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ postid: id, id: id, visibility: nextVis })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
visSaveBtn.disabled = false;
|
||||||
|
visSaveBtn.textContent = origText;
|
||||||
|
if (data.success) {
|
||||||
|
const visVal = parseInt(data.visibility, 10);
|
||||||
|
const titles = ['Public', 'Unlisted', 'Private'];
|
||||||
|
const icons = ['fa-globe', 'fa-link', 'fa-lock'];
|
||||||
|
const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)'];
|
||||||
|
|
||||||
|
const visBtn = document.getElementById('a_visibility');
|
||||||
|
if (visBtn) {
|
||||||
|
visBtn.dataset.visibility = visVal;
|
||||||
|
visBtn.setAttribute('title', `Visibility: ${titles[visVal]} (Click to change)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
|
||||||
|
if (infoVisBtn) {
|
||||||
|
infoVisBtn.dataset.visibility = visVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoVisLabel = document.getElementById('info-visibility-label');
|
||||||
|
if (infoVisLabel) {
|
||||||
|
infoVisLabel.innerHTML = `<i class="fa-solid ${icons[visVal]}" style="color: ${colors[visVal]};"></i> ${titles[visVal]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
if (window.flashMessage) window.flashMessage(`VISIBILITY SET TO ${titles[visVal].toUpperCase()}`);
|
||||||
|
} else {
|
||||||
|
if (window.flashError) window.flashError(data.msg || 'Failed to update visibility');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
visSaveBtn.disabled = false;
|
||||||
|
visSaveBtn.textContent = origText;
|
||||||
|
console.error('Error changing visibility:', err);
|
||||||
|
if (window.flashError) window.flashError('Network error');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Title save button
|
// Title save button
|
||||||
const saveBtn = e.target.closest('#info-title-save');
|
const saveBtn = e.target.closest('#info-title-save');
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
@@ -11192,6 +11480,125 @@ document.addEventListener('click', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Info Modal: Regenerate Thumbnail button
|
||||||
|
const rethumbBtn = e.target.closest('#info-rethumb-btn');
|
||||||
|
if (rethumbBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const itemId = rethumbBtn.dataset.itemId || document.getElementById('info-title-input')?.dataset.itemId;
|
||||||
|
if (!itemId) return;
|
||||||
|
|
||||||
|
rethumbBtn.disabled = true;
|
||||||
|
const origText = rethumbBtn.innerHTML;
|
||||||
|
rethumbBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Regenerating...';
|
||||||
|
|
||||||
|
const csrf = window.f0ckSession?.csrf_token;
|
||||||
|
fetch(`/api/v2/items/${itemId}/rethumb`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrf
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
rethumbBtn.disabled = false;
|
||||||
|
rethumbBtn.innerHTML = origText;
|
||||||
|
if (data.success) {
|
||||||
|
if (window.flashMessage) window.flashMessage('THUMBNAIL REGENERATED');
|
||||||
|
else if (window.showFlash) window.showFlash('Thumbnail regenerated');
|
||||||
|
// Cache-bust thumbnails on page
|
||||||
|
const timestamp = Date.now();
|
||||||
|
document.querySelectorAll(`img[src*="/t/${itemId}.webp"]`).forEach(img => {
|
||||||
|
const url = new URL(img.src, window.location.origin);
|
||||||
|
url.searchParams.set('t', timestamp);
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
document.querySelectorAll(`.thumb[data-bg*="/t/${itemId}.webp"]`).forEach(thumb => {
|
||||||
|
const bg = thumb.dataset.bg;
|
||||||
|
if (bg) {
|
||||||
|
const url = new URL(bg, window.location.origin);
|
||||||
|
url.searchParams.set('t', timestamp);
|
||||||
|
thumb.style.setProperty('--thumb-bg', `url('${url.toString()}')`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (window.flashError) window.flashError(data.msg || 'Regeneration failed');
|
||||||
|
else alert(data.msg || 'Regeneration failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
rethumbBtn.disabled = false;
|
||||||
|
rethumbBtn.innerHTML = origText;
|
||||||
|
console.error('Error regenerating thumbnail:', err);
|
||||||
|
if (window.flashError) window.flashError('Network error');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info Modal: Set Expiry button
|
||||||
|
const setExpiryBtn = e.target.closest('#info-set-expiry-btn');
|
||||||
|
if (setExpiryBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const select = document.getElementById('info-expiry-select');
|
||||||
|
const itemId = setExpiryBtn.dataset.itemId || select?.dataset.itemId || document.getElementById('info-title-input')?.dataset.itemId;
|
||||||
|
if (!itemId || !select) return;
|
||||||
|
|
||||||
|
const selectedExpiry = select.value;
|
||||||
|
|
||||||
|
setExpiryBtn.disabled = true;
|
||||||
|
select.disabled = true;
|
||||||
|
const origText = setExpiryBtn.innerHTML;
|
||||||
|
setExpiryBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||||
|
|
||||||
|
const csrf = window.f0ckSession?.csrf_token;
|
||||||
|
fetch(`/api/v2/items/${itemId}/expiry`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrf
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ expiry: selectedExpiry })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setExpiryBtn.disabled = false;
|
||||||
|
select.disabled = false;
|
||||||
|
setExpiryBtn.innerHTML = origText;
|
||||||
|
if (data.success) {
|
||||||
|
const label = document.getElementById('info-expiry-label');
|
||||||
|
if (label) {
|
||||||
|
if (data.expires_at) {
|
||||||
|
label.innerHTML = `<i class="fa-solid fa-clock"></i> ${data.expires_in || ('in ' + new Date(data.expires_at * 1000).toLocaleString())}`;
|
||||||
|
label.setAttribute('title', new Date(data.expires_at * 1000).toLocaleString());
|
||||||
|
label.style.color = '#ffaa00';
|
||||||
|
} else {
|
||||||
|
label.innerHTML = '<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Never';
|
||||||
|
label.removeAttribute('title');
|
||||||
|
label.style.color = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.purged) {
|
||||||
|
if (window.flashMessage) window.flashMessage('ITEM EXPIRED AND PURGED');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1200);
|
||||||
|
} else {
|
||||||
|
if (window.flashMessage) window.flashMessage('EXPIRATION UPDATED');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.flashError) window.flashError(data.msg || 'Failed to update expiry');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setExpiryBtn.disabled = false;
|
||||||
|
select.disabled = false;
|
||||||
|
setExpiryBtn.innerHTML = origText;
|
||||||
|
console.error('Error setting expiry:', err);
|
||||||
|
if (window.flashError) window.flashError('Network error');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Close when clicking outside modal content
|
// Close when clicking outside modal content
|
||||||
const infoModal = document.getElementById('info-modal');
|
const infoModal = document.getElementById('info-modal');
|
||||||
if (infoModal && e.target === infoModal) {
|
if (infoModal && e.target === infoModal) {
|
||||||
|
|||||||
@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
|
|||||||
dmGridArea = document.createElement('div');
|
dmGridArea = document.createElement('div');
|
||||||
dmGridArea.className = 'emoji-picker-grid';
|
dmGridArea.className = 'emoji-picker-grid';
|
||||||
|
|
||||||
|
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
||||||
|
let lastDmHoldTime = 0;
|
||||||
|
const addDmTabTouch = (btn, cb, onHold) => {
|
||||||
|
let ts = null;
|
||||||
|
let holdTimer = null;
|
||||||
|
let holdFired = false;
|
||||||
|
btn.addEventListener('touchstart', (e) => {
|
||||||
|
holdFired = false;
|
||||||
|
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
if (onHold) {
|
||||||
|
holdTimer = setTimeout(() => {
|
||||||
|
holdFired = true;
|
||||||
|
lastDmHoldTime = Date.now();
|
||||||
|
try { navigator.vibrate?.(40); } catch(err) {}
|
||||||
|
onHold();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
btn.addEventListener('touchmove', (e) => {
|
||||||
|
if (!ts) return;
|
||||||
|
const dx = Math.abs(e.touches[0].clientX - ts.x);
|
||||||
|
const dy = Math.abs(e.touches[0].clientY - ts.y);
|
||||||
|
if (dx > 10 || dy > 10) {
|
||||||
|
clearTimeout(holdTimer);
|
||||||
|
ts = null;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
btn.addEventListener('touchend', (e) => {
|
||||||
|
clearTimeout(holdTimer);
|
||||||
|
if (holdFired) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
holdFired = false;
|
||||||
|
ts = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ts) return;
|
||||||
|
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
||||||
|
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
||||||
|
ts = null;
|
||||||
|
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.addEventListener('touchcancel', () => {
|
||||||
|
clearTimeout(holdTimer);
|
||||||
|
holdFired = false;
|
||||||
|
ts = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDmFavDefault = (tabEl) => {
|
||||||
|
const KEY = 'f0ck_emoji_default_tab';
|
||||||
|
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||||
|
if (isDefault) {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
tabEl.classList.remove('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites unset as default tab');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(KEY, '__favs__');
|
||||||
|
tabEl.classList.add('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites set as default tab');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ── Favorites tab ──
|
// ── Favorites tab ──
|
||||||
const favsTab = document.createElement('button');
|
const favsTab = document.createElement('button');
|
||||||
favsTab.className = 'ep-tab ep-tab-favs';
|
favsTab.className = 'ep-tab ep-tab-favs';
|
||||||
@@ -1985,22 +2052,13 @@ if (window.__dmLoaded) {
|
|||||||
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
|
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
|
||||||
favsTab.addEventListener('mousedown', e => e.preventDefault());
|
favsTab.addEventListener('mousedown', e => e.preventDefault());
|
||||||
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
|
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
|
||||||
|
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
|
||||||
// Right-click on fav tab → set/unset as default
|
// Right-click on fav tab → set/unset as default
|
||||||
favsTab.addEventListener('contextmenu', (e) => {
|
favsTab.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (navigator.maxTouchPoints > 0) return;
|
if (Date.now() - lastDmHoldTime < 1000) return;
|
||||||
const KEY = 'f0ck_emoji_default_tab';
|
toggleDmFavDefault(favsTab);
|
||||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
|
||||||
if (isDefault) {
|
|
||||||
localStorage.removeItem(KEY);
|
|
||||||
favsTab.classList.remove('ep-tab-default');
|
|
||||||
window._showEmojiToast?.('Favorites unset as default tab');
|
|
||||||
} else {
|
|
||||||
localStorage.setItem(KEY, '__favs__');
|
|
||||||
favsTab.classList.add('ep-tab-default');
|
|
||||||
window._showEmojiToast?.('Favorites set as default tab');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Mark as default if already set
|
// Mark as default if already set
|
||||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||||
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
|
|||||||
}
|
}
|
||||||
tab.addEventListener('mousedown', e => e.preventDefault());
|
tab.addEventListener('mousedown', e => e.preventDefault());
|
||||||
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
|
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
|
||||||
|
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
|
||||||
dmTabBar.appendChild(tab);
|
dmTabBar.appendChild(tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1000,9 +1000,10 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const itemId = slide.dataset.id;
|
const itemId = slide.dataset.id;
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) pill.remove();
|
if (data.success) pill.remove();
|
||||||
@@ -1157,10 +1158,14 @@
|
|||||||
if (nowFaved) flashFav(slide);
|
if (nowFaved) flashFav(slide);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch('/api/v2/togglefav', {
|
const resp = await fetch('/api/v2/togglefav', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
body: `postid=${id}`
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||||
|
},
|
||||||
|
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
// Sync count to server truth (handles race conditions)
|
// Sync count to server truth (handles race conditions)
|
||||||
@@ -1582,9 +1587,10 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const itemId = slide.dataset.id;
|
const itemId = slide.dataset.id;
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) pill.remove();
|
if (data.success) pill.remove();
|
||||||
@@ -1850,11 +1856,12 @@
|
|||||||
else if (item.external_board === 'gif') rating = 'nsfw';
|
else if (item.external_board === 'gif') rating = 'nsfw';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'x-csrf-token': window.scrollerCsrf || ''
|
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
url: item.external_media_url || item.dest,
|
url: item.external_media_url || item.dest,
|
||||||
@@ -1966,9 +1973,10 @@
|
|||||||
showShareToast(info.toast);
|
showShareToast(info.toast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -2119,7 +2127,12 @@
|
|||||||
const contentEl = commentEl.querySelector('.comment-content');
|
const contentEl = commentEl.querySelector('.comment-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
|
||||||
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||||
|
if (raw.includes('>') || raw.includes('<') || raw.includes('&')) {
|
||||||
|
const txt = document.createElement('textarea');
|
||||||
|
txt.innerHTML = raw;
|
||||||
|
raw = txt.value;
|
||||||
|
}
|
||||||
const lines = raw.split('\n');
|
const lines = raw.split('\n');
|
||||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||||
|
|
||||||
@@ -2787,11 +2800,16 @@
|
|||||||
if (!content || !commentsItemId || commentsPosting) return;
|
if (!content || !commentsItemId || commentsPosting) return;
|
||||||
commentsPosting = true; commentSendBtn.disabled = true;
|
commentsPosting = true; commentSendBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
||||||
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
||||||
|
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
|
||||||
const resp = await fetch('/api/comments', {
|
const resp = await fetch('/api/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||||
|
},
|
||||||
body: postBody
|
body: postBody
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -2899,10 +2917,14 @@
|
|||||||
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
||||||
closeSugg();
|
closeSugg();
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
body: `tagname=${encodeURIComponent(tag)}`
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||||
|
},
|
||||||
|
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -3658,7 +3680,11 @@
|
|||||||
if (sMarkAll) {
|
if (sMarkAll) {
|
||||||
sMarkAll.addEventListener('click', async () => {
|
sMarkAll.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/notifications/read', { method: 'POST' });
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
|
await fetch('/api/notifications/read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||||
|
});
|
||||||
updateScrollerNotifBadge(0);
|
updateScrollerNotifBadge(0);
|
||||||
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
||||||
updateScrollerTabBadges(sCachedNotifs);
|
updateScrollerTabBadges(sCachedNotifs);
|
||||||
@@ -3673,7 +3699,12 @@
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
const nid = item.dataset.id;
|
const nid = item.dataset.id;
|
||||||
if (nid && item.classList.contains('unread')) {
|
if (nid && item.classList.contains('unread')) {
|
||||||
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
|
fetch(`/api/notifications/${nid}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
keepalive: true,
|
||||||
|
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||||
|
}).catch(() => {});
|
||||||
item.classList.remove('unread');
|
item.classList.remove('unread');
|
||||||
// Update cache
|
// Update cache
|
||||||
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
||||||
|
|||||||
@@ -1119,6 +1119,97 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const favsPrivateToggle = document.getElementById('favorites_private_toggle');
|
||||||
|
if (favsPrivateToggle) {
|
||||||
|
favsPrivateToggle.addEventListener('change', async () => {
|
||||||
|
const favorites_private = favsPrivateToggle.checked;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/settings/favorites_private', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ favorites_private })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showStatus('Favorites privacy preference updated!', 'success');
|
||||||
|
if (window.f0ckSession) {
|
||||||
|
window.f0ckSession.favorites_private = favorites_private;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Error saving preference');
|
||||||
|
favsPrivateToggle.checked = !favorites_private; // Revert
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update Favorites Privacy error:', err);
|
||||||
|
alert('Connection error');
|
||||||
|
favsPrivateToggle.checked = !favorites_private; // Revert
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideFavBadgeToggle = document.getElementById('hide_fav_badge_toggle');
|
||||||
|
if (hideFavBadgeToggle) {
|
||||||
|
hideFavBadgeToggle.addEventListener('change', async function() {
|
||||||
|
const hide_fav_badge = hideFavBadgeToggle.checked;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/settings/hide_fav_badge', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ hide_fav_badge })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showStatus('Hide favorite badge preference updated!', 'success');
|
||||||
|
if (window.f0ckSession) {
|
||||||
|
window.f0ckSession.hide_fav_badge = hide_fav_badge;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Error saving preference');
|
||||||
|
hideFavBadgeToggle.checked = !hide_fav_badge;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update Hide Fav Badge error:', err);
|
||||||
|
alert('Connection error');
|
||||||
|
hideFavBadgeToggle.checked = !hide_fav_badge;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select');
|
||||||
|
if (defaultUploadVisSelect) {
|
||||||
|
defaultUploadVisSelect.addEventListener('change', async function() {
|
||||||
|
const vis = parseInt(defaultUploadVisSelect.value, 10);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/settings/default_upload_visibility', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ default_upload_visibility: vis })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showStatus('Default upload visibility updated!', 'success');
|
||||||
|
if (window.f0ckSession) {
|
||||||
|
window.f0ckSession.default_upload_visibility = vis;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Error saving preference');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update Default Upload Visibility error:', err);
|
||||||
|
alert('Connection error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// New Dual Column Layout Toggle
|
// New Dual Column Layout Toggle
|
||||||
const layoutToggle = document.getElementById('use_new_layout_toggle');
|
const layoutToggle = document.getElementById('use_new_layout_toggle');
|
||||||
if (layoutToggle) {
|
if (layoutToggle) {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = unsafe;
|
div.textContent = unsafe;
|
||||||
return div.innerHTML;
|
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSidebarEmojiVideos = (container) => {
|
const playSidebarEmojiVideos = (container) => {
|
||||||
@@ -153,12 +153,6 @@
|
|||||||
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
// Truncate extremely long comments before any processing to keep the sidebar
|
|
||||||
// fast and the DOM lean, regardless of markdown / regex complexity.
|
|
||||||
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
|
|
||||||
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof marked === 'undefined') {
|
if (typeof marked === 'undefined') {
|
||||||
return escapeHtml(content)
|
return escapeHtml(content)
|
||||||
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||||
@@ -268,9 +262,10 @@
|
|||||||
// Manual greentext handling — apply emoji if the user preference allows it
|
// Manual greentext handling — apply emoji if the user preference allows it
|
||||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
|
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||||
const rendered = quoteEmojis
|
const rendered = quoteEmojis
|
||||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||||
: quoteContent;
|
: escapedQuote;
|
||||||
return `<span class="greentext">>${rendered}</span>`;
|
return `<span class="greentext">>${rendered}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,8 +479,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderActivityItem = (c) => {
|
const renderActivityItem = (c) => {
|
||||||
|
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||||
const rawContent = c.content || c.body || '';
|
const rawContent = c.content || c.body || '';
|
||||||
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
|
||||||
|
|
||||||
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
||||||
|
|
||||||
@@ -502,7 +498,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||||
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
|
||||||
|
|
||||||
// Build avatar URL — same priority as the rest of the app
|
// Build avatar URL — same priority as the rest of the app
|
||||||
let avatarSrc = '/a/default.png';
|
let avatarSrc = '/a/default.png';
|
||||||
@@ -547,8 +543,8 @@
|
|||||||
|
|
||||||
itemPreview = `
|
itemPreview = `
|
||||||
<div class="item-preview">
|
<div class="item-preview">
|
||||||
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
<a href="/${itemKey}" 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'} »</a>
|
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -908,7 +908,7 @@ window.initUploadForm = (selector) => {
|
|||||||
}
|
}
|
||||||
lines.forEach(url => {
|
lines.forEach(url => {
|
||||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
|
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
|
||||||
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
selectedFiles.push({ type: 'url', url, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
urlInput.value = '';
|
urlInput.value = '';
|
||||||
@@ -931,7 +931,7 @@ window.initUploadForm = (selector) => {
|
|||||||
const val = urlInput.value.trim();
|
const val = urlInput.value.trim();
|
||||||
if (!val || !/^https?:\/\//i.test(val)) return;
|
if (!val || !/^https?:\/\//i.test(val)) return;
|
||||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) {
|
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) {
|
||||||
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
selectedFiles.push({ type: 'url', url: val, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||||
}
|
}
|
||||||
urlInput.value = '';
|
urlInput.value = '';
|
||||||
if (urlBadge) urlBadge.style.display = 'none';
|
if (urlBadge) urlBadge.style.display = 'none';
|
||||||
@@ -1177,7 +1177,7 @@ window.initUploadForm = (selector) => {
|
|||||||
|
|
||||||
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
||||||
if (isShitpost) {
|
if (isShitpost) {
|
||||||
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||||
} else {
|
} else {
|
||||||
selectedFiles.push(file); // Legacy single file mode uses raw File
|
selectedFiles.push(file); // Legacy single file mode uses raw File
|
||||||
}
|
}
|
||||||
@@ -1490,6 +1490,8 @@ window.initUploadForm = (selector) => {
|
|||||||
infoRow.className = 'file-meta-row-small';
|
infoRow.className = 'file-meta-row-small';
|
||||||
|
|
||||||
let ratingSwitch = '';
|
let ratingSwitch = '';
|
||||||
|
let visibilitySwitch = '';
|
||||||
|
let expirySwitch = '';
|
||||||
let tagsUI = '';
|
let tagsUI = '';
|
||||||
let ocUI = '';
|
let ocUI = '';
|
||||||
let commentUI = '';
|
let commentUI = '';
|
||||||
@@ -1517,6 +1519,56 @@ window.initUploadForm = (selector) => {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const hasVisSection = !!form.querySelector('.global-visibility-section');
|
||||||
|
if (hasVisSection) {
|
||||||
|
const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0';
|
||||||
|
const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis;
|
||||||
|
item.visibility = visValue;
|
||||||
|
visibilitySwitch = `
|
||||||
|
<div class="item-visibility-container item-rating-container" style="margin-top: 4px;">
|
||||||
|
<label class="item-rating-option">
|
||||||
|
<input type="radio" name="visibility_${index}" value="0" ${visValue === '0' || visValue === 0 ? 'checked' : ''}>
|
||||||
|
<span class="item-rating-label sfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-globe"></i> Public</span>
|
||||||
|
</label>
|
||||||
|
<label class="item-rating-option">
|
||||||
|
<input type="radio" name="visibility_${index}" value="1" ${visValue === '1' || visValue === 1 ? 'checked' : ''}>
|
||||||
|
<span class="item-rating-label nsfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-link"></i> Unlisted</span>
|
||||||
|
</label>
|
||||||
|
<label class="item-rating-option">
|
||||||
|
<input type="radio" name="visibility_${index}" value="2" ${visValue === '2' || visValue === 2 ? 'checked' : ''}>
|
||||||
|
<span class="item-rating-label nsfl" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-lock"></i> Private</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
visibilitySwitch = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||||
|
const hasExpirySection = !!globalExpiryEl || window.f0ckEnableExpiringUploads !== false;
|
||||||
|
if (hasExpirySection && globalExpiryEl) {
|
||||||
|
const globalExpiry = globalExpiryEl.value || 'permanent';
|
||||||
|
const expiryValue = (item.expiry !== undefined && item.expiry !== '') ? item.expiry : globalExpiry;
|
||||||
|
item.expiry = expiryValue;
|
||||||
|
|
||||||
|
expirySwitch = `
|
||||||
|
<div class="item-expiry-container" style="margin-top: 4px;">
|
||||||
|
<select class="item-expiry-select" style="width: 100%; padding: 4px 8px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 4px; font-size: 0.85em; cursor: pointer;">
|
||||||
|
<option value="permanent" ${expiryValue === 'permanent' ? 'selected' : ''}>Permanent (Never expires)</option>
|
||||||
|
<option value="30minutes" ${expiryValue === '30minutes' ? 'selected' : ''}>30 Minutes</option>
|
||||||
|
<option value="1hour" ${expiryValue === '1hour' ? 'selected' : ''}>1 Hour</option>
|
||||||
|
<option value="24hours" ${expiryValue === '24hours' ? 'selected' : ''}>24 Hours</option>
|
||||||
|
<option value="1week" ${expiryValue === '1week' ? 'selected' : ''}>1 Week</option>
|
||||||
|
<option value="1month" ${expiryValue === '1month' ? 'selected' : ''}>1 Month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
expirySwitch = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
|
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
|
||||||
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
|
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
|
||||||
tagsUI = `
|
tagsUI = `
|
||||||
@@ -1560,19 +1612,37 @@ window.initUploadForm = (selector) => {
|
|||||||
</div>
|
</div>
|
||||||
${titleUI}
|
${titleUI}
|
||||||
${ratingSwitch}
|
${ratingSwitch}
|
||||||
|
${visibilitySwitch}
|
||||||
|
${expirySwitch}
|
||||||
${tagsUI}
|
${tagsUI}
|
||||||
${commentUI}
|
${commentUI}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (isShitpost) {
|
if (isShitpost) {
|
||||||
// Handle Rating
|
// Handle Rating
|
||||||
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
|
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
|
||||||
radio.onchange = () => {
|
radio.onchange = () => {
|
||||||
item.rating = radio.value;
|
item.rating = radio.value;
|
||||||
updateSubmitButton();
|
updateSubmitButton();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle Visibility
|
||||||
|
infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => {
|
||||||
|
radio.onchange = () => {
|
||||||
|
item.visibility = radio.value;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Expiry
|
||||||
|
const expirySelect = infoRow.querySelector('.item-expiry-select');
|
||||||
|
if (expirySelect) {
|
||||||
|
expirySelect.onchange = () => {
|
||||||
|
item.expiry = expirySelect.value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Handle Comment
|
// Handle Comment
|
||||||
const commentInput = infoRow.querySelector('.item-comment-input');
|
const commentInput = infoRow.querySelector('.item-comment-input');
|
||||||
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
|
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
|
||||||
@@ -2436,6 +2506,11 @@ window.initUploadForm = (selector) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
|
||||||
|
const visibilityVal = globalVisEl ? globalVisEl.value : '0';
|
||||||
|
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||||
|
const expiryVal = globalExpiryEl ? globalExpiryEl.value : 'permanent';
|
||||||
|
|
||||||
const resp = await fetch('/api/v2/upload-url', {
|
const resp = await fetch('/api/v2/upload-url', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2445,7 +2520,9 @@ window.initUploadForm = (selector) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url,
|
url,
|
||||||
rating: globalRatingEl.value,
|
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
|
||||||
|
visibility: visibilityVal,
|
||||||
|
expiry: expiryVal,
|
||||||
tags: tags.join(','),
|
tags: tags.join(','),
|
||||||
comment: comment,
|
comment: comment,
|
||||||
is_oc: isOc,
|
is_oc: isOc,
|
||||||
@@ -2552,9 +2629,13 @@ window.initUploadForm = (selector) => {
|
|||||||
|
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
const item = selectedFiles[i];
|
const item = selectedFiles[i];
|
||||||
|
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
|
||||||
|
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||||
const isUrlItem = isShitpost && item.type === 'url';
|
const isUrlItem = isShitpost && item.type === 'url';
|
||||||
const file = !isUrlItem ? (isShitpost ? item.file : item) : null;
|
const file = !isUrlItem ? (isShitpost ? item.file : item) : null;
|
||||||
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
|
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
|
||||||
|
const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0');
|
||||||
|
const fileExpiry = isShitpost ? (item.expiry || globalExpiryEl?.value || 'permanent') : (globalExpiryEl?.value || 'permanent');
|
||||||
const fileTags = isShitpost ? item.tags : tags;
|
const fileTags = isShitpost ? item.tags : tags;
|
||||||
const fileComment = isShitpost ? item.comment : comment;
|
const fileComment = isShitpost ? item.comment : comment;
|
||||||
const fileTitle = isShitpost ? (item.title || '') : titleVal;
|
const fileTitle = isShitpost ? (item.title || '') : titleVal;
|
||||||
@@ -2571,6 +2652,8 @@ window.initUploadForm = (selector) => {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
}
|
}
|
||||||
formData.append('rating', fileRating);
|
formData.append('rating', fileRating);
|
||||||
|
formData.append('visibility', fileVisibility);
|
||||||
|
formData.append('expiry', fileExpiry);
|
||||||
formData.append('tags', fileTags.join(','));
|
formData.append('tags', fileTags.join(','));
|
||||||
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
|
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
|
||||||
if (isShitpost) formData.append('is_shitpost', 'true');
|
if (isShitpost) formData.append('is_shitpost', 'true');
|
||||||
@@ -2622,6 +2705,8 @@ window.initUploadForm = (selector) => {
|
|||||||
xhr.send(JSON.stringify({
|
xhr.send(JSON.stringify({
|
||||||
url: item.url,
|
url: item.url,
|
||||||
rating: fileRating,
|
rating: fileRating,
|
||||||
|
visibility: fileVisibility,
|
||||||
|
expiry: fileExpiry,
|
||||||
tags: fileTags.join(','),
|
tags: fileTags.join(','),
|
||||||
is_oc: (isShitpost ? item.is_oc : isOc),
|
is_oc: (isShitpost ? item.is_oc : isOc),
|
||||||
comment: fileComment,
|
comment: fileComment,
|
||||||
@@ -2738,17 +2823,14 @@ window.initUploadForm = (selector) => {
|
|||||||
// Skip redirect if every item was a background URL job
|
// Skip redirect if every item was a background URL job
|
||||||
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||||
if (!allPending) {
|
if (!allPending) {
|
||||||
// Inject now if the grid is already in the DOM (upload modal open on main page)
|
const targetUrl = (lastData && (lastData.visibility > 0 || lastData.redirect || lastData.slug))
|
||||||
injectNewItem();
|
? (lastData.redirect || `/${lastData.slug || lastData.itemid}`)
|
||||||
// Navigate to main page, then inject again after the grid has loaded.
|
: '/';
|
||||||
// Awaiting loadPageAjax ensures the .posts grid DOM is present before the
|
|
||||||
// handleNewItem call — this covers the item-page drag-and-upload scenario
|
|
||||||
// where no grid exists until after navigation completes.
|
|
||||||
if (typeof window.loadPageAjax === 'function') {
|
if (typeof window.loadPageAjax === 'function') {
|
||||||
await window.loadPageAjax('/', true, { bypassCache: true });
|
await window.loadPageAjax(targetUrl, true, { bypassCache: true });
|
||||||
injectNewItem();
|
if (targetUrl === '/') injectNewItem();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/';
|
window.location.href = targetUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Helper to get dynamic context from the DOM
|
// Helper to get dynamic context from the DOM
|
||||||
const getContext = () => {
|
const getContext = () => {
|
||||||
const idLink = document.querySelector("a.id-link");
|
const commentsEl = document.querySelector("#comments-container");
|
||||||
if (!idLink) return null;
|
const favoEl = document.querySelector("#a_favo");
|
||||||
|
const infoEl = document.querySelector("#a_info");
|
||||||
|
const idLinkEl = document.querySelector("a.id-link");
|
||||||
|
|
||||||
|
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||||
|
if (!rawId) return null;
|
||||||
|
|
||||||
const tagsContainer = document.querySelector("#tags");
|
const tagsContainer = document.querySelector("#tags");
|
||||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||||
return {
|
return {
|
||||||
postid: +idLink.innerText,
|
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||||
poster: document.querySelector("a#a_username")?.innerText,
|
poster: document.querySelector("a#a_username")?.innerText,
|
||||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -306,7 +306,12 @@ if (!window.UserCommentSystem) {
|
|||||||
const trimmed = line.trimStart();
|
const trimmed = line.trimStart();
|
||||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
return `<span class="greentext">>${quoteContent}</span>`;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
|
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||||
|
const rendered = quoteEmojis
|
||||||
|
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||||
|
: escapedQuote;
|
||||||
|
return `<span class="greentext">>${rendered}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-line limit
|
// Per-line limit
|
||||||
@@ -405,11 +410,12 @@ if (!window.UserCommentSystem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderComment(c) {
|
renderComment(c) {
|
||||||
|
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||||
const timeAgo = this.timeAgo(c.created_at);
|
const timeAgo = this.timeAgo(c.created_at);
|
||||||
const fullDate = new Date(c.created_at).toISOString();
|
const fullDate = new Date(c.created_at).toISOString();
|
||||||
const content = this.renderCommentContent(c.content, c.item_id);
|
const content = this.renderCommentContent(c.content, itemKey);
|
||||||
|
|
||||||
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>`;
|
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${itemKey}"><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="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
startLiveTimestamps() {
|
startLiveTimestamps() {
|
||||||
@@ -450,7 +456,7 @@ if (!window.UserCommentSystem) {
|
|||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = unsafe;
|
div.textContent = unsafe;
|
||||||
return div.innerHTML;
|
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export default new class {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateSlug(length = 11) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||||
|
let slug = '';
|
||||||
|
const bytes = crypto.randomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
slug += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||||
};
|
};
|
||||||
@@ -53,6 +63,29 @@ export default new class {
|
|||||||
const timeStr = t(unitKey, { n: interval });
|
const timeStr = t(unitKey, { n: interval });
|
||||||
return t('timeago.ago', { t: timeStr });
|
return t('timeago.ago', { t: timeStr });
|
||||||
};
|
};
|
||||||
|
expiresIn(expiresAt) {
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
const expiresNum = parseInt(expiresAt, 10);
|
||||||
|
if (isNaN(expiresNum)) return null;
|
||||||
|
const now = ~~(Date.now() / 1000);
|
||||||
|
const diff = expiresNum - now;
|
||||||
|
if (diff <= 0) return "expiring now";
|
||||||
|
|
||||||
|
if (diff < 60) return `in ${diff}s`;
|
||||||
|
if (diff < 3600) {
|
||||||
|
const mins = Math.floor(diff / 60);
|
||||||
|
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
if (diff < 86400) {
|
||||||
|
const hours = Math.floor(diff / 3600);
|
||||||
|
const mins = Math.floor((diff % 3600) / 60);
|
||||||
|
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
const days = Math.floor(diff / 86400);
|
||||||
|
const hours = Math.floor((diff % 86400) / 3600);
|
||||||
|
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
|
||||||
|
};
|
||||||
|
|
||||||
md5(str) {
|
md5(str) {
|
||||||
return crypto.createHash('md5').update(str).digest("hex");
|
return crypto.createHash('md5').update(str).digest("hex");
|
||||||
};
|
};
|
||||||
@@ -460,4 +493,15 @@ export default new class {
|
|||||||
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
||||||
return `Internal Error. Reference: ${errId}`;
|
return `Internal Error. Reference: ${errId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isOnionRequest(req) {
|
||||||
|
if (!req || !req.headers) return false;
|
||||||
|
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
|
||||||
|
if (!rawHost) return false;
|
||||||
|
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
|
||||||
|
const firstHost = hostStr.split(',')[0].trim();
|
||||||
|
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
|
||||||
|
return hostNoPort.endsWith('.onion');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -204,3 +204,46 @@ export async function moveToDeleted(dest, deletedId) {
|
|||||||
await fs.unlink(srcPath).catch(() => {});
|
await fs.unlink(srcPath).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodically scan for and purge expired uploads (expires_at <= now).
|
||||||
|
* Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
|
||||||
|
* and sets is_deleted = true, is_purged = true, active = false in DB.
|
||||||
|
*/
|
||||||
|
export async function purgeExpiredUploads() {
|
||||||
|
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const now = ~~(Date.now() / 1000);
|
||||||
|
const expiredItems = await db`
|
||||||
|
SELECT id, dest, mime
|
||||||
|
FROM items
|
||||||
|
WHERE expires_at IS NOT NULL
|
||||||
|
AND expires_at <= ${now}
|
||||||
|
AND is_purged = false
|
||||||
|
`;
|
||||||
|
if (expiredItems.length > 0) {
|
||||||
|
console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
|
||||||
|
for (const item of expiredItems) {
|
||||||
|
try {
|
||||||
|
if (item.dest) {
|
||||||
|
await safeDeleteMediaFile(item.dest, item.id);
|
||||||
|
}
|
||||||
|
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
|
||||||
|
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
|
||||||
|
if (item.mime && item.mime.startsWith('audio')) {
|
||||||
|
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
|
||||||
|
}
|
||||||
|
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||||
|
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,12 @@
|
|||||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||||
"disable_swiping": "Wischen deaktivieren",
|
"disable_swiping": "Wischen deaktivieren",
|
||||||
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
||||||
|
"favorites_private": "Private Favoriten",
|
||||||
|
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||||
|
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
|
||||||
|
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
|
||||||
|
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
|
||||||
|
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
|
||||||
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
||||||
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
||||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||||
@@ -535,6 +541,7 @@
|
|||||||
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
||||||
"no_uploads": "Keine Uploads gefunden",
|
"no_uploads": "Keine Uploads gefunden",
|
||||||
"no_favs": "Keine Favoriten",
|
"no_favs": "Keine Favoriten",
|
||||||
|
"private_favorites": "private Favoriten",
|
||||||
"back_to_profile": "Zurück zum Profil",
|
"back_to_profile": "Zurück zum Profil",
|
||||||
"ban_modal_title": "Benutzer sperren",
|
"ban_modal_title": "Benutzer sperren",
|
||||||
"ban_modal_reason": "Grund:",
|
"ban_modal_reason": "Grund:",
|
||||||
@@ -794,6 +801,12 @@
|
|||||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||||
"slot_refreshed": "Slot erneuert",
|
"slot_refreshed": "Slot erneuert",
|
||||||
"admin_desc": "Du bist Admin, leg los."
|
"admin_desc": "Du bist Admin, leg los.",
|
||||||
|
"visibility": {
|
||||||
|
"public": "Öffentlich",
|
||||||
|
"unlisted": "Nicht gelistet",
|
||||||
|
"private": "Privat",
|
||||||
|
"change_visibility": "Sichtbarkeit ändern"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +165,12 @@
|
|||||||
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
|
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
|
||||||
"disable_swiping": "Disable Swiping",
|
"disable_swiping": "Disable Swiping",
|
||||||
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
||||||
|
"favorites_private": "Private Favorites",
|
||||||
|
"favorites_private_hint": "Only you and administrators can view your favorites list.",
|
||||||
|
"hide_fav_badge": "Hide Favorite Badge Avatar",
|
||||||
|
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
|
||||||
|
"default_upload_visibility": "Default Upload Visibility",
|
||||||
|
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
|
||||||
"image_expand_on_click": "Expand images inline on click",
|
"image_expand_on_click": "Expand images inline on click",
|
||||||
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
||||||
"enable_bg_blur": "Enable Background blur",
|
"enable_bg_blur": "Enable Background blur",
|
||||||
@@ -539,6 +545,7 @@
|
|||||||
"subscribe_uploads_btn": "Subscribe user to uploads",
|
"subscribe_uploads_btn": "Subscribe user to uploads",
|
||||||
"no_uploads": "no uploads found",
|
"no_uploads": "no uploads found",
|
||||||
"no_favs": "no favorites",
|
"no_favs": "no favorites",
|
||||||
|
"private_favorites": "private favorites",
|
||||||
"back_to_profile": "Back to Profile",
|
"back_to_profile": "Back to Profile",
|
||||||
"ban_modal_title": "Ban User",
|
"ban_modal_title": "Ban User",
|
||||||
"ban_modal_reason": "Reason:",
|
"ban_modal_reason": "Reason:",
|
||||||
@@ -796,6 +803,12 @@
|
|||||||
"delete_confirm": "Delete this invite token?",
|
"delete_confirm": "Delete this invite token?",
|
||||||
"slot_refreshes_on": "slot refreshes on {date}",
|
"slot_refreshes_on": "slot refreshes on {date}",
|
||||||
"slot_refreshed": "slot refreshed",
|
"slot_refreshed": "slot refreshed",
|
||||||
"admin_desc": "You are an admin, go ahead."
|
"admin_desc": "You are an admin, go ahead.",
|
||||||
|
"visibility": {
|
||||||
|
"public": "Public",
|
||||||
|
"unlisted": "Unlisted",
|
||||||
|
"private": "Private",
|
||||||
|
"change_visibility": "Change Visibility"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +165,10 @@
|
|||||||
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
|
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
|
||||||
"disable_swiping": "Swipen uitschakelen",
|
"disable_swiping": "Swipen uitschakelen",
|
||||||
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
||||||
|
"favorites_private": "Privé favorieten",
|
||||||
|
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
|
||||||
|
"hide_fav_badge": "Favoriet-badge avatar verbergen",
|
||||||
|
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
|
||||||
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
||||||
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
||||||
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
||||||
@@ -535,6 +539,7 @@
|
|||||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||||
"no_uploads": "geen uploads gevonden",
|
"no_uploads": "geen uploads gevonden",
|
||||||
"no_favs": "geen favorieten",
|
"no_favs": "geen favorieten",
|
||||||
|
"private_favorites": "privé favorieten",
|
||||||
"back_to_profile": "Terug naar Profiel",
|
"back_to_profile": "Terug naar Profiel",
|
||||||
"ban_modal_title": "Gebruiker Bannen",
|
"ban_modal_title": "Gebruiker Bannen",
|
||||||
"ban_modal_reason": "Reden:",
|
"ban_modal_reason": "Reden:",
|
||||||
|
|||||||
@@ -165,6 +165,10 @@
|
|||||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||||
"disable_swiping": "Wischen deaktivieren",
|
"disable_swiping": "Wischen deaktivieren",
|
||||||
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
||||||
|
"favorites_private": "Private Favoriten",
|
||||||
|
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||||
|
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
|
||||||
|
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
|
||||||
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
||||||
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
||||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||||
@@ -536,6 +540,7 @@
|
|||||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||||
"no_uploads": "keine Aufladierungen gefunden",
|
"no_uploads": "keine Aufladierungen gefunden",
|
||||||
"no_favs": "keine Favoriten",
|
"no_favs": "keine Favoriten",
|
||||||
|
"private_favorites": "private Favoriten",
|
||||||
"back_to_profile": "Zurück zum Profil",
|
"back_to_profile": "Zurück zum Profil",
|
||||||
"ban_modal_title": "Benutzer sperren",
|
"ban_modal_title": "Benutzer sperren",
|
||||||
"ban_modal_reason": "Grund:",
|
"ban_modal_reason": "Grund:",
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
|
import { getEnableItemSlugs } from "../settings.mjs";
|
||||||
import { updateHallsCache } from "../halls_cache.mjs";
|
import { updateHallsCache } from "../halls_cache.mjs";
|
||||||
import queue from "../queue.mjs";
|
import queue from "../queue.mjs";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import url from "url";
|
import url from "url";
|
||||||
|
|
||||||
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null;
|
const getGlobalfilter = () => {
|
||||||
|
if (!cfg.nsfp?.length) return null;
|
||||||
|
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
|
||||||
|
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(" or ") : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveNumericItemId = async (itemIdOrSlug) => {
|
||||||
|
if (!itemIdOrSlug) return null;
|
||||||
|
if (typeof itemIdOrSlug === 'number') return itemIdOrSlug;
|
||||||
|
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
|
||||||
|
try {
|
||||||
|
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
|
||||||
|
return rows[0]?.id || null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
|
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
|
||||||
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
||||||
@@ -136,8 +153,60 @@ const xdScoreMeta = (score) => {
|
|||||||
return { tier: 5, label: 'xDDDDD+' };
|
return { tier: 5, label: 'xDDDDD+' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) {
|
||||||
|
if (!rawUser) return { isPrivate: false, isAllowed: true };
|
||||||
|
const decodedUser = decodeURI(rawUser);
|
||||||
|
const targetUserRows = await db`
|
||||||
|
select u.id, u."user", u.admin, uo.favorites_private
|
||||||
|
from "user" u
|
||||||
|
left join user_options uo on uo.user_id = u.id
|
||||||
|
where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
if (!targetUserRows.length || !targetUserRows[0].favorites_private) {
|
||||||
|
return { isPrivate: false, isAllowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = targetUserRows[0];
|
||||||
|
|
||||||
|
let reqUserId = user_id;
|
||||||
|
let reqIsAdmin = is_admin;
|
||||||
|
|
||||||
|
if (typeof session === 'object' && session !== null) {
|
||||||
|
if (reqUserId === undefined) reqUserId = session.id;
|
||||||
|
if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAllowed = false;
|
||||||
|
if (reqUserId) {
|
||||||
|
if (+reqUserId === +targetUser.id) {
|
||||||
|
isAllowed = true;
|
||||||
|
} else if (reqIsAdmin === true) {
|
||||||
|
isAllowed = true;
|
||||||
|
} else if (reqIsAdmin === undefined) {
|
||||||
|
const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`;
|
||||||
|
if (reqUserRows.length > 0 && reqUserRows[0].admin) {
|
||||||
|
isAllowed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isPrivate: true, isAllowed };
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
|
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
|
||||||
|
if (fav && rawUser) {
|
||||||
|
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||||
|
if (isPrivate && !isAllowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
is_private: true,
|
||||||
|
message: "private favorites"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
|
|
||||||
// --- title: prefix — search items.title instead of the tags table ---
|
// --- title: prefix — search items.title instead of the tags table ---
|
||||||
@@ -194,7 +263,22 @@ export default {
|
|||||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
|
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
|
||||||
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
||||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
let baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||||
|
if (!session) {
|
||||||
|
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||||
|
if (cfg.websrv.public_nsfw) {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||||
|
} else {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||||
|
}
|
||||||
|
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||||
|
baseMode = "1 = 0";
|
||||||
|
}
|
||||||
|
}
|
||||||
const modequery = baseMode;
|
const modequery = baseMode;
|
||||||
|
|
||||||
let tagFilter = db``;
|
let tagFilter = db``;
|
||||||
@@ -250,6 +334,9 @@ export default {
|
|||||||
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
|
||||||
|
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce(items.visibility, 0) = 0`;
|
||||||
|
|
||||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
||||||
let total = getCachedCount(cacheKey);
|
let total = getCachedCount(cacheKey);
|
||||||
|
|
||||||
@@ -261,6 +348,7 @@ export default {
|
|||||||
where
|
where
|
||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
${visibilityFilter}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${titleFilter}
|
||||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||||
@@ -303,6 +391,7 @@ export default {
|
|||||||
where
|
where
|
||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
${visibilityFilter}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${titleFilter}
|
||||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||||
@@ -333,6 +422,8 @@ export default {
|
|||||||
const rows = (await db`
|
const rows = (await db`
|
||||||
select
|
select
|
||||||
items.id,
|
items.id,
|
||||||
|
items.slug,
|
||||||
|
items.visibility,
|
||||||
items.mime,
|
items.mime,
|
||||||
items.dest,
|
items.dest,
|
||||||
items.username as username,
|
items.username as username,
|
||||||
@@ -428,7 +519,18 @@ export default {
|
|||||||
view_mode: fav ? 'favs' : 'uploads'
|
view_mode: fav ? 'favs' : 'uploads'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
|
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
|
||||||
|
if (fav && rawUser) {
|
||||||
|
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||||
|
if (isPrivate && !isAllowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
is_private: true,
|
||||||
|
message: "private favorites"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
|
|
||||||
// --- title: prefix — search items.title instead of the tags table ---
|
// --- title: prefix — search items.title instead of the tags table ---
|
||||||
@@ -458,7 +560,17 @@ export default {
|
|||||||
if (uhData.length) userHallObj = uhData[0];
|
if (uhData.length) userHallObj = uhData[0];
|
||||||
}
|
}
|
||||||
const mime = (rawMime ?? "");
|
const mime = (rawMime ?? "");
|
||||||
const itemid = rawItemid ? +rawItemid : null;
|
const rawIdOrSlug = rawItemid ?? null;
|
||||||
|
if (rawIdOrSlug === null || rawIdOrSlug === undefined || rawIdOrSlug === '') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "404 - upload not found"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
|
||||||
|
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
|
||||||
|
|
||||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||||
const mimeSQL = mimeParts.length > 0
|
const mimeSQL = mimeParts.length > 0
|
||||||
? db`and (${mimeParts.map(m => m === 'flash'
|
? db`and (${mimeParts.map(m => m === 'flash'
|
||||||
@@ -472,18 +584,27 @@ export default {
|
|||||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||||
const isStrict = strictParams.length > 0;
|
const isStrict = strictParams.length > 0;
|
||||||
|
|
||||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||||
|
|
||||||
const effMode = Number(mode ?? 0);
|
const effMode = Number(mode ?? 0);
|
||||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||||
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
let baseMode = multiRatingSQL ?? lib.getMode(effMode);
|
||||||
|
if (!session) {
|
||||||
if (itemid === null) {
|
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||||
return {
|
if (cfg.websrv.public_nsfw) {
|
||||||
success: false,
|
baseMode = cfg.websrv.public_untagged
|
||||||
message: "404 - upload not found"
|
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
};
|
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||||
|
} else {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||||
}
|
}
|
||||||
|
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||||
|
baseMode = "1 = 0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modequery = baseMode;
|
||||||
|
|
||||||
let tagFilter = db``;
|
let tagFilter = db``;
|
||||||
let titleFilter = db``;
|
let titleFilter = db``;
|
||||||
@@ -525,6 +646,9 @@ export default {
|
|||||||
return db`
|
return db`
|
||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
and coalesce(items.visibility, 0) = 0
|
||||||
|
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
|
||||||
|
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${titleFilter}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
@@ -538,11 +662,9 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
|
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
|
||||||
|
|
||||||
// 1. Fetch the main item
|
// 1. Fetch the main item
|
||||||
// We only apply the active check and global NSFW filter (for guests) here.
|
|
||||||
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
|
|
||||||
const items = await db`
|
const items = await db`
|
||||||
select distinct on (items.id)
|
select distinct on (items.id)
|
||||||
items.*,
|
items.*,
|
||||||
@@ -566,15 +688,48 @@ export default {
|
|||||||
left join "user_options" uo on uo.user_id = author_u.id
|
left join "user_options" uo on uo.user_id = author_u.id
|
||||||
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
||||||
where
|
where
|
||||||
items.id = ${itemid} and
|
${itemLookup} and
|
||||||
items.active = true
|
items.active = true
|
||||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const actitem = items[0];
|
const actitem = items[0];
|
||||||
|
|
||||||
if (actitem && user_id) {
|
if (!actitem) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "404 - upload not found"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemid = actitem.id;
|
||||||
|
|
||||||
|
// Check visibility permissions:
|
||||||
|
const isOwnerOrAdmin = session && (
|
||||||
|
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
|
||||||
|
session.admin || session.is_moderator
|
||||||
|
);
|
||||||
|
|
||||||
|
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
|
||||||
|
// Block numeric enumeration unless viewer is owner/admin
|
||||||
|
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "404 - upload not found"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If item is Private (visibility === 2):
|
||||||
|
// Direct link only allowed for owner/admin
|
||||||
|
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
is_private: true,
|
||||||
|
message: "403 - private upload"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
db`
|
db`
|
||||||
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
||||||
values (${user_id}, ${itemid}, 1, now())
|
values (${user_id}, ${itemid}, 1, now())
|
||||||
@@ -583,15 +738,12 @@ export default {
|
|||||||
last_viewed = now()
|
last_viewed = now()
|
||||||
`.catch(e => console.error('Failed to track view:', e));
|
`.catch(e => console.error('Failed to track view:', e));
|
||||||
}
|
}
|
||||||
|
// Guest global filter check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
|
||||||
if (!actitem) {
|
if (!session && getGlobalfilter() && (actitem.visibility || 0) === 0) {
|
||||||
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
const filteredItem = await db`
|
||||||
if (!session && getGlobalfilter()) {
|
select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
|
||||||
const unfilteredItem = await db`
|
|
||||||
select id from items where id = ${itemid} and active = true limit 1
|
|
||||||
`;
|
`;
|
||||||
if (unfilteredItem[0]) {
|
if (filteredItem.length > 0) {
|
||||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
|
||||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -607,22 +759,17 @@ export default {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Sorry, this post is currently not visible."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
||||||
// Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes
|
// Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes
|
||||||
|
|
||||||
// Determine the effective mode for optimization check (similar to Random)
|
// Determine the effective mode for optimization check (similar to Random)
|
||||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
const nsfl_id = cfg.nsfl_tag_id || 3;
|
||||||
const useTagsDriver = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
const useTagsDriver = !!session && (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||||
|
|
||||||
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
||||||
return db`
|
return db`
|
||||||
select items.id
|
select items.id, items.slug
|
||||||
from items
|
from items
|
||||||
left join tags_assign on tags_assign.item_id = items.id
|
left join tags_assign on tags_assign.item_id = items.id
|
||||||
left join tags on tags.id = tags_assign.tag_id
|
left join tags on tags.id = tags_assign.tag_id
|
||||||
@@ -633,7 +780,7 @@ export default {
|
|||||||
where
|
where
|
||||||
${buildConditions()}
|
${buildConditions()}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
group by items.id
|
group by items.id, items.slug
|
||||||
${orderBy}
|
${orderBy}
|
||||||
limit ${limit}
|
limit ${limit}
|
||||||
`;
|
`;
|
||||||
@@ -648,7 +795,7 @@ export default {
|
|||||||
const checkFilter = !session && nsfpIds.length > 0;
|
const checkFilter = !session && nsfpIds.length > 0;
|
||||||
|
|
||||||
const query = db`
|
const query = db`
|
||||||
SELECT ta.item_id as id
|
SELECT ta.item_id as id, items.slug
|
||||||
FROM tags_assign ta
|
FROM tags_assign ta
|
||||||
INNER JOIN items ON items.id = ta.item_id
|
INNER JOIN items ON items.id = ta.item_id
|
||||||
${checkFilter
|
${checkFilter
|
||||||
@@ -657,6 +804,7 @@ export default {
|
|||||||
}
|
}
|
||||||
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
|
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
|
AND coalesce(items.visibility, 0) = 0
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
@@ -705,7 +853,7 @@ export default {
|
|||||||
link.suffix = '';
|
link.suffix = '';
|
||||||
}
|
}
|
||||||
const favorites = await db`
|
const favorites = await db`
|
||||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name
|
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge
|
||||||
from "favorites"
|
from "favorites"
|
||||||
left join "user" on "user".id = "favorites".user_id
|
left join "user" on "user".id = "favorites".user_id
|
||||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||||
@@ -718,24 +866,24 @@ export default {
|
|||||||
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
||||||
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
||||||
const repostRows = await db`
|
const repostRows = await db`
|
||||||
SELECT id, username, stamp FROM items
|
SELECT id, slug, username, stamp FROM items
|
||||||
WHERE active = true
|
WHERE active = true
|
||||||
AND id != ${itemid}
|
AND id != ${itemid}
|
||||||
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
|
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`;
|
`;
|
||||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||||
} else if (actitem.checksum) {
|
} else if (actitem.checksum) {
|
||||||
// Even without bypass, check if other bypass-entries exist with this same hash
|
// Even without bypass, check if other bypass-entries exist with this same hash
|
||||||
const baseChecksum = actitem.checksum;
|
const baseChecksum = actitem.checksum;
|
||||||
const repostRows = await db`
|
const repostRows = await db`
|
||||||
SELECT id, username, stamp FROM items
|
SELECT id, slug, username, stamp FROM items
|
||||||
WHERE active = true
|
WHERE active = true
|
||||||
AND id != ${itemid}
|
AND id != ${itemid}
|
||||||
AND checksum LIKE ${baseChecksum + '_bypass_%'}
|
AND checksum LIKE ${baseChecksum + '_bypass_%'}
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`;
|
`;
|
||||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also find visually-similar items via phash, merging with checksum results
|
// Also find visually-similar items via phash, merging with checksum results
|
||||||
@@ -745,7 +893,7 @@ export default {
|
|||||||
const existingIds = new Set(repostItems.map(r => r.id));
|
const existingIds = new Set(repostItems.map(r => r.id));
|
||||||
for (const pm of phashMatches) {
|
for (const pm of phashMatches) {
|
||||||
if (!existingIds.has(pm.id)) {
|
if (!existingIds.has(pm.id)) {
|
||||||
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||||
existingIds.add(pm.id);
|
existingIds.add(pm.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,19 +916,14 @@ export default {
|
|||||||
const isNsfw = tags.some(t => t.id == 2);
|
const isNsfw = tags.some(t => t.id == 2);
|
||||||
const isSfw = tags.some(t => t.id == 1);
|
const isSfw = tags.some(t => t.id == 1);
|
||||||
const isTagged = tags.length > 0;
|
const isTagged = tags.length > 0;
|
||||||
|
// Guest rating & untagged restriction check for public uploads (visibility === 0)
|
||||||
|
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
|
||||||
|
let guestBlocked = false;
|
||||||
|
if (!isTagged && !cfg.websrv.public_untagged) guestBlocked = true;
|
||||||
|
else if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
|
||||||
|
else if (isNsfl) guestBlocked = true;
|
||||||
|
|
||||||
// Mode-mismatch visibility check:
|
if (guestBlocked) {
|
||||||
// Only enforce for members (session users) with an explicit mode preference.
|
|
||||||
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
|
|
||||||
const userMode = Number(mode ?? 0);
|
|
||||||
if (userMode !== 3) {
|
|
||||||
let modeBlocked = false;
|
|
||||||
if (userMode === 0 && (isNsfw || isNsfl)) modeBlocked = true; // SFW mode, item is NSFW or NSFL
|
|
||||||
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
|
|
||||||
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
|
|
||||||
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
|
||||||
|
|
||||||
if (modeBlocked) {
|
|
||||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -797,6 +940,39 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mode-mismatch visibility check:
|
||||||
|
// Only enforce for members (session users) with an explicit mode preference.
|
||||||
|
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
|
||||||
|
const userMode = Number(mode ?? 0);
|
||||||
|
if (userMode !== 3) {
|
||||||
|
let modeBlocked = false;
|
||||||
|
if (userMode === 0 && (isNsfw || isNsfl)) modeBlocked = true; // SFW mode, item is NSFW or NSFL
|
||||||
|
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
|
||||||
|
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
|
||||||
|
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
||||||
|
|
||||||
|
if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) {
|
||||||
|
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Sorry, this post is currently not visible.",
|
||||||
|
item: {
|
||||||
|
id: itemid,
|
||||||
|
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`,
|
||||||
|
og_url: hallSlug
|
||||||
|
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
|
||||||
|
: `https://${cfg.main.url.domain}/${itemid}`,
|
||||||
|
og_description: `Content not visible in current mode`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getEnableItemSlugs() && !actitem.slug) {
|
||||||
|
actitem.slug = lib.generateSlug(11);
|
||||||
|
db`UPDATE items SET slug = ${actitem.slug} WHERE id = ${actitem.id} AND (slug IS NULL OR slug = '')`.catch(e => console.error('[AUTO_SLUG] Failed DB update:', e.message));
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
@@ -808,6 +984,8 @@ export default {
|
|||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
id: actitem.id,
|
id: actitem.id,
|
||||||
|
slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
|
||||||
|
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
|
||||||
username: actitem.username,
|
username: actitem.username,
|
||||||
author_id: actitem.author_id,
|
author_id: actitem.author_id,
|
||||||
author_color: actitem.author_color,
|
author_color: actitem.author_color,
|
||||||
@@ -866,17 +1044,21 @@ export default {
|
|||||||
is_oc: actitem.is_oc || false,
|
is_oc: actitem.is_oc || false,
|
||||||
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
|
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
|
||||||
reposts: repostItems,
|
reposts: repostItems,
|
||||||
|
show_repost_row: !!((session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (actitem.checksum?.includes('_bypass_') || (repostItems && repostItems.length > 0))),
|
||||||
width: actitem.width || null,
|
width: actitem.width || null,
|
||||||
height: actitem.height || null,
|
height: actitem.height || null,
|
||||||
original_filename: actitem.original_filename || null
|
original_filename: actitem.original_filename || null,
|
||||||
|
expires_at: actitem.expires_at || null,
|
||||||
|
expires_in: lib.expiresIn(actitem.expires_at)
|
||||||
|
|
||||||
},
|
},
|
||||||
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
|
||||||
pagination: {
|
pagination: {
|
||||||
end: endItem[0]?.id || itemid,
|
end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
|
||||||
start: startItem[0]?.id || itemid,
|
start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
|
||||||
next: nextItem[0]?.id || null,
|
next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
|
||||||
prev: prevItem[0]?.id || null,
|
prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
|
||||||
page: actitem.id,
|
page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
|
||||||
cheat: cheat
|
cheat: cheat
|
||||||
},
|
},
|
||||||
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
|
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
|
||||||
@@ -884,7 +1066,14 @@ export default {
|
|||||||
tmp
|
tmp
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
},
|
||||||
|
getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
||||||
|
if (fav && rawUser) {
|
||||||
|
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||||
|
if (isPrivate && !isAllowed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
const hall = rawHall || null;
|
const hall = rawHall || null;
|
||||||
|
|
||||||
@@ -925,7 +1114,22 @@ export default {
|
|||||||
const isStrict = strictParams.length > 0;
|
const isStrict = strictParams.length > 0;
|
||||||
|
|
||||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
let baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||||
|
if (!session) {
|
||||||
|
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||||
|
if (cfg.websrv.public_nsfw) {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||||
|
} else {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||||
|
}
|
||||||
|
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||||
|
baseMode = "1 = 0";
|
||||||
|
}
|
||||||
|
}
|
||||||
const modequery = baseMode;
|
const modequery = baseMode;
|
||||||
|
|
||||||
let item;
|
let item;
|
||||||
@@ -938,6 +1142,7 @@ export default {
|
|||||||
WHERE
|
WHERE
|
||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
|
AND coalesce(items.visibility, 0) = 0
|
||||||
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
||||||
AND items.title IS NOT NULL
|
AND items.title IS NOT NULL
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
@@ -960,6 +1165,7 @@ export default {
|
|||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and "user".user ilike ${user}
|
and "user".user ilike ${user}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
and coalesce(items.visibility, 0) = 0
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
group by items.id
|
group by items.id
|
||||||
@@ -999,6 +1205,7 @@ export default {
|
|||||||
where
|
where
|
||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
and coalesce(items.visibility, 0) = 0
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${user ? db`and items.username ilike ${user}` : db``}
|
${user ? db`and items.username ilike ${user}` : db``}
|
||||||
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
|
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
|
||||||
@@ -1021,6 +1228,7 @@ export default {
|
|||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and h.slug = ${hall}
|
and h.slug = ${hall}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
and coalesce(items.visibility, 0) = 0
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
@@ -1037,6 +1245,7 @@ export default {
|
|||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and uha.hall_id = ${userHallId}
|
and uha.hall_id = ${userHallId}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
|
and coalesce(items.visibility, 0) = 0
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
order by random()
|
order by random()
|
||||||
@@ -1045,9 +1254,9 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// Uniform random logic for global requests (no user/tag/hall)
|
// Uniform random logic for global requests (no user/tag/hall)
|
||||||
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
|
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
|
||||||
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
const globalModeQuery = modequery;
|
||||||
// tagId optimisation only applies for single native modes (not multi-rating)
|
// tagId optimisation only applies for single native modes for logged-in users (not multi-rating or guest mode)
|
||||||
const tagId = !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
const tagId = session && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||||
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
|
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
|
||||||
: null;
|
: null;
|
||||||
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
||||||
@@ -1063,6 +1272,7 @@ export default {
|
|||||||
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
|
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
|
||||||
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
|
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
|
||||||
WHERE items.active = true
|
WHERE items.active = true
|
||||||
|
AND coalesce(items.visibility, 0) = 0
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
||||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||||
@@ -1088,7 +1298,8 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getComments: async (itemId, sort = 'new', process = true) => {
|
getComments: async (itemId, sort = 'new', process = true) => {
|
||||||
if (!itemId) return [];
|
const numericId = await resolveNumericItemId(itemId);
|
||||||
|
if (!numericId) return [];
|
||||||
const tStart = Date.now();
|
const tStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const comments = await db`
|
const comments = await db`
|
||||||
@@ -1101,7 +1312,7 @@ export default {
|
|||||||
FROM comments c
|
FROM comments c
|
||||||
JOIN "user" u ON c.user_id = u.id
|
JOIN "user" u ON c.user_id = u.id
|
||||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
WHERE c.item_id = ${itemId} AND c.is_deleted = false
|
WHERE c.item_id = ${numericId} AND c.is_deleted = false
|
||||||
ORDER BY COALESCE(c.is_pinned, false) DESC,
|
ORDER BY COALESCE(c.is_pinned, false) DESC,
|
||||||
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
|
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
|
||||||
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
||||||
@@ -1244,11 +1455,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getSubscriptionStatus: async (userId, itemId) => {
|
getSubscriptionStatus: async (userId, itemId) => {
|
||||||
if (!userId || !itemId) return false;
|
const numericId = await resolveNumericItemId(itemId);
|
||||||
|
if (!userId || !numericId) return false;
|
||||||
const tStart = Date.now();
|
const tStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${itemId} AND is_subscribed = true`;
|
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${numericId} AND is_subscribed = true`;
|
||||||
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${itemId} in ${Date.now() - tStart}ms`);
|
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${numericId} in ${Date.now() - tStart}ms`);
|
||||||
return sub.length > 0;
|
return sub.length > 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1256,9 +1468,10 @@ export default {
|
|||||||
},
|
},
|
||||||
processMentions,
|
processMentions,
|
||||||
markNotificationsRead: async (userId, itemId) => {
|
markNotificationsRead: async (userId, itemId) => {
|
||||||
if (!userId || !itemId) return;
|
const numericId = await resolveNumericItemId(itemId);
|
||||||
|
if (!userId || !numericId) return;
|
||||||
try {
|
try {
|
||||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${itemId} AND is_read = false`;
|
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${numericId} AND is_read = false`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[F0CKLIB] Error marking notifications as read:', e);
|
console.error('[F0CKLIB] Error marking notifications as read:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
|
|||||||
import security from "../security.mjs";
|
import security from "../security.mjs";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs";
|
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||||
@@ -319,7 +319,7 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get(/\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
||||||
const userId = +req.params.userId;
|
const userId = +req.params.userId;
|
||||||
const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
|
const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
|
||||||
if (user.length === 0) return res.reply({ code: 404, body: 'User not found' });
|
if (user.length === 0) return res.reply({ code: 404, body: 'User not found' });
|
||||||
@@ -651,6 +651,77 @@ export default (router, tpl) => {
|
|||||||
return res.writeHead(302, { "Location": "/admin" }).end();
|
return res.writeHead(302, { "Location": "/admin" }).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Config Manager API GET
|
||||||
|
router.get(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
|
||||||
|
const origin = req.headers?.origin || '*';
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
|
||||||
|
const isDev = cfg.main?.development === true;
|
||||||
|
if (!isDev && (!req.session || !req.session.admin)) {
|
||||||
|
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = path.resolve(process.cwd(), "config.json");
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
const json = JSON.parse(raw);
|
||||||
|
if (res.json) return res.json({ success: true, config: json });
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, config: json }));
|
||||||
|
} catch (err) {
|
||||||
|
if (res.json) return res.json({ success: false, msg: err.message });
|
||||||
|
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config Manager API POST
|
||||||
|
router.post(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
|
||||||
|
const origin = req.headers?.origin || '*';
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
|
||||||
|
const isDev = cfg.main?.development === true;
|
||||||
|
if (!isDev && (!req.session || !req.session.admin)) {
|
||||||
|
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let updatedConfig = req.post?.config || req.json?.config || req.post;
|
||||||
|
if (typeof updatedConfig === 'string') {
|
||||||
|
updatedConfig = JSON.parse(updatedConfig);
|
||||||
|
}
|
||||||
|
if (!updatedConfig || typeof updatedConfig !== 'object') {
|
||||||
|
throw new Error('Invalid configuration payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.resolve(process.cwd(), "config.json");
|
||||||
|
|
||||||
|
// Write formatted JSON to config.json on disk
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
|
||||||
|
|
||||||
|
// Mutate in-memory cfg object so changes apply immediately
|
||||||
|
Object.assign(cfg, updatedConfig);
|
||||||
|
|
||||||
|
if (getEnableItemSlugs()) {
|
||||||
|
ensureAllItemsHaveSlugs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log entry (if session available)
|
||||||
|
if (req.session?.id) {
|
||||||
|
await audit.log(req.session.id, 'update_config_file', 'system', 0, { updatedKeys: Object.keys(updatedConfig) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = { success: true, message: 'Configuration saved to config.json' };
|
||||||
|
if (res.json) return res.json(response);
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN] Config Save Error:', err);
|
||||||
|
const response = { success: false, msg: err.message };
|
||||||
|
if (res.json) return res.json(response, 400);
|
||||||
|
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
|
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
|
||||||
if (!getEnableCleanup()) {
|
if (!getEnableCleanup()) {
|
||||||
return res.redirect("/admin");
|
return res.redirect("/admin");
|
||||||
@@ -661,6 +732,7 @@ export default (router, tpl) => {
|
|||||||
enable_cleanup: getEnableCleanup(),
|
enable_cleanup: getEnableCleanup(),
|
||||||
cleanup_start_date: getCleanupStartDate(),
|
cleanup_start_date: getCleanupStartDate(),
|
||||||
cleanup_end_date: getCleanupEndDate(),
|
cleanup_end_date: getCleanupEndDate(),
|
||||||
|
cleanup_include_engaged: getCleanupIncludeEngaged(),
|
||||||
totals: await lib.countf0cks(),
|
totals: await lib.countf0cks(),
|
||||||
tmp: null
|
tmp: null
|
||||||
};
|
};
|
||||||
@@ -674,15 +746,18 @@ export default (router, tpl) => {
|
|||||||
try {
|
try {
|
||||||
const cleanup_start_date = req.post.cleanup_start_date || '';
|
const cleanup_start_date = req.post.cleanup_start_date || '';
|
||||||
const cleanup_end_date = req.post.cleanup_end_date || '';
|
const cleanup_end_date = req.post.cleanup_end_date || '';
|
||||||
|
const cleanup_include_engaged = req.post.cleanup_include_engaged === 'true' || req.post.cleanup_include_engaged === 'on' || req.post.cleanup_include_engaged === '1';
|
||||||
|
|
||||||
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||||
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||||
|
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_include_engaged', ${cleanup_include_engaged ? 'true' : 'false'}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||||
|
|
||||||
setCleanupStartDate(cleanup_start_date);
|
setCleanupStartDate(cleanup_start_date);
|
||||||
setCleanupEndDate(cleanup_end_date);
|
setCleanupEndDate(cleanup_end_date);
|
||||||
|
setCleanupIncludeEngaged(cleanup_include_engaged);
|
||||||
|
|
||||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||||
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() });
|
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), cleanup_include_engaged: getCleanupIncludeEngaged() });
|
||||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,17 +776,23 @@ export default (router, tpl) => {
|
|||||||
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
|
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Ensure settings are synced from DB before execution
|
// Ensure settings are synced from DB before execution
|
||||||
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`;
|
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date', 'cleanup_include_engaged')`;
|
||||||
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
|
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
|
||||||
|
|
||||||
const isEnabled = settingsMap['enable_cleanup'] === 'true';
|
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
|
||||||
const startDate = settingsMap['cleanup_start_date'] || '';
|
const startDate = settingsMap['cleanup_start_date'] || '';
|
||||||
const endDate = settingsMap['cleanup_end_date'] || '';
|
const endDate = settingsMap['cleanup_end_date'] || '';
|
||||||
|
|
||||||
|
const reqEngaged = req.post?.include_engaged;
|
||||||
|
const includeEngaged = reqEngaged !== undefined
|
||||||
|
? (reqEngaged === 'true' || reqEngaged === true || reqEngaged === 'on' || reqEngaged === '1')
|
||||||
|
: (settingsMap['cleanup_include_engaged'] === 'true' || getCleanupIncludeEngaged());
|
||||||
|
|
||||||
// Update memory state
|
// Update memory state
|
||||||
setEnableCleanup(isEnabled);
|
setEnableCleanup(isEnabled);
|
||||||
setCleanupStartDate(startDate);
|
setCleanupStartDate(startDate);
|
||||||
setCleanupEndDate(endDate);
|
setCleanupEndDate(endDate);
|
||||||
|
setCleanupIncludeEngaged(includeEngaged);
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
throw new Error('Cleanup is disabled in settings.');
|
throw new Error('Cleanup is disabled in settings.');
|
||||||
@@ -721,7 +802,7 @@ export default (router, tpl) => {
|
|||||||
throw new Error('Please select both a Start Date and an End Date.');
|
throw new Error('Please select both a Start Date and an End Date.');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`);
|
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate} (includeEngaged: ${includeEngaged})...`);
|
||||||
|
|
||||||
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
|
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
|
||||||
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
|
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
|
||||||
@@ -737,7 +818,7 @@ export default (router, tpl) => {
|
|||||||
AND i.is_pinned = false
|
AND i.is_pinned = false
|
||||||
AND i.stamp >= ${start_stamp}
|
AND i.stamp >= ${start_stamp}
|
||||||
AND i.stamp <= ${end_stamp}
|
AND i.stamp <= ${end_stamp}
|
||||||
AND (
|
${includeEngaged ? db`` : db`AND (
|
||||||
-- Case 1: Active posts with no engagement (ignoring automatic subscriptions)
|
-- Case 1: Active posts with no engagement (ignoring automatic subscriptions)
|
||||||
(i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id))
|
(i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id))
|
||||||
OR
|
OR
|
||||||
@@ -746,7 +827,7 @@ export default (router, tpl) => {
|
|||||||
OR
|
OR
|
||||||
-- Case 3: Pending posts (not yet approved) that are old enough
|
-- Case 3: Pending posts (not yet approved) that are old enough
|
||||||
(i.active = false AND i.is_deleted = false)
|
(i.active = false AND i.is_deleted = false)
|
||||||
)
|
)`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
|
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
|
||||||
@@ -1428,8 +1509,8 @@ export default (router, tpl) => {
|
|||||||
const userId = newUser[0].id;
|
const userId = newUser[0].id;
|
||||||
|
|
||||||
await db`
|
await db`
|
||||||
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
||||||
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
|
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
|
|||||||
import { createI18n } from "../i18n.mjs";
|
import { createI18n } from "../i18n.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
||||||
const tAjaxStart = Date.now();
|
const tAjaxStart = Date.now();
|
||||||
let query = {};
|
let query = {};
|
||||||
if (typeof req.url === 'string') {
|
if (typeof req.url === 'string') {
|
||||||
@@ -35,12 +35,12 @@ export default (router, tpl) => {
|
|||||||
const ratingsRaw = req.cookies.ratings;
|
const ratingsRaw = req.cookies.ratings;
|
||||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
|
||||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
|
||||||
const data = await f0cklib.getf0ck({
|
const data = await f0cklib.getf0ck({
|
||||||
itemid: itemid,
|
itemid: itemid,
|
||||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
url: contextUrl,
|
url: contextUrl,
|
||||||
user: query.user,
|
user: query.user,
|
||||||
tag: query.tag,
|
tag: query.tag,
|
||||||
@@ -170,7 +170,8 @@ export default (router, tpl) => {
|
|||||||
html: itemHtml,
|
html: itemHtml,
|
||||||
pagination: paginationHtml,
|
pagination: paginationHtml,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
id: itemid
|
id: itemid,
|
||||||
|
slug: data.item?.slug || null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -194,7 +195,7 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
|
|
||||||
// Infinite scroll endpoint for index thumbnails
|
// Infinite scroll endpoint for index thumbnails
|
||||||
router.get(/\/ajax\/items/, async (req, res) => {
|
router.get(/^\/ajax\/items/, async (req, res) => {
|
||||||
let query = {};
|
let query = {};
|
||||||
if (typeof req.url === 'string') {
|
if (typeof req.url === 'string') {
|
||||||
const parsedUrl = url.parse(req.url, true);
|
const parsedUrl = url.parse(req.url, true);
|
||||||
@@ -218,9 +219,10 @@ export default (router, tpl) => {
|
|||||||
mime: query.mime || (req.cookies.mime || null),
|
mime: query.mime || (req.cookies.mime || null),
|
||||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
|
is_admin: req.session?.admin,
|
||||||
|
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||||
fav: query.fav === 'true',
|
fav: query.fav === 'true',
|
||||||
random: isRandom,
|
random: isRandom,
|
||||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||||
@@ -231,6 +233,19 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
if (data.is_private) {
|
||||||
|
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||||
|
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
is_private: true,
|
||||||
|
html: privateHtml,
|
||||||
|
hasMore: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ import { promises as fs } from "fs";
|
|||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
|
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||||
import queue from '../../queue.mjs';
|
import queue from '../../queue.mjs';
|
||||||
import search from '../../routeinc/search.mjs';
|
import search from '../../routeinc/search.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import f0cklib from '../../routeinc/f0cklib.mjs';
|
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||||
import audit from '../../audit.mjs';
|
import audit from '../../audit.mjs';
|
||||||
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||||
|
import { purgeExpiredUploads } from '../../lib_delete.mjs';
|
||||||
|
import { calculateExpiresAt } from './upload.mjs';
|
||||||
|
|
||||||
const allowedMimes = ["audio", "image", "video", "%"];
|
const allowedMimes = ["audio", "image", "video", "%"];
|
||||||
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
const getGlobalfilter = () => {
|
||||||
|
if (!cfg.nsfp?.length) return null;
|
||||||
|
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
|
||||||
|
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||||
|
};
|
||||||
const metaCache = new Map();
|
const metaCache = new Map();
|
||||||
const MAX_META_CACHE = 2000;
|
const MAX_META_CACHE = 2000;
|
||||||
|
|
||||||
@@ -594,10 +601,12 @@ export default router => {
|
|||||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||||
strict: isStrict,
|
strict: isStrict,
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
exclude: req.session?.excluded_tags || []
|
exclude: req.session?.excluded_tags || [],
|
||||||
|
user_id: req.session?.id,
|
||||||
|
is_admin: req.session?.admin
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data.itemid) {
|
if (!data || !data.itemid) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
items: []
|
items: []
|
||||||
@@ -636,6 +645,7 @@ export default router => {
|
|||||||
items: {
|
items: {
|
||||||
...safeItem,
|
...safeItem,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
||||||
dest: relativeDest,
|
dest: relativeDest,
|
||||||
url: directUrl,
|
url: directUrl,
|
||||||
direct_url: directUrl
|
direct_url: directUrl
|
||||||
@@ -700,9 +710,11 @@ export default router => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const excludedTags = req.session?.excluded_tags || [];
|
const excludedTags = req.session?.excluded_tags || [];
|
||||||
|
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||||
|
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce("items".visibility, 0) = 0`;
|
||||||
|
|
||||||
const newest = (await db`select max(id) as id from "items"`)[0].id;
|
const newest = (await db`select max(id) as id from "items"`)[0]?.id || 0;
|
||||||
const oldest = (await db`select min(id) as id from "items"`)[0].id;
|
const oldest = (await db`select min(id) as id from "items"`)[0]?.id || 0;
|
||||||
const modequery = lib.getMode(opt.mode);
|
const modequery = lib.getMode(opt.mode);
|
||||||
|
|
||||||
const rows = (await db`
|
const rows = (await db`
|
||||||
@@ -712,6 +724,7 @@ export default router => {
|
|||||||
where
|
where
|
||||||
${db.unsafe(modequery)} and
|
${db.unsafe(modequery)} and
|
||||||
active = true
|
active = true
|
||||||
|
${visibilityFilter}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = "items".id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = "items".id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
${opt.older
|
${opt.older
|
||||||
? db`and id <= ${opt.older}`
|
? db`and id <= ${opt.older}`
|
||||||
@@ -727,8 +740,8 @@ export default router => {
|
|||||||
`).sort((a, b) => b.id - a.id);
|
`).sort((a, b) => b.id - a.id);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
atEnd: rows[0].id === newest,
|
atEnd: rows.length > 0 && rows[0].id === newest,
|
||||||
atStart: rows[rows.length - 1].id === oldest,
|
atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
|
||||||
success: true,
|
success: true,
|
||||||
items: rows
|
items: rows
|
||||||
}, 200);
|
}, 200);
|
||||||
@@ -743,20 +756,6 @@ export default router => {
|
|||||||
where id = ${+id} and active = true
|
where id = ${+id} and active = true
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
const next = await db`
|
|
||||||
select id
|
|
||||||
from "items"
|
|
||||||
where id > ${+id} and active = true
|
|
||||||
order by id
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
const prev = await db`
|
|
||||||
select id
|
|
||||||
from "items"
|
|
||||||
where id < ${+id} and active = true
|
|
||||||
order by id desc
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (item.length === 0) {
|
if (item.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -765,12 +764,54 @@ export default router => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actitem = item[0];
|
||||||
|
const session = req.session;
|
||||||
|
const isOwnerOrAdmin = session && (
|
||||||
|
session.admin ||
|
||||||
|
session.is_moderator ||
|
||||||
|
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exclude unlisted (1) and private (2) items from API responses for non-owners/non-admins
|
||||||
|
if ((actitem.visibility || 0) > 0 && !isOwnerOrAdmin) {
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
msg: 'no items found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let guestTagFilter = db``;
|
||||||
|
if (!session) {
|
||||||
|
if (cfg.websrv.public_nsfw) {
|
||||||
|
guestTagFilter = cfg.websrv.public_untagged
|
||||||
|
? db`and (id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))`
|
||||||
|
: db`and id in (select item_id from tags_assign where tag_id in (1, 2))`;
|
||||||
|
} else {
|
||||||
|
guestTagFilter = cfg.websrv.public_untagged
|
||||||
|
? db`and (id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))`
|
||||||
|
: db`and id in (select item_id from tags_assign where tag_id = 1)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await db`
|
||||||
|
select id
|
||||||
|
from "items"
|
||||||
|
where id > ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
|
||||||
|
order by id
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
const prev = await db`
|
||||||
|
select id
|
||||||
|
from "items"
|
||||||
|
where id < ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
|
||||||
|
order by id desc
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
|
||||||
const rows = {
|
const rows = {
|
||||||
...item[0],
|
...actitem,
|
||||||
...{
|
|
||||||
next: next[0]?.id ?? null,
|
next: next[0]?.id ?? null,
|
||||||
prev: prev[0]?.id ?? null
|
prev: prev[0]?.id ?? null
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -1038,7 +1079,24 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
|
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
|
||||||
const postid = +req.post.postid;
|
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
|
||||||
|
if (rawPostid === undefined || rawPostid === null) {
|
||||||
|
return res.json({ success: false, msg: 'Missing postid' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support both numeric item ID and string slug
|
||||||
|
const isNumeric = /^\d+$/.test(String(rawPostid));
|
||||||
|
const itemRow = await db`
|
||||||
|
SELECT id FROM items
|
||||||
|
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!itemRow.length) {
|
||||||
|
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const postid = itemRow[0].id;
|
||||||
|
|
||||||
// Check if already faved by this user — compare as numbers to avoid type mismatch
|
// Check if already faved by this user — compare as numbers to avoid type mismatch
|
||||||
const existing = await db`
|
const existing = await db`
|
||||||
@@ -1066,7 +1124,7 @@ export default router => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const favs = await db`
|
const favs = await db`
|
||||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
|
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge
|
||||||
from "favorites"
|
from "favorites"
|
||||||
left join "user" on "user".id = "favorites".user_id
|
left join "user" on "user".id = "favorites".user_id
|
||||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||||
@@ -1168,6 +1226,121 @@ export default router => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.enable_private_uploads === false) {
|
||||||
|
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
|
||||||
|
}
|
||||||
|
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
|
||||||
|
const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10);
|
||||||
|
if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) {
|
||||||
|
return res.json({ success: false, msg: 'Invalid parameters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNumeric = /^\d+$/.test(String(postid));
|
||||||
|
const item = await db`
|
||||||
|
SELECT id, slug, username, visibility
|
||||||
|
FROM items
|
||||||
|
WHERE ${isNumeric ? db`id = ${+postid}` : db`slug = ${String(postid)}`} AND active = true AND is_deleted = false
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (item.length === 0) {
|
||||||
|
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = item[0].username === req.session.user;
|
||||||
|
const isAdmin = req.session.admin || req.session.is_moderator;
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`;
|
||||||
|
|
||||||
|
f0cklib.clearCountCache();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
itemid: item[0].id,
|
||||||
|
slug: item[0].slug,
|
||||||
|
visibility: visibility
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
|
||||||
|
const itemid = +req.params.id;
|
||||||
|
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||||
|
|
||||||
|
const rows = await db`
|
||||||
|
SELECT id, dest, mime, username
|
||||||
|
FROM items
|
||||||
|
WHERE id = ${itemid} AND active = true AND is_deleted = false
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
|
|
||||||
|
const item = rows[0];
|
||||||
|
const isOwner = item.username === req.session.user;
|
||||||
|
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||||
|
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
|
||||||
|
const ok = await queue.genThumbnail(item.dest, item.mime, item.id, '', false);
|
||||||
|
await queue.genBlurredThumbnail(item.id, false);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
db.notify('rethumb', JSON.stringify({ item_id: item.id })).catch(() => {});
|
||||||
|
audit.log(req.session.id, 'rethumb_item', 'item', item.id, {}).catch(() => {});
|
||||||
|
return res.json({ success: true, itemid: item.id });
|
||||||
|
} else {
|
||||||
|
return res.json({ success: false, msg: 'Thumbnail regeneration failed' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||||
|
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemid = +req.params.id;
|
||||||
|
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||||
|
|
||||||
|
const rows = await db`
|
||||||
|
SELECT id, username, expires_at
|
||||||
|
FROM items
|
||||||
|
WHERE id = ${itemid} AND active = true AND is_deleted = false
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
|
|
||||||
|
const item = rows[0];
|
||||||
|
const isOwner = item.username === req.session.user;
|
||||||
|
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||||
|
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
|
||||||
|
const reqExpiry = req.body?.expiry ?? req.body?.expires_at ?? req.post?.expiry ?? req.post?.expires_at;
|
||||||
|
const nowStamp = Math.floor(Date.now() / 1000);
|
||||||
|
const targetExpiresAt = calculateExpiresAt(reqExpiry, nowStamp);
|
||||||
|
|
||||||
|
await db`UPDATE items SET expires_at = ${targetExpiresAt} WHERE id = ${item.id}`;
|
||||||
|
audit.log(req.session.id, 'set_item_expiry', 'item', item.id, { expires_at: targetExpiresAt }).catch(() => {});
|
||||||
|
|
||||||
|
if (targetExpiresAt && targetExpiresAt <= nowStamp) {
|
||||||
|
await purgeExpiredUploads().catch(err => {
|
||||||
|
console.error('[API ITEM EXPIRY] Purge failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires_in = targetExpiresAt ? lib.expiresIn(targetExpiresAt) : null;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
itemid: item.id,
|
||||||
|
expires_at: targetExpiresAt,
|
||||||
|
expires_in: expires_in,
|
||||||
|
purged: !!(targetExpiresAt && targetExpiresAt <= nowStamp)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
|
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
|
||||||
const itemid = +req.params.id;
|
const itemid = +req.params.id;
|
||||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||||
|
|||||||
@@ -315,6 +315,68 @@ export default router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Favorites Privacy preference
|
||||||
|
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
|
||||||
|
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set favorites_private = ${favorites_private}
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
// Sync session immediately
|
||||||
|
if (req.session) req.session.favorites_private = favorites_private;
|
||||||
|
return res.json({ success: true, favorites_private }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Update Favorites Privacy pref error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Hide Fav Badge preference
|
||||||
|
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
|
||||||
|
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set hide_fav_badge = ${hide_fav_badge}
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
// Sync session immediately
|
||||||
|
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
|
||||||
|
return res.json({ success: true, hide_fav_badge }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Update Hide Fav Badge pref error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Default Upload Visibility preference
|
||||||
|
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
|
||||||
|
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
|
||||||
|
}
|
||||||
|
const vis = parseInt(req.post.default_upload_visibility, 10);
|
||||||
|
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
|
||||||
|
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set default_upload_visibility = ${vis}
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
if (req.session) req.session.default_upload_visibility = vis;
|
||||||
|
return res.json({ success: true, default_upload_visibility: vis }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Update Default Upload Visibility pref error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update Username Color preference
|
// Update Username Color preference
|
||||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||||
const { color } = req.post;
|
const { color } = req.post;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export default router => {
|
|||||||
|
|
||||||
group.post(/$/, lib.loggedin, async (req, res) => {
|
group.post(/$/, lib.loggedin, async (req, res) => {
|
||||||
// assign and/or create tag
|
// assign and/or create tag
|
||||||
if (!req.params.postid || !req.post.tagname) {
|
const rawTagname = req.post?.tagname || req.body?.tagname;
|
||||||
|
if (!req.params.postid || !rawTagname) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
msg: 'missing postid or tag'
|
msg: 'missing postid or tag'
|
||||||
@@ -33,7 +34,7 @@ export default router => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postid = +req.params.postid;
|
const postid = +req.params.postid;
|
||||||
const tagname = req.post.tagname?.trim();
|
const tagname = rawTagname.trim();
|
||||||
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
||||||
|
|
||||||
if (protectedTags.includes(tagname.toLowerCase())) {
|
if (protectedTags.includes(tagname.toLowerCase())) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { spawn as _spawnRaw } from 'child_process';
|
|||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
|
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||||
import { applyWordFilter } from '../../wordfilter.mjs';
|
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||||
import queue from '../../queue.mjs';
|
import queue from '../../queue.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -137,6 +138,71 @@ const parseMultipart = (buffer, boundary) => {
|
|||||||
|
|
||||||
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
|
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
|
||||||
|
|
||||||
|
const getTargetVisibility = (req, postVis) => {
|
||||||
|
if (cfg.enable_private_uploads === false) return 0;
|
||||||
|
|
||||||
|
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
|
||||||
|
? cfg.default_upload_visibility
|
||||||
|
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
|
||||||
|
|
||||||
|
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
|
||||||
|
|
||||||
|
if (!allowUserOverride) {
|
||||||
|
return sysDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null;
|
||||||
|
const val = (rawHeader || postVis || '').toString().trim().toLowerCase();
|
||||||
|
if (val === 'private' || val === '2') return 2;
|
||||||
|
if (val === 'unlisted' || val === '1') return 1;
|
||||||
|
if (val === 'public' || val === '0') return 0;
|
||||||
|
return (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
|
||||||
|
? req.session.default_upload_visibility
|
||||||
|
: sysDefault;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateExpiresAt(expiryVal, baseStamp = ~~(Date.now() / 1000)) {
|
||||||
|
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!expiryVal || expiryVal === 'permanent' || expiryVal === 'never' || expiryVal === '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const val = expiryVal.toString().trim().toLowerCase();
|
||||||
|
switch (val) {
|
||||||
|
case '30minutes':
|
||||||
|
case '30m':
|
||||||
|
case '1800':
|
||||||
|
return baseStamp + 1800;
|
||||||
|
case '1hour':
|
||||||
|
case '1h':
|
||||||
|
case '3600':
|
||||||
|
return baseStamp + 3600;
|
||||||
|
case '24hours':
|
||||||
|
case '24h':
|
||||||
|
case '1day':
|
||||||
|
case '86400':
|
||||||
|
return baseStamp + 86400;
|
||||||
|
case '1week':
|
||||||
|
case '1w':
|
||||||
|
case '7days':
|
||||||
|
case '604800':
|
||||||
|
return baseStamp + 604800;
|
||||||
|
case '1month':
|
||||||
|
case '1m':
|
||||||
|
case '30days':
|
||||||
|
case '2592000':
|
||||||
|
return baseStamp + 2592000;
|
||||||
|
default:
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
return parsed > 2000000000 ? parsed : baseStamp + parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Collect request body as buffer with debug logging
|
// Collect request body as buffer with debug logging
|
||||||
const collectBody = (req) => {
|
const collectBody = (req) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -369,6 +435,11 @@ export default router => {
|
|||||||
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
||||||
const filename = `yt:${videoId}`;
|
const filename = `yt:${videoId}`;
|
||||||
|
|
||||||
|
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||||
|
const itemSlug = lib.generateSlug(11);
|
||||||
|
const nowStamp = ~~(Date.now() / 1000);
|
||||||
|
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||||
|
|
||||||
const [{ id: itemid }] = await db`
|
const [{ id: itemid }] = await db`
|
||||||
insert into items ${db({
|
insert into items ${db({
|
||||||
src: ytUrl,
|
src: ytUrl,
|
||||||
@@ -380,11 +451,14 @@ export default router => {
|
|||||||
username: req.session.user,
|
username: req.session.user,
|
||||||
userchannel: 'web',
|
userchannel: 'web',
|
||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: nowStamp,
|
||||||
active: !isApprovalRequired,
|
active: !isApprovalRequired,
|
||||||
is_oc: !!is_oc,
|
is_oc: !!is_oc,
|
||||||
title: title
|
title: title,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
visibility: targetVisibility,
|
||||||
|
slug: itemSlug,
|
||||||
|
expires_at: targetExpiresAt
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -437,6 +511,9 @@ export default router => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
||||||
|
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||||
|
const itemSlug = lib.generateSlug(11);
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
id: req.session.id,
|
id: req.session.id,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
@@ -674,6 +751,7 @@ export default router => {
|
|||||||
await fs.unlink(source).catch(() => { });
|
await fs.unlink(source).catch(() => { });
|
||||||
|
|
||||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||||
|
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||||
|
|
||||||
const [{ id: itemid }] = await db`
|
const [{ id: itemid }] = await db`
|
||||||
insert into items ${db({
|
insert into items ${db({
|
||||||
@@ -686,11 +764,14 @@ export default router => {
|
|||||||
username: session.user,
|
username: session.user,
|
||||||
userchannel: 'web',
|
userchannel: 'web',
|
||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: nowStamp,
|
||||||
active: !isApprovalRequired,
|
active: !isApprovalRequired,
|
||||||
is_oc: !!is_oc,
|
is_oc: !!is_oc,
|
||||||
title: title
|
title: title,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
visibility: targetVisibility,
|
||||||
|
slug: itemSlug,
|
||||||
|
expires_at: targetExpiresAt
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -725,8 +806,8 @@ export default router => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
// Broadcast new_item event for live grid updates (only if auto-approved and public)
|
||||||
if (!isApprovalRequired) {
|
if (!isApprovalRequired && targetVisibility === 0) {
|
||||||
try {
|
try {
|
||||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||||
id: itemid,
|
id: itemid,
|
||||||
@@ -742,8 +823,8 @@ export default router => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push to Matrix Channel (only if auto-approved)
|
// Push to Matrix Channel (only if auto-approved and public)
|
||||||
if (!isApprovalRequired) {
|
if (!isApprovalRequired && targetVisibility === 0) {
|
||||||
try {
|
try {
|
||||||
const self = router.self;
|
const self = router.self;
|
||||||
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
|
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/chat/:id — admin: delete a single message
|
// DELETE /api/chat/:id — admin: delete a single message
|
||||||
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||||
if (!cfg.websrv.enable_global_chat) {
|
if (!cfg.websrv.enable_global_chat) {
|
||||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
|
import { getEnableItemSlugs } from "../settings.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import audit from "../audit.mjs";
|
import audit from "../audit.mjs";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
@@ -155,7 +156,8 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
|
||||||
|
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||||
/* <mode-override> */
|
/* <mode-override> */
|
||||||
// prioritize query mode (from AJAX) over session default
|
// prioritize query mode (from AJAX) over session default
|
||||||
@@ -174,7 +176,7 @@ export default (router, tpl) => {
|
|||||||
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||||
|
|
||||||
const comments = await db`
|
const comments = await db`
|
||||||
SELECT c.*, i.mime, i.id as item_id
|
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
|
||||||
FROM comments c
|
FROM comments c
|
||||||
LEFT JOIN items i ON c.item_id = i.id
|
LEFT JOIN items i ON c.item_id = i.id
|
||||||
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
||||||
@@ -208,6 +210,7 @@ export default (router, tpl) => {
|
|||||||
let processedComments = mentionsProcessed.map(c => {
|
let processedComments = mentionsProcessed.map(c => {
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
|
item_slug: getEnableItemSlugs() ? c.item_slug : null,
|
||||||
content: c.content
|
content: c.content
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -570,7 +573,9 @@ export default (router, tpl) => {
|
|||||||
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
|
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
|
||||||
const itemQuery = await db`
|
const itemQuery = await db`
|
||||||
SELECT
|
SELECT
|
||||||
|
i.slug,
|
||||||
i.xd_score,
|
i.xd_score,
|
||||||
|
COALESCE(i.visibility, 0) as visibility,
|
||||||
(SELECT ta.tag_id FROM tags_assign ta
|
(SELECT ta.tag_id FROM tags_assign ta
|
||||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||||
@@ -592,6 +597,7 @@ export default (router, tpl) => {
|
|||||||
type: 'comment',
|
type: 'comment',
|
||||||
id: commentId,
|
id: commentId,
|
||||||
item_id: item_id,
|
item_id: item_id,
|
||||||
|
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
|
||||||
parent_id: parent_id || null,
|
parent_id: parent_id || null,
|
||||||
body: notifyBody,
|
body: notifyBody,
|
||||||
username: req.session.user,
|
username: req.session.user,
|
||||||
@@ -609,9 +615,9 @@ export default (router, tpl) => {
|
|||||||
// 1. Thread live update
|
// 1. Thread live update
|
||||||
db.notify('comments', JSON.stringify(livePayload));
|
db.notify('comments', JSON.stringify(livePayload));
|
||||||
|
|
||||||
// 2. Sidebar activity update
|
// 2. Sidebar activity update (only for public items)
|
||||||
// Compute is_long using the full content (not the truncated notifyBody) so the
|
const itemVisibility = itemQuery[0]?.visibility ?? 0;
|
||||||
// sidebar renders the correct clamped state immediately on first paint.
|
if (itemVisibility === 0) {
|
||||||
const activityIsLong = content.length > 120
|
const activityIsLong = content.length > 120
|
||||||
|| content.split('\n').length > 2
|
|| content.split('\n').length > 2
|
||||||
|| activityFiles.length > 0
|
|| activityFiles.length > 0
|
||||||
@@ -632,6 +638,7 @@ export default (router, tpl) => {
|
|||||||
files: activityFiles,
|
files: activityFiles,
|
||||||
is_long: activityIsLong
|
is_long: activityIsLong
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Automatically subscribe user to the thread
|
// Automatically subscribe user to the thread
|
||||||
const subResult = await db`
|
const subResult = await db`
|
||||||
@@ -995,7 +1002,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
||||||
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
|
||||||
|
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||||
|
|
||||||
const comments = await db`
|
const comments = await db`
|
||||||
@@ -1003,6 +1011,7 @@ export default (router, tpl) => {
|
|||||||
c.*,
|
c.*,
|
||||||
i.mime,
|
i.mime,
|
||||||
i.id as item_id,
|
i.id as item_id,
|
||||||
|
i.slug as item_slug,
|
||||||
i.dest as item_dest,
|
i.dest as item_dest,
|
||||||
(SELECT ta.tag_id FROM tags_assign ta
|
(SELECT ta.tag_id FROM tags_assign ta
|
||||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||||
@@ -1019,6 +1028,7 @@ export default (router, tpl) => {
|
|||||||
WHERE c.is_deleted = false
|
WHERE c.is_deleted = false
|
||||||
AND i.active = true
|
AND i.active = true
|
||||||
AND i.is_deleted = false
|
AND i.is_deleted = false
|
||||||
|
AND COALESCE(i.visibility, 0) = 0
|
||||||
AND ${db.unsafe(modequery)}
|
AND ${db.unsafe(modequery)}
|
||||||
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
@@ -1027,9 +1037,15 @@ export default (router, tpl) => {
|
|||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Normalize item_slug based on getEnableItemSlugs()
|
||||||
|
const processedCommentsList = comments.map(c => ({
|
||||||
|
...c,
|
||||||
|
item_slug: getEnableItemSlugs() ? c.item_slug : null
|
||||||
|
}));
|
||||||
|
|
||||||
// Fetch comment file attachments
|
// Fetch comment file attachments
|
||||||
const filesMap = new Map();
|
const filesMap = new Map();
|
||||||
if (comments.length > 0) {
|
if (processedCommentsList.length > 0) {
|
||||||
const commentIds = comments.map(c => c.id);
|
const commentIds = comments.map(c => c.id);
|
||||||
try {
|
try {
|
||||||
const files = await db`
|
const files = await db`
|
||||||
|
|||||||
@@ -437,8 +437,9 @@ export default (router) => {
|
|||||||
stamp: ~~(Date.now() / 1000),
|
stamp: ~~(Date.now() / 1000),
|
||||||
active: !isApprovalRequired,
|
active: !isApprovalRequired,
|
||||||
is_oc: !!is_oc,
|
is_oc: !!is_oc,
|
||||||
original_filename: original_filename || null
|
original_filename: original_filename || null,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
|
slug: lib.generateSlug(11)
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default (router, tpl) => {
|
|||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
fav: false,
|
fav: false,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
random: isRandom
|
random: isRandom
|
||||||
});
|
});
|
||||||
@@ -99,8 +99,9 @@ export default (router, tpl) => {
|
|||||||
ratings: ratingsArr,
|
ratings: ratingsArr,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
fav: true,
|
fav: true,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
|
is_admin: req.session?.admin,
|
||||||
random: isRandom
|
random: isRandom
|
||||||
});
|
});
|
||||||
if (favs && 'items' in favs) {
|
if (favs && 'items' in favs) {
|
||||||
@@ -236,8 +237,9 @@ export default (router, tpl) => {
|
|||||||
fav: req.params.mode == 'favs',
|
fav: req.params.mode == 'favs',
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
|
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
|
is_admin: req.session?.admin,
|
||||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||||
url: decodeURIComponent(req.url.pathname || req.url),
|
url: decodeURIComponent(req.url.pathname || req.url),
|
||||||
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
|
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
|
||||||
@@ -251,6 +253,20 @@ export default (router, tpl) => {
|
|||||||
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
|
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
|
||||||
|
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||||
|
return res.reply({
|
||||||
|
code: 403,
|
||||||
|
body: tpl.render('error', {
|
||||||
|
message: tErr('profile.private_favorites'),
|
||||||
|
domain: cfg.main.url.domain,
|
||||||
|
tmp: null,
|
||||||
|
session: req.session ? { ...req.session } : false,
|
||||||
|
error_filter_hint: null,
|
||||||
|
error_filter_hint_link: null
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
}
|
||||||
// For index/grid views with zero items (empty DB), render an empty grid instead of error
|
// For index/grid views with zero items (empty DB), render an empty grid instead of error
|
||||||
if (mode !== 'item') {
|
if (mode !== 'item') {
|
||||||
data.items = [];
|
data.items = [];
|
||||||
@@ -374,6 +390,8 @@ export default (router, tpl) => {
|
|||||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||||
data.item_has_dimensions = !!(item.width && item.height);
|
data.item_has_dimensions = !!(item.width && item.height);
|
||||||
|
data.show_repost_row = !!((data.session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (item.is_repost || (item.reposts && item.reposts.length > 0)));
|
||||||
|
data.item.show_repost_row = data.show_repost_row;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
@@ -398,11 +416,11 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Specific route for direct item links: /user/:user/:itemid
|
// Specific route for direct item links: /user/:user/:itemid
|
||||||
// This avoids ambiguity with the profile route
|
// This avoids ambiguity with the profile route
|
||||||
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
|
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
|
||||||
|
|
||||||
// Generic router for everything else (Index, Tags, standard User Grids)
|
// Generic router for everything else (Index, Tags, standard User Grids)
|
||||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
|
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
|
||||||
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||||
/* </routing-refactor> */
|
/* </routing-refactor> */
|
||||||
|
|
||||||
router.get(/^\/(about)$/, (req, res) => {
|
router.get(/^\/(about)$/, (req, res) => {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const f0ck = await db`
|
const f0ck = await db`
|
||||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
|
||||||
from "items" i
|
from "items" i
|
||||||
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
||||||
where i.id = ${id} and i.active = false
|
where i.id = ${id} and i.active = false
|
||||||
@@ -156,6 +156,9 @@ export default (router, tpl) => {
|
|||||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPublic = (f0ck[0].visibility || 0) === 0;
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
// Push to Discord Webhook (Direct)
|
// Push to Discord Webhook (Direct)
|
||||||
try {
|
try {
|
||||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||||
@@ -217,6 +220,7 @@ export default (router, tpl) => {
|
|||||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Move files to public location
|
// Move files to public location
|
||||||
const movePaths = [
|
const movePaths = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
|
import { getEnableItemSlugs } from "../settings.mjs";
|
||||||
import { setMotd } from "../motd.mjs";
|
import { setMotd } from "../motd.mjs";
|
||||||
|
|
||||||
export const clients = new Set();
|
export const clients = new Set();
|
||||||
@@ -122,7 +123,7 @@ db.listen('activity', async (payload) => {
|
|||||||
// We need the username, avatar, and item mime for the preview
|
// We need the username, avatar, and item mime for the preview
|
||||||
// trigger only gave us user_id and item_id
|
// trigger only gave us user_id and item_id
|
||||||
const [details] = await db`
|
const [details] = await db`
|
||||||
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
|
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, i.slug as item_slug, COALESCE(i.visibility, 0) as visibility,
|
||||||
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
|
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
|
||||||
FROM "user" u
|
FROM "user" u
|
||||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||||
@@ -131,6 +132,7 @@ db.listen('activity', async (payload) => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
if (details) {
|
if (details) {
|
||||||
|
if (details.visibility > 0) return;
|
||||||
data.username = details.username;
|
data.username = details.username;
|
||||||
data.avatar = details.avatar;
|
data.avatar = details.avatar;
|
||||||
data.avatar_file = details.avatar_file;
|
data.avatar_file = details.avatar_file;
|
||||||
@@ -138,6 +140,7 @@ db.listen('activity', async (payload) => {
|
|||||||
data.username_color = details.username_color;
|
data.username_color = details.username_color;
|
||||||
data.display_name = details.display_name || null;
|
data.display_name = details.display_name || null;
|
||||||
data.tag_id = details.tag_id;
|
data.tag_id = details.tag_id;
|
||||||
|
data.item_slug = getEnableItemSlugs() ? details.item_slug : null;
|
||||||
} else {
|
} else {
|
||||||
data.username = 'System';
|
data.username = 'System';
|
||||||
}
|
}
|
||||||
@@ -210,6 +213,7 @@ db.listen('motd', (payload) => {
|
|||||||
db.listen('new_item', (payload) => {
|
db.listen('new_item', (payload) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(payload);
|
const data = JSON.parse(payload);
|
||||||
|
if (data.visibility && data.visibility !== 0) return;
|
||||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
|
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
client.send({ type: 'new_item', data });
|
client.send({ type: 'new_item', data });
|
||||||
@@ -367,7 +371,7 @@ export default (router, tpl) => {
|
|||||||
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
||||||
const notifications = typeFilter
|
const notifications = typeFilter
|
||||||
? await db`
|
? await db`
|
||||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||||
COALESCE(u.user, 'System') as from_user,
|
COALESCE(u.user, 'System') as from_user,
|
||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
COALESCE(u.id, 0) as from_user_id,
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
@@ -392,7 +396,7 @@ export default (router, tpl) => {
|
|||||||
OFFSET ${offset}
|
OFFSET ${offset}
|
||||||
`
|
`
|
||||||
: await db`
|
: await db`
|
||||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||||
COALESCE(u.user, 'System') as from_user,
|
COALESCE(u.user, 'System') as from_user,
|
||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
COALESCE(u.id, 0) as from_user_id,
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
@@ -426,7 +430,7 @@ export default (router, tpl) => {
|
|||||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||||
reason = data.reason || reason;
|
reason = data.reason || reason;
|
||||||
}
|
}
|
||||||
return { ...n, reason };
|
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -443,7 +447,7 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const notifications = await db`
|
const notifications = await db`
|
||||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||||
COALESCE(u.user, 'System') as from_user,
|
COALESCE(u.user, 'System') as from_user,
|
||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
COALESCE(u.id, 0) as from_user_id,
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
@@ -480,7 +484,7 @@ export default (router, tpl) => {
|
|||||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||||
reason = data.reason || reason;
|
reason = data.reason || reason;
|
||||||
}
|
}
|
||||||
return { ...n, reason };
|
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.reply({
|
return res.reply({
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ export default (router, tpl) => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const favotop = await db`
|
const favotop = await db`
|
||||||
select favorites.item_id, count(*) favs
|
select favorites.item_id as id, items.slug, count(*) favs
|
||||||
from favorites
|
from favorites
|
||||||
join items on items.id = favorites.item_id
|
join items on items.id = favorites.item_id
|
||||||
where items.active = true
|
where items.active = true
|
||||||
group by favorites.item_id
|
group by favorites.item_id, items.slug
|
||||||
having count(*) > 1
|
having count(*) > 1
|
||||||
order by favs desc
|
order by favs desc
|
||||||
limit 10
|
limit 10
|
||||||
@@ -110,7 +110,7 @@ export default (router, tpl) => {
|
|||||||
let xdtop = [];
|
let xdtop = [];
|
||||||
if (config.websrv.enable_xd_score) {
|
if (config.websrv.enable_xd_score) {
|
||||||
const xdRows = await db`
|
const xdRows = await db`
|
||||||
select id, xd_score
|
select id, slug, xd_score
|
||||||
from items
|
from items
|
||||||
where active = true and is_deleted = false and xd_score > 0
|
where active = true and is_deleted = false and xd_score > 0
|
||||||
order by xd_score desc
|
order by xd_score desc
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default (router, tpl) => {
|
|||||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
|
||||||
}
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
|
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() }, req)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export default (router, tpl) => {
|
|||||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||||
}
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
|
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() }, req)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export default (router, tpl) => {
|
|||||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
||||||
}
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
|
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() }, req)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,8 +88,9 @@ export default (router, tpl) => {
|
|||||||
return renderError("Passwords do not match.");
|
return renderError("Passwords do not match.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// reCAPTCHA verification
|
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
|
||||||
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
const isOnion = lib.isOnionRequest(req);
|
||||||
|
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
||||||
const rcToken = req.post['g-recaptcha-response'];
|
const rcToken = req.post['g-recaptcha-response'];
|
||||||
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
|
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
|
||||||
try {
|
try {
|
||||||
@@ -167,8 +168,8 @@ export default (router, tpl) => {
|
|||||||
const avatarFile = 'default.png';
|
const avatarFile = 'default.png';
|
||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
||||||
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[REGISTER] DB Error during user creation:`, err);
|
console.error(`[REGISTER] DB Error during user creation:`, err);
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export default (router, tpl) => {
|
|||||||
WHERE
|
WHERE
|
||||||
${db.unsafe(modeQuery)}
|
${db.unsafe(modeQuery)}
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
|
AND COALESCE(items.visibility, 0) = 0
|
||||||
${excludeSwfSQL}
|
${excludeSwfSQL}
|
||||||
${excludePdfSQL}
|
${excludePdfSQL}
|
||||||
${excludeArchiveSQL}
|
${excludeArchiveSQL}
|
||||||
@@ -292,6 +293,7 @@ export default (router, tpl) => {
|
|||||||
WHERE
|
WHERE
|
||||||
${db.unsafe(modeQuery)}
|
${db.unsafe(modeQuery)}
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
|
AND COALESCE(items.visibility, 0) = 0
|
||||||
${excludeSwfSQL}
|
${excludeSwfSQL}
|
||||||
${excludePdfSQL}
|
${excludePdfSQL}
|
||||||
${excludeArchiveSQL}
|
${excludeArchiveSQL}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default (router, tpl) => {
|
|||||||
const subs = await db`
|
const subs = await db`
|
||||||
SELECT
|
SELECT
|
||||||
s.created_at as sub_date,
|
s.created_at as sub_date,
|
||||||
i.id, i.dest, i.mime, i.username as uploader_name
|
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
|
||||||
FROM comment_subscriptions s
|
FROM comment_subscriptions s
|
||||||
JOIN items i ON s.item_id = i.id
|
JOIN items i ON s.item_id = i.id
|
||||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||||
@@ -45,6 +45,7 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const items = subs.map(i => ({
|
const items = subs.map(i => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
|
slug: i.slug || null,
|
||||||
user: i.uploader_name || 'System',
|
user: i.uploader_name || 'System',
|
||||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||||
thumb: `/t/${i.id}.webp`
|
thumb: `/t/${i.id}.webp`
|
||||||
@@ -108,7 +109,7 @@ export default (router, tpl) => {
|
|||||||
const subs = await db`
|
const subs = await db`
|
||||||
SELECT
|
SELECT
|
||||||
s.created_at as sub_date,
|
s.created_at as sub_date,
|
||||||
i.id, i.dest, i.mime, i.username as uploader_name
|
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
|
||||||
FROM comment_subscriptions s
|
FROM comment_subscriptions s
|
||||||
JOIN items i ON s.item_id = i.id
|
JOIN items i ON s.item_id = i.id
|
||||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||||
@@ -118,6 +119,7 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const items = subs.map(i => ({
|
const items = subs.map(i => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
|
slug: i.slug || null,
|
||||||
user: i.uploader_name || 'System',
|
user: i.uploader_name || 'System',
|
||||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||||
thumb: `/t/${i.id}.webp`
|
thumb: `/t/${i.id}.webp`
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export async function regenerateTagImage(tag, mode) {
|
|||||||
JOIN tags_assign ta ON ta.item_id = i.id
|
JOIN tags_assign ta ON ta.item_id = i.id
|
||||||
JOIN tags t ON t.id = ta.tag_id
|
JOIN tags t ON t.id = ta.tag_id
|
||||||
${modeFilter}
|
${modeFilter}
|
||||||
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
|
WHERE (t.tag = ${tag} OR t.normalized = ${tag})
|
||||||
|
AND i.active = true
|
||||||
|
AND COALESCE(i.visibility, 0) = 0
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT 3
|
LIMIT 3
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -9,7 +9,23 @@ export default (router, tpl) => {
|
|||||||
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
|
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
|
||||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||||
const isGuest = !sessionObj;
|
const isGuest = !sessionObj;
|
||||||
const modequery = lib.getMode(mode);
|
let baseMode = lib.getMode(mode);
|
||||||
|
if (isGuest) {
|
||||||
|
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
|
||||||
|
if (cfg.websrv.public_nsfw) {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||||
|
} else {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||||
|
}
|
||||||
|
} else if (!cfg.websrv.public_untagged && mode === 2) {
|
||||||
|
baseMode = "1 = 0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modequery = baseMode;
|
||||||
|
|
||||||
let restrictedFilter = db``;
|
let restrictedFilter = db``;
|
||||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||||
@@ -35,6 +51,7 @@ export default (router, tpl) => {
|
|||||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
JOIN items ON items.id = ta.item_id
|
JOIN items ON items.id = ta.item_id
|
||||||
WHERE items.active = true
|
WHERE items.active = true
|
||||||
|
AND COALESCE(items.visibility, 0) = 0
|
||||||
AND t.id NOT IN (1, 2)
|
AND t.id NOT IN (1, 2)
|
||||||
AND ${db.unsafe(modequery)}
|
AND ${db.unsafe(modequery)}
|
||||||
${restrictedFilter}
|
${restrictedFilter}
|
||||||
@@ -58,6 +75,7 @@ export default (router, tpl) => {
|
|||||||
JOIN items ON items.id = ta.item_id
|
JOIN items ON items.id = ta.item_id
|
||||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
|
AND COALESCE(items.visibility, 0) = 0
|
||||||
AND ${db.unsafe(modequery)}
|
AND ${db.unsafe(modequery)}
|
||||||
${restrictedFilter}
|
${restrictedFilter}
|
||||||
${userExcludeFilter}
|
${userExcludeFilter}
|
||||||
@@ -95,7 +113,7 @@ export default (router, tpl) => {
|
|||||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
hasMore: tags.length === TAGS_PER_PAGE
|
hasMore: tags.length === TAGS_PER_PAGE
|
||||||
});
|
});
|
||||||
@@ -128,6 +146,7 @@ export default (router, tpl) => {
|
|||||||
phrase,
|
phrase,
|
||||||
tmp: null,
|
tmp: null,
|
||||||
hidePagination: true,
|
hidePagination: true,
|
||||||
|
user: req.session?.user ? { user: req.session.user } : null,
|
||||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||||
page_meta: {
|
page_meta: {
|
||||||
title: 'Tags',
|
title: 'Tags',
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default (router, tpl) => {
|
|||||||
const data = await f0cklib.getf0cks({
|
const data = await f0cklib.getf0cks({
|
||||||
page: req.params.page,
|
page: req.params.page,
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
userHall: slug,
|
userHall: slug,
|
||||||
@@ -138,7 +138,7 @@ export default (router, tpl) => {
|
|||||||
const data = await f0cklib.getf0ck({
|
const data = await f0cklib.getf0ck({
|
||||||
itemid: req.params.itemid,
|
itemid: req.params.itemid,
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
session: !!req.session,
|
session: req.session,
|
||||||
exclude: req.session?.excluded_tags || [],
|
exclude: req.session?.excluded_tags || [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
userHall: slug,
|
userHall: slug,
|
||||||
@@ -260,7 +260,7 @@ export default (router, tpl) => {
|
|||||||
FROM items i
|
FROM items i
|
||||||
JOIN user_halls_assign uha ON uha.item_id = i.id
|
JOIN user_halls_assign uha ON uha.item_id = i.id
|
||||||
${modeFilter}
|
${modeFilter}
|
||||||
WHERE uha.hall_id = ${hall.id} AND i.active = true
|
WHERE uha.hall_id = ${hall.id} AND i.active = true AND COALESCE(i.visibility, 0) = 0
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT 3
|
LIMIT 3
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -9,7 +9,23 @@ export default (router, tpl) => {
|
|||||||
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
|
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
|
||||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||||
const isGuest = !sessionObj;
|
const isGuest = !sessionObj;
|
||||||
const modequery = lib.getMode(mode);
|
let baseMode = lib.getMode(mode);
|
||||||
|
if (isGuest) {
|
||||||
|
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
|
||||||
|
if (cfg.websrv.public_nsfw) {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||||
|
} else {
|
||||||
|
baseMode = cfg.websrv.public_untagged
|
||||||
|
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||||
|
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||||
|
}
|
||||||
|
} else if (!cfg.websrv.public_untagged && mode === 2) {
|
||||||
|
baseMode = "1 = 0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modequery = baseMode;
|
||||||
|
|
||||||
let restrictedFilter = db``;
|
let restrictedFilter = db``;
|
||||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||||
@@ -35,6 +51,7 @@ export default (router, tpl) => {
|
|||||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
JOIN items ON items.id = ta.item_id
|
JOIN items ON items.id = ta.item_id
|
||||||
WHERE items.active = true
|
WHERE items.active = true
|
||||||
|
AND COALESCE(items.visibility, 0) = 0
|
||||||
AND t.id NOT IN (1, 2)
|
AND t.id NOT IN (1, 2)
|
||||||
AND ta.user_id = ${userId}
|
AND ta.user_id = ${userId}
|
||||||
AND ${db.unsafe(modequery)}
|
AND ${db.unsafe(modequery)}
|
||||||
@@ -59,6 +76,7 @@ export default (router, tpl) => {
|
|||||||
JOIN items ON items.id = ta.item_id
|
JOIN items ON items.id = ta.item_id
|
||||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
|
AND COALESCE(items.visibility, 0) = 0
|
||||||
AND ta.user_id = ${userId}
|
AND ta.user_id = ${userId}
|
||||||
AND ${db.unsafe(modequery)}
|
AND ${db.unsafe(modequery)}
|
||||||
${restrictedFilter}
|
${restrictedFilter}
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ export default new class {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
getRealIP(req) {
|
getRealIP(req) {
|
||||||
let ip = req.headers['cf-connecting-ip'] ||
|
let rawIp = req.headers['cf-connecting-ip'] ||
|
||||||
req.headers['true-client-ip'] ||
|
req.headers['true-client-ip'] ||
|
||||||
req.headers['x-client-ip'] ||
|
req.headers['x-client-ip'] ||
|
||||||
req.headers['x-real-ip'] ||
|
req.headers['x-real-ip'] ||
|
||||||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
req.headers['x-forwarded-for'] ||
|
||||||
req.socket.remoteAddress;
|
req.socket?.remoteAddress;
|
||||||
|
|
||||||
if (!ip) return "unknown";
|
if (!rawIp) return "unknown";
|
||||||
|
|
||||||
|
// If the header contains a comma-separated proxy chain (e.g. "IP1, IP2"), extract the client IP (first element)
|
||||||
|
let ip = String(rawIp).split(',')[0].trim();
|
||||||
|
|
||||||
// Handle IPv6 loopback and mapped IPv4
|
// Handle IPv6 loopback and mapped IPv4
|
||||||
if (ip === "::1") ip = "127.0.0.1";
|
if (ip === "::1") ip = "127.0.0.1";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import cfg from "./config.mjs";
|
import cfg from "./config.mjs";
|
||||||
|
import db from "./sql.mjs";
|
||||||
|
import lib from "./lib.mjs";
|
||||||
|
|
||||||
let manual_approval = true;
|
let manual_approval = true;
|
||||||
let min_tags = 3;
|
let min_tags = 3;
|
||||||
@@ -14,9 +16,37 @@ let enable_pdf = false;
|
|||||||
let enable_cleanup = false;
|
let enable_cleanup = false;
|
||||||
let cleanup_start_date = '';
|
let cleanup_start_date = '';
|
||||||
let cleanup_end_date = '';
|
let cleanup_end_date = '';
|
||||||
|
let cleanup_include_engaged = false;
|
||||||
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
|
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
|
||||||
export const setShitpostMode = (val) => {}; // No-op, strictly config-based
|
export const setShitpostMode = (val) => {}; // No-op, strictly config-based
|
||||||
|
|
||||||
|
export const getEnableExpiringUploads = () => {
|
||||||
|
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEnableItemSlugs = () => {
|
||||||
|
if (cfg.enable_item_slugs === false || cfg.websrv?.enable_item_slugs === false) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureAllItemsHaveSlugs = async () => {
|
||||||
|
try {
|
||||||
|
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
|
||||||
|
if (!rows || rows.length === 0) return;
|
||||||
|
console.log(`[SLUG_BACKFILL] Found ${rows.length} item(s) missing slugs. Backfilling...`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const newSlug = lib.generateSlug(11);
|
||||||
|
await db`UPDATE items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
|
||||||
|
}
|
||||||
|
console.log(`[SLUG_BACKFILL] Successfully backfilled ${rows.length} item slug(s).`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SLUG_BACKFILL] Error during slug backfill:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getEnableCleanup = () => {
|
export const getEnableCleanup = () => {
|
||||||
if (cfg.websrv.enable_cleanup === false) return false;
|
if (cfg.websrv.enable_cleanup === false) return false;
|
||||||
return enable_cleanup;
|
return enable_cleanup;
|
||||||
@@ -29,6 +59,9 @@ export const setCleanupStartDate = (val) => cleanup_start_date = val || '';
|
|||||||
export const getCleanupEndDate = () => cleanup_end_date;
|
export const getCleanupEndDate = () => cleanup_end_date;
|
||||||
export const setCleanupEndDate = (val) => cleanup_end_date = val || '';
|
export const setCleanupEndDate = (val) => cleanup_end_date = val || '';
|
||||||
|
|
||||||
|
export const getCleanupIncludeEngaged = () => cleanup_include_engaged;
|
||||||
|
export const setCleanupIncludeEngaged = (val) => cleanup_include_engaged = !!val;
|
||||||
|
|
||||||
export const getEnablePdf = () => enable_pdf;
|
export const getEnablePdf = () => enable_pdf;
|
||||||
export const setEnablePdf = (val) => enable_pdf = !!val;
|
export const setEnablePdf = (val) => enable_pdf = !!val;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default async bot => {
|
|||||||
rows = await db`
|
rows = await db`
|
||||||
select id, mime, username, size
|
select id, mime, username, size
|
||||||
from "items"
|
from "items"
|
||||||
where id >= ${randomId} and active = true
|
where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
|
||||||
order by id asc
|
order by id asc
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
@@ -43,7 +43,7 @@ export default async bot => {
|
|||||||
rows = await db`
|
rows = await db`
|
||||||
select id, mime, username, size
|
select id, mime, username, size
|
||||||
from "items"
|
from "items"
|
||||||
where active = true
|
where active = true and coalesce(visibility, 0) = 0
|
||||||
order by id asc
|
order by id asc
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
@@ -54,7 +54,7 @@ export default async bot => {
|
|||||||
rows = await db`
|
rows = await db`
|
||||||
select id, mime, username, size
|
select id, mime, username, size
|
||||||
from "items"
|
from "items"
|
||||||
where
|
where active = true and coalesce(visibility, 0) = 0 and
|
||||||
${args.map(a => a.charAt(0) === "!"
|
${args.map(a => a.charAt(0) === "!"
|
||||||
? db`username not ilike ${a.slice(1)}`
|
? db`username not ilike ${a.slice(1)}`
|
||||||
: db`username ilike ${a}`
|
: db`username ilike ${a}`
|
||||||
|
|||||||
@@ -752,8 +752,9 @@ export default async bot => {
|
|||||||
userchannel: e.channel,
|
userchannel: e.channel,
|
||||||
usernetwork: e.network,
|
usernetwork: e.network,
|
||||||
stamp: ~~(new Date() / 1000),
|
stamp: ~~(new Date() / 1000),
|
||||||
active: !getManualApproval()
|
active: !getManualApproval(),
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
slug: lib.generateSlug(11)
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||||
`;
|
`;
|
||||||
const itemid = await queue.getItemID(filename);
|
const itemid = await queue.getItemID(filename);
|
||||||
|
|
||||||
@@ -858,8 +859,9 @@ export default async bot => {
|
|||||||
userchannel: e.channel,
|
userchannel: e.channel,
|
||||||
usernetwork: e.network,
|
usernetwork: e.network,
|
||||||
stamp: ~~(new Date() / 1000),
|
stamp: ~~(new Date() / 1000),
|
||||||
active: !getManualApproval()
|
active: !getManualApproval(),
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
slug: lib.generateSlug(11)
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||||
`;
|
`;
|
||||||
const itemid = await queue.getItemID(filename);
|
const itemid = await queue.getItemID(filename);
|
||||||
|
|
||||||
|
|||||||
237
src/index.mjs
237
src/index.mjs
@@ -20,9 +20,11 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
|||||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
||||||
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
||||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } from "./inc/settings.mjs";
|
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
|
||||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
|
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
||||||
|
|
||||||
import security from "./inc/security.mjs";
|
import security from "./inc/security.mjs";
|
||||||
|
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
@@ -49,7 +51,7 @@ const gateOptions = {
|
|||||||
},
|
},
|
||||||
cloudflare_status: {
|
cloudflare_status: {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
location: 'Frankfurt',
|
location: cfg.websrv.private_society_gate_location || 'Frankfurt',
|
||||||
name: 'Cloudflare',
|
name: 'Cloudflare',
|
||||||
status_text: 'Working',
|
status_text: 'Working',
|
||||||
},
|
},
|
||||||
@@ -79,11 +81,13 @@ const nginx502Fallback = `<html>
|
|||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
// Login + Register modal injected before </body>
|
// Login + Register modal injected before </body>
|
||||||
// This string is built at startup so it can reference cfg.recaptcha values.
|
// Dynamic function so reCAPTCHA can be bypassed for .onion requests
|
||||||
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
||||||
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
|
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
|
||||||
|
|
||||||
const gateLoginInjection = `
|
function getGateLoginInjection(req) {
|
||||||
|
const rcEnabled = _rcEnabled && !lib.isOnionRequest(req);
|
||||||
|
return `
|
||||||
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
|
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
|
||||||
|
|
||||||
<div id="gate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;align-items:center;justify-content:center;">
|
<div id="gate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;align-items:center;justify-content:center;">
|
||||||
@@ -123,7 +127,7 @@ const gateLoginInjection = `
|
|||||||
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
|
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
|
||||||
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
|
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
|
||||||
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
|
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
|
||||||
${_rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
|
${rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
|
||||||
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
|
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
|
||||||
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
|
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
|
||||||
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
|
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
|
||||||
@@ -264,8 +268,9 @@ const gateLoginInjection = `
|
|||||||
if (_sb.length > 12) _sb = _sb.slice(-12);
|
if (_sb.length > 12) _sb = _sb.slice(-12);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
${_rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
|
${rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Text injected into the "What can I do?" section
|
// Text injected into the "What can I do?" section
|
||||||
@@ -282,11 +287,40 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Called on every gated request — produces a fresh page with unique Ray ID + timestamp
|
// Called on every gated request — produces a fresh page with unique Ray ID + timestamp
|
||||||
// Accepts an optional request object to inject the visitor's real IP into the footer reveal.
|
// Accepts an optional request object to inject the visitor's real IP into the footer reveal and dynamic host into page text/title.
|
||||||
function buildGatePage(req) {
|
function buildGatePage(req) {
|
||||||
if (!_cfRender) return nginx502Fallback;
|
if (!_cfRender) return nginx502Fallback;
|
||||||
|
|
||||||
let html = _cfRender({ ...gateOptions, what_can_i_do: gateSignInButton });
|
let reqHost = null;
|
||||||
|
if (req && req.headers) {
|
||||||
|
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'];
|
||||||
|
if (rawHost) {
|
||||||
|
const firstHost = rawHost.split(',')[0].trim();
|
||||||
|
let extracted = '';
|
||||||
|
if (firstHost.startsWith('[')) {
|
||||||
|
const closeBracket = firstHost.indexOf(']');
|
||||||
|
extracted = closeBracket !== -1 ? firstHost.substring(1, closeBracket) : firstHost;
|
||||||
|
} else {
|
||||||
|
extracted = firstHost.split(':')[0].trim();
|
||||||
|
}
|
||||||
|
const isLocalHost = ['localhost', '127.0.0.1', '::1', 'f0ckm', '0.0.0.0', 'localhost.localdomain'].includes(extracted.toLowerCase());
|
||||||
|
if (extracted && !isLocalHost) {
|
||||||
|
reqHost = extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!reqHost) reqHost = cfg.main.url.domain;
|
||||||
|
|
||||||
|
const currentGateOptions = {
|
||||||
|
...gateOptions,
|
||||||
|
host_status: {
|
||||||
|
...gateOptions.host_status,
|
||||||
|
location: reqHost,
|
||||||
|
},
|
||||||
|
what_can_i_do: gateSignInButton,
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = _cfRender(currentGateOptions);
|
||||||
|
|
||||||
// Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
|
// Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
|
||||||
if (req) {
|
if (req) {
|
||||||
@@ -305,9 +339,9 @@ function buildGatePage(req) {
|
|||||||
// Patch the <title> to include the domain — matches real Cloudflare behaviour
|
// Patch the <title> to include the domain — matches real Cloudflare behaviour
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
'<title>502: Bad Gateway</title>',
|
'<title>502: Bad Gateway</title>',
|
||||||
`<title>${cfg.main.url.domain} | 502: Bad gateway</title>`
|
`<title>${reqHost} | 502: Bad gateway</title>`
|
||||||
);
|
);
|
||||||
html = html.replace('</body>', gateLoginInjection + '\n</body>');
|
html = html.replace('</body>', getGateLoginInjection(req) + '\n</body>');
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +350,14 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
|
|||||||
? null
|
? null
|
||||||
: nginx502Fallback;
|
: nginx502Fallback;
|
||||||
|
|
||||||
|
// Custom gate template — resolved once at boot from config
|
||||||
|
// Set private_society_gate: "custom" and private_society_gate_template: "your-template-name" (no .html)
|
||||||
|
const _customGateTemplate = (cfg.websrv.private_society && cfg.websrv.private_society_gate === 'custom' && cfg.websrv.private_society_gate_template)
|
||||||
|
? String(cfg.websrv.private_society_gate_template).replace(/\.html$/i, '')
|
||||||
|
: null;
|
||||||
|
|
||||||
if (nginx502 === null) console.log('[BOOT] Private society gate: Cloudflare dynamic mode');
|
if (nginx502 === null) console.log('[BOOT] Private society gate: Cloudflare dynamic mode');
|
||||||
|
if (_customGateTemplate) console.log(`[BOOT] Private society gate: Custom template → views/${_customGateTemplate}.html (flummpress resolves extension)`);
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -456,11 +497,58 @@ process.on('uncaughtException', err => {
|
|||||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
|
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
// Block incoming requests to .onion if Tor Hidden Service is explicitly disabled in config
|
||||||
|
if (cfg.websrv?.enable_tor_hs === false && lib.isOnionRequest(req)) {
|
||||||
|
res.writeHead(503).end();
|
||||||
|
req.url.pathname = '/tor_hs_disabled_bypass';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tor Onion-Location header: advertises .onion service counterpart to Tor Browser users when enabled
|
||||||
|
if (cfg.websrv?.enable_tor_hs !== false && cfg.main?.onion && !lib.isOnionRequest(req)) {
|
||||||
|
const p = req.url?.pathname || '/';
|
||||||
|
const s = req.url?.search || '';
|
||||||
|
const onionHost = cfg.main.onion.replace(/\/+$/, '');
|
||||||
|
res.setHeader('Onion-Location', `${onionHost}${p}${s}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (isSecure) {
|
if (isSecure) {
|
||||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Global CORS & OPTIONS preflight handler for API routes (enables standalone config_editor.html)
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.url?.pathname?.startsWith('/api/')) {
|
||||||
|
const origin = req.headers?.origin || '*';
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With, X-CSRF-Token, Authorization');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204).end();
|
||||||
|
req.url.pathname = '/handled_options_bypass';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve standalone config_editor.html statically
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.method === 'GET' && (req.url?.pathname === '/config_editor.html' || req.url?.pathname === '/config.html')) {
|
||||||
|
try {
|
||||||
|
const filePath = path.resolve(process.cwd(), "config_editor.html");
|
||||||
|
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(content);
|
||||||
|
req.url.pathname = '/handled_config_editor_bypass';
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(404).end("config_editor.html not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cache-Control headers for static assets.
|
// Cache-Control headers for static assets.
|
||||||
// flummpress router.static() sends no caching headers, which forces Chrome to
|
// flummpress router.static() sends no caching headers, which forces Chrome to
|
||||||
// re-fetch all thumbnails on every grid visit even when they haven't changed.
|
// re-fetch all thumbnails on every grid visit even when they haven't changed.
|
||||||
@@ -617,7 +705,7 @@ process.on('uncaughtException', err => {
|
|||||||
user = [_cachedRow];
|
user = [_cachedRow];
|
||||||
} else {
|
} else {
|
||||||
user = await db`
|
user = await db`
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||||
from "user_sessions"
|
from "user_sessions"
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
@@ -801,29 +889,47 @@ process.on('uncaughtException', err => {
|
|||||||
req.url.pathname = '/private_society_bypass';
|
req.url.pathname = '/private_society_bypass';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate)
|
// For page requests, serve the gate for all paths (custom template or fallback)
|
||||||
if (req.url.pathname !== '/') {
|
if (_customGateTemplate) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(tpl.render(_customGateTemplate, {}, req));
|
||||||
|
} else if (req.url.pathname !== '/') {
|
||||||
|
// Non-home paths: always show the static/cloudflare gate
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
||||||
req.url.pathname = '/private_society_bypass';
|
} else if (nginx502 === null) {
|
||||||
return;
|
// Homepage in cloudflare dynamic mode: serve the CF gate directly
|
||||||
}
|
|
||||||
// Homepage: in cloudflare dynamic mode serve gate directly; otherwise fall through to template
|
|
||||||
if (nginx502 === null) {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
|
||||||
req.url.pathname = '/private_society_bypass';
|
} else {
|
||||||
|
// Homepage in plain nginx mode: fall through to index.html template
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
req.url.pathname = '/private_society_bypass';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// CSRF validation helper — used by route handlers that have already populated req.session
|
// Intercept app.readBody to allow memoized reading of request body.
|
||||||
// NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers
|
// flummpress runs app.use() in parallel via Promise.all before routing/body-reading,
|
||||||
// because flummpress runs ALL middlewares in parallel (Promise.all), so the session
|
// so validateCsrf needs to be able to read req.post without breaking subsequent router handler body parsing.
|
||||||
// middleware hasn't finished by the time these run. Those handlers validate CSRF inline.
|
const _originalReadBody = app.readBody.bind(app);
|
||||||
const validateCsrf = (req, res) => {
|
app.readBody = async (req) => {
|
||||||
|
if (req.post !== undefined) return req.post;
|
||||||
|
return await _originalReadBody(req);
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSRF validation helper — used by route handlers and global middleware
|
||||||
|
const validateCsrf = async (req, res) => {
|
||||||
if (req.session && req.session.csrf_token) {
|
if (req.session && req.session.csrf_token) {
|
||||||
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
|
let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
|
||||||
|
|
||||||
|
// If header/query token is missing and body is not parsed yet on a non-GET method, parse it now
|
||||||
|
if (!token && req.post === undefined && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||||
|
try {
|
||||||
|
req.post = await app.readBody(req);
|
||||||
|
token = req.post?.csrf_token;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
if (!token || token !== req.session.csrf_token) {
|
if (!token || token !== req.session.csrf_token) {
|
||||||
console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`);
|
console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`);
|
||||||
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
|
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
|
||||||
@@ -845,7 +951,7 @@ process.on('uncaughtException', err => {
|
|||||||
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
||||||
// User hall image upload is handled by bypass middleware below
|
// User hall image upload is handled by bypass middleware below
|
||||||
if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return;
|
if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return;
|
||||||
if (!validateCsrf(req, res)) return;
|
if (!(await validateCsrf(req, res))) return;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bypass middleware for direct upload handling
|
// Bypass middleware for direct upload handling
|
||||||
@@ -1166,6 +1272,12 @@ process.on('uncaughtException', err => {
|
|||||||
setCleanupEndDate(endSetting[0].value);
|
setCleanupEndDate(endSetting[0].value);
|
||||||
}
|
}
|
||||||
console.log(`[BOOT] Cleanup End Date: ${getCleanupEndDate()}`);
|
console.log(`[BOOT] Cleanup End Date: ${getCleanupEndDate()}`);
|
||||||
|
|
||||||
|
const engagedSetting = await db`SELECT value FROM site_settings WHERE key = 'cleanup_include_engaged' LIMIT 1`;
|
||||||
|
if (engagedSetting.length > 0) {
|
||||||
|
setCleanupIncludeEngaged(engagedSetting[0].value === 'true');
|
||||||
|
}
|
||||||
|
console.log(`[BOOT] Cleanup Include Engaged: ${getCleanupIncludeEngaged()}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message);
|
console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message);
|
||||||
setEnableCleanup(!!cfg.websrv.enable_cleanup);
|
setEnableCleanup(!!cfg.websrv.enable_cleanup);
|
||||||
@@ -1251,6 +1363,9 @@ process.on('uncaughtException', err => {
|
|||||||
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
|
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure all items in database have a unique slug backfilled
|
||||||
|
ensureAllItemsHaveSlugs();
|
||||||
|
|
||||||
const globals = {
|
const globals = {
|
||||||
lul: cfg.websrv.lul,
|
lul: cfg.websrv.lul,
|
||||||
themes: cfg.websrv.themes,
|
themes: cfg.websrv.themes,
|
||||||
@@ -1292,6 +1407,7 @@ process.on('uncaughtException', err => {
|
|||||||
show_mime_picker: cfg.websrv.show_mime_picker !== false,
|
show_mime_picker: cfg.websrv.show_mime_picker !== false,
|
||||||
private_society: cfg.websrv.private_society || false,
|
private_society: cfg.websrv.private_society || false,
|
||||||
private_society_gate: cfg.websrv.private_society_gate || '',
|
private_society_gate: cfg.websrv.private_society_gate || '',
|
||||||
|
private_society_gate_location: cfg.websrv.private_society_gate_location || 'Frankfurt',
|
||||||
show_content_warning: cfg.websrv.show_content_warning !== false,
|
show_content_warning: cfg.websrv.show_content_warning !== false,
|
||||||
web_url_upload: !!cfg.websrv.web_url_upload,
|
web_url_upload: !!cfg.websrv.web_url_upload,
|
||||||
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
|
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
|
||||||
@@ -1303,10 +1419,16 @@ process.on('uncaughtException', err => {
|
|||||||
default_font: cfg.websrv.default_font || "",
|
default_font: cfg.websrv.default_font || "",
|
||||||
site_description: cfg.websrv.description || "The webs dumpster",
|
site_description: cfg.websrv.description || "The webs dumpster",
|
||||||
enable_nsfl: !!cfg.enable_nsfl,
|
enable_nsfl: !!cfg.enable_nsfl,
|
||||||
|
enable_private_uploads: cfg.enable_private_uploads !== false,
|
||||||
|
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
||||||
|
get enable_item_slugs() { return getEnableItemSlugs(); },
|
||||||
|
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
|
||||||
|
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
|
||||||
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
||||||
scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'],
|
scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'],
|
||||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||||
|
expose_repost_links_to_guests: !!(cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links),
|
||||||
get private_messages() { return getPrivateMessages(); },
|
get private_messages() { return getPrivateMessages(); },
|
||||||
get dm_attachments() { return getDmAttachments(); },
|
get dm_attachments() { return getDmAttachments(); },
|
||||||
get dm_unencrypted() { return getDmUnencrypted(); },
|
get dm_unencrypted() { return getDmUnencrypted(); },
|
||||||
@@ -1315,6 +1437,7 @@ process.on('uncaughtException', err => {
|
|||||||
get enable_cleanup() { return getEnableCleanup(); },
|
get enable_cleanup() { return getEnableCleanup(); },
|
||||||
get cleanup_start_date() { return getCleanupStartDate(); },
|
get cleanup_start_date() { return getCleanupStartDate(); },
|
||||||
get cleanup_end_date() { return getCleanupEndDate(); },
|
get cleanup_end_date() { return getCleanupEndDate(); },
|
||||||
|
get cleanup_include_engaged() { return getCleanupIncludeEngaged(); },
|
||||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||||
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
|
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
@@ -1403,39 +1526,48 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve per-request recaptcha preference (disabled for .onion requests or when passed false)
|
||||||
|
const effectiveReq = req || (data && data.req);
|
||||||
|
const defaultRecaptcha = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
||||||
|
let perRequestRecaptcha = defaultRecaptcha;
|
||||||
|
|
||||||
|
if (effectiveReq && lib.isOnionRequest(effectiveReq)) {
|
||||||
|
perRequestRecaptcha = false;
|
||||||
|
} else if (data && typeof data.recaptcha_enabled === 'boolean') {
|
||||||
|
perRequestRecaptcha = data.recaptcha_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
// Build data: globals first, then caller-supplied data, then per-request i18n last
|
// Build data: globals first, then caller-supplied data, then per-request i18n last
|
||||||
// so t/lang always reflect the user's language, not the site default.
|
// so t/lang always reflect the user's language, not the site default.
|
||||||
// ALSO mutate globals.t and globals.lang: flummpress spreads this.#globals LAST
|
// ALSO mutate globals.t, globals.lang, globals.recaptcha_enabled: flummpress spreads this.#globals LAST
|
||||||
// inside render(), so globals must carry the per-request values too.
|
// inside render(), so globals must carry the per-request values too.
|
||||||
globals.t = perRequestT;
|
globals.t = perRequestT;
|
||||||
globals.lang = perRequestLang;
|
globals.lang = perRequestLang;
|
||||||
|
globals.recaptcha_enabled = perRequestRecaptcha;
|
||||||
|
|
||||||
// Resolve per-request infobox preference
|
// Resolve per-request infobox preference
|
||||||
// Guests always get false — the alternative infobox is a logged-in user preference only
|
const activeReq = req || effectiveReq;
|
||||||
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
|
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
|
||||||
? req.session.use_alternative_infobox
|
? activeReq.session.use_alternative_infobox
|
||||||
: (req && !req.session
|
|
||||||
? false
|
|
||||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||||
? data.user_alternative_infobox
|
? data.user_alternative_infobox
|
||||||
: (cfg.websrv.user_alternative_infobox !== false)));
|
: (cfg.websrv.user_alternative_infobox !== false));
|
||||||
|
|
||||||
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean')
|
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
|
||||||
? req.session.use_alternative_steuerung
|
? activeReq.session.use_alternative_steuerung
|
||||||
: (req && !req.session
|
|
||||||
? false
|
|
||||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||||
? data.user_alternative_steuerung
|
? data.user_alternative_steuerung
|
||||||
: (cfg.websrv.user_alternative_steuerung !== false)));
|
: (cfg.websrv.user_alternative_steuerung !== false));
|
||||||
|
|
||||||
data = Object.assign({}, globals, data || {}, {
|
data = Object.assign({}, globals, data || {}, {
|
||||||
t: perRequestT,
|
t: perRequestT,
|
||||||
lang: perRequestLang,
|
lang: perRequestLang,
|
||||||
|
recaptcha_enabled: perRequestRecaptcha,
|
||||||
user_alternative_infobox: useAltInfobox,
|
user_alternative_infobox: useAltInfobox,
|
||||||
user_alternative_steuerung: useAltSteuerung,
|
user_alternative_steuerung: useAltSteuerung,
|
||||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
|
||||||
? req.session.comment_display_mode
|
? activeReq.session.comment_display_mode
|
||||||
: (data && typeof data.comment_display_mode === 'number'
|
: (data && typeof data.comment_display_mode === 'number'
|
||||||
? data.comment_display_mode
|
? data.comment_display_mode
|
||||||
: (cfg.websrv.default_comment_display_mode || 0))
|
: (cfg.websrv.default_comment_display_mode || 0))
|
||||||
@@ -1447,17 +1579,19 @@ process.on('uncaughtException', err => {
|
|||||||
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
|
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req) {
|
if (activeReq) {
|
||||||
if (req.mode !== undefined) data.mode = req.mode;
|
data.recaptcha_enabled = perRequestRecaptcha;
|
||||||
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
if (activeReq.mode !== undefined) data.mode = activeReq.mode;
|
||||||
if (!data.url) data.url = req.url;
|
data.theme = activeReq.theme || activeReq.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
||||||
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false;
|
if (!data.url) data.url = activeReq.url;
|
||||||
data.user_logged_in_bool = !!req.session;
|
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
|
||||||
data.csrf_token = req.session?.csrf_token || '';
|
data.user_logged_in_bool = !!activeReq.session;
|
||||||
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
data.csrf_token = activeReq.session?.csrf_token || '';
|
||||||
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||||
|
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||||
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
||||||
} else {
|
} else {
|
||||||
|
data.recaptcha_enabled = perRequestRecaptcha;
|
||||||
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
||||||
data.user_strict_bool = false;
|
data.user_strict_bool = false;
|
||||||
data.user_logged_in_bool = false;
|
data.user_logged_in_bool = false;
|
||||||
@@ -1493,6 +1627,11 @@ process.on('uncaughtException', err => {
|
|||||||
setTimeout(cleanupStaleSessions, 30_000);
|
setTimeout(cleanupStaleSessions, 30_000);
|
||||||
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Expiring uploads background purge (every 30s)
|
||||||
|
setTimeout(purgeExpiredUploads, 10_000);
|
||||||
|
setInterval(purgeExpiredUploads, 30_000);
|
||||||
|
|
||||||
|
|
||||||
// ── Inactivity ban — permanently ban accounts that haven't logged in for N days
|
// ── Inactivity ban — permanently ban accounts that haven't logged in for N days
|
||||||
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
|
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
|
||||||
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;
|
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export async function handleKoepfeUpload(req, res) {
|
|||||||
throw new Error('Unsupported format');
|
throw new Error('Unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(cfg.paths.koepfe, { recursive: true });
|
||||||
const newName = crypto.randomBytes(8).toString('hex') + ext;
|
const newName = crypto.randomBytes(8).toString('hex') + ext;
|
||||||
const targetPath = path.join(cfg.paths.koepfe, newName);
|
const targetPath = path.join(cfg.paths.koepfe, newName);
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
|
|||||||
import queue from "./inc/queue.mjs";
|
import queue from "./inc/queue.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
|
||||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
||||||
|
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
|
||||||
|
|
||||||
|
|
||||||
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
|
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
|
||||||
// Adding a new archive type to config.json is sufficient; no code change needed.
|
// Adding a new archive type to config.json is sufficient; no code change needed.
|
||||||
@@ -34,6 +36,8 @@ db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text`.catch(() => {});
|
|||||||
// One-time migration: add width/height columns for image and video dimension storage
|
// One-time migration: add width/height columns for image and video dimension storage
|
||||||
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {});
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {});
|
||||||
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {});
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {});
|
||||||
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`.catch(() => {});
|
||||||
|
|
||||||
|
|
||||||
// One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support
|
// One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support
|
||||||
// (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more)
|
// (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more)
|
||||||
@@ -43,6 +47,10 @@ db`ALTER TABLE items ALTER COLUMN checksum TYPE character varying(255)`.catch(()
|
|||||||
db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
||||||
db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
||||||
|
|
||||||
|
// One-time migration: fix NULL visibility values and ensure NOT NULL default going forward
|
||||||
|
db`UPDATE items SET visibility = 0 WHERE visibility IS NULL`.catch(() => {});
|
||||||
|
db`ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0`.catch(() => {});
|
||||||
|
|
||||||
export const handleUpload = async (req, res, self) => {
|
export const handleUpload = async (req, res, self) => {
|
||||||
// Manual session lookup is required here because this handler is called from a
|
// Manual session lookup is required here because this handler is called from a
|
||||||
// bypass middleware that runs in parallel with the main session middleware.
|
// bypass middleware that runs in parallel with the main session middleware.
|
||||||
@@ -138,6 +146,44 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
|
|
||||||
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
|
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
|
||||||
|
|
||||||
|
// Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference
|
||||||
|
let targetVisibility = 0;
|
||||||
|
if (cfg.enable_private_uploads !== false) {
|
||||||
|
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
|
||||||
|
? cfg.default_upload_visibility
|
||||||
|
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
|
||||||
|
|
||||||
|
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
|
||||||
|
|
||||||
|
if (!allowUserOverride) {
|
||||||
|
targetVisibility = sysDefault;
|
||||||
|
} else {
|
||||||
|
const rawVisHeader = req.headers['x-upload-visibility'];
|
||||||
|
const rawVisBody = parts.visibility;
|
||||||
|
const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
if (visVal === 'private' || visVal === '2') {
|
||||||
|
targetVisibility = 2;
|
||||||
|
} else if (visVal === 'unlisted' || visVal === '1') {
|
||||||
|
targetVisibility = 1;
|
||||||
|
} else if (visVal === 'public' || visVal === '0') {
|
||||||
|
targetVisibility = 0;
|
||||||
|
} else {
|
||||||
|
targetVisibility = (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
|
||||||
|
? req.session.default_upload_visibility
|
||||||
|
: sysDefault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawExpiry = req.headers['x-upload-expiry'] || parts.expiry || parts.expires_at;
|
||||||
|
const nowStamp = ~~(Date.now() / 1000);
|
||||||
|
const targetExpiresAt = calculateExpiresAt(rawExpiry, nowStamp);
|
||||||
|
|
||||||
|
|
||||||
|
// Always generate a unique item slug for the database
|
||||||
|
const itemSlug = lib.generateSlug(11);
|
||||||
|
|
||||||
const maxLen = cfg.main.comment_max_length;
|
const maxLen = cfg.main.comment_max_length;
|
||||||
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
|
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
|
||||||
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
|
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
|
||||||
@@ -450,14 +496,17 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
username: req.session.user,
|
username: req.session.user,
|
||||||
userchannel: 'web',
|
userchannel: 'web',
|
||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: nowStamp,
|
||||||
active: !manualApproval,
|
active: !manualApproval,
|
||||||
is_oc: is_oc,
|
is_oc: is_oc,
|
||||||
original_filename: originalFilename,
|
original_filename: originalFilename,
|
||||||
title: title,
|
title: title,
|
||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
height: itemHeight
|
height: itemHeight,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')}
|
visibility: targetVisibility,
|
||||||
|
slug: itemSlug,
|
||||||
|
expires_at: targetExpiresAt
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const itemid = await queue.getItemID(filename);
|
const itemid = await queue.getItemID(filename);
|
||||||
@@ -678,7 +727,8 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
||||||
|
|
||||||
|
|
||||||
// Discord Webhook
|
// Discord Webhook (only for public uploads)
|
||||||
|
if (targetVisibility === 0) {
|
||||||
try {
|
try {
|
||||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||||
if (discordClient && discordClient.webhook_url) {
|
if (discordClient && discordClient.webhook_url) {
|
||||||
@@ -702,9 +752,10 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
// Broadcast new_item event for live grid updates (only if auto-approved and public)
|
||||||
if (!manualApproval) {
|
if (!manualApproval && targetVisibility === 0) {
|
||||||
try {
|
try {
|
||||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||||
id: itemid,
|
id: itemid,
|
||||||
@@ -720,7 +771,8 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push to Matrix Channel
|
// Push to Matrix Channel (only if auto-approved and public)
|
||||||
|
if (!manualApproval && targetVisibility === 0) {
|
||||||
try {
|
try {
|
||||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||||
@@ -735,6 +787,7 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[UPLOAD] Matrix notification error:', err);
|
console.error('[UPLOAD] Matrix notification error:', err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Staff Notifications
|
// Staff Notifications
|
||||||
if (manualApproval) {
|
if (manualApproval) {
|
||||||
@@ -768,13 +821,16 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
: 'Upload successful! Your upload is now live.';
|
: 'Upload successful! Your upload is now live.';
|
||||||
|
|
||||||
const imagesPath = cfg.websrv.paths?.images || '/b';
|
const imagesPath = cfg.websrv.paths?.images || '/b';
|
||||||
|
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
|
||||||
return sendJson(res, {
|
return sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
msg: successMsg,
|
msg: successMsg,
|
||||||
itemid: itemid,
|
itemid: itemid,
|
||||||
|
slug: itemSlug,
|
||||||
|
visibility: targetVisibility,
|
||||||
manual_approval: manualApproval,
|
manual_approval: manualApproval,
|
||||||
redirect: !manualApproval ? `/${itemid}` : null,
|
redirect: !manualApproval ? itemRoute : null,
|
||||||
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`,
|
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
|
||||||
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
|
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
|
||||||
// Fields for immediate client-side grid injection (avoids SSE race condition)
|
// Fields for immediate client-side grid injection (avoids SSE race condition)
|
||||||
dest: filename,
|
dest: filename,
|
||||||
|
|||||||
35
views/_gate_template.html
Normal file
35
views/_gate_template.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html theme="@if(typeof theme !== 'undefined'){{ theme }}@else{{ default_theme || 'amoled' }}@endif">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{ domain }}</title>
|
||||||
|
<link rel="icon" type="image/gif" href="/s/img/favicon.gif" />
|
||||||
|
<link rel="stylesheet" id="injected-gate-styles" href="/s/css/f0ckm.css?v={{ ts }}">
|
||||||
|
</head>
|
||||||
|
<body class="{{ default_layout === 'legacy' ? 'layout-legacy' : 'layout-modern' }}">
|
||||||
|
|
||||||
|
<div class="wrapper" style="text-align: center;">
|
||||||
|
<h2>Example Gate</h2>
|
||||||
|
<button type="button" onclick="openLoginGate();">Login</button>
|
||||||
|
|
||||||
|
<div id="gate-container" style="display:none;">
|
||||||
|
@include(snippets/navbar)
|
||||||
|
@include(snippets/footer)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openLoginGate() {
|
||||||
|
document.getElementById('gate-container').style.display = 'block';
|
||||||
|
const tryOpen = () => {
|
||||||
|
const m = document.getElementById('login-modal');
|
||||||
|
if (m) { m.style.display = 'flex'; }
|
||||||
|
else { setTimeout(tryOpen, 50); }
|
||||||
|
};
|
||||||
|
tryOpen();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -117,18 +117,20 @@
|
|||||||
status.style.color = 'var(--accent)';
|
status.style.color = 'var(--accent)';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = window.f0ckSession?.csrf_token || '{{ csrf_token }}';
|
||||||
const res = await fetch('/admin/settings', {
|
const res = await fetch('/admin/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': csrfToken
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
manual_approval: approvalToggle.checked ? 'on' : 'off',
|
manual_approval: approvalToggle.checked ? 'on' : 'off',
|
||||||
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
|
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
|
||||||
min_tags: minTagsInput.value,
|
min_tags: minTagsInput.value,
|
||||||
trusted_uploads: trustedUploadsInput.value,
|
trusted_uploads: trustedUploadsInput.value,
|
||||||
csrf_token: '{{ csrf_token }}'
|
csrf_token: csrfToken
|
||||||
}).toString()
|
}).toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-item" style="margin-bottom: 20px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; color: #ff6b6b; font-weight: bold;">
|
||||||
|
<input type="checkbox" id="cleanup_include_engaged" name="cleanup_include_engaged" value="true" @if(cleanup_include_engaged) checked @endif style="width: 18px; height: 18px; cursor: pointer;">
|
||||||
|
<span>Include items with engagement</span>
|
||||||
|
</label>
|
||||||
|
<span style="color: #888; display: block; margin-top: 5px; margin-left: 28px;">If checked, cleanup will purge ALL posts in the selected date range, even if they have comments or favorites.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; align-items: center;">
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
<button type="submit" class="btn-primary" style="width: auto; padding: 12px 40px; font-weight: bold;">Save Configuration</button>
|
<button type="submit" class="btn-primary" style="width: auto; padding: 12px 40px; font-weight: bold;">Save Configuration</button>
|
||||||
<span id="cleanup-status" style="margin-left: 15px; font-weight: bold; display: none;"></span>
|
<span id="cleanup-status" style="margin-left: 15px; font-weight: bold; display: none;"></span>
|
||||||
@@ -101,8 +109,13 @@
|
|||||||
async function runCleanup() {
|
async function runCleanup() {
|
||||||
const btn = document.getElementById('run-cleanup-btn');
|
const btn = document.getElementById('run-cleanup-btn');
|
||||||
const status = document.getElementById('run-status');
|
const status = document.getElementById('run-status');
|
||||||
|
const includeEngaged = document.getElementById('cleanup_include_engaged')?.checked || false;
|
||||||
|
|
||||||
if (!confirm('Are you absolutely sure? This will PERMANENTLY delete files from disk. This action cannot be undone.')) return;
|
const warningMsg = includeEngaged
|
||||||
|
? 'SECURITY CLEANUP WARNING: You have selected to INCLUDE posts WITH engagement (comments/favorites). Are you ABSOLUTELY SURE you want to permanently delete ALL posts in this date range?'
|
||||||
|
: 'Are you absolutely sure? This will PERMANENTLY delete files from disk. This action cannot be undone.';
|
||||||
|
|
||||||
|
if (!confirm(warningMsg)) return;
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'CLEANING UP...';
|
btn.textContent = 'CLEANING UP...';
|
||||||
@@ -110,12 +123,17 @@
|
|||||||
status.style.color = 'var(--accent)';
|
status.style.color = 'var(--accent)';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append('include_engaged', includeEngaged ? 'true' : 'false');
|
||||||
|
|
||||||
const res = await fetch('/admin/cleanup/run', {
|
const res = await fetch('/admin/cleanup/run', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || '{{ csrf_token }}'
|
'X-CSRF-Token': window.f0ckSession?.csrf_token || '{{ csrf_token }}'
|
||||||
}
|
},
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanup_response = await res.json();
|
const cleanup_response = await res.json();
|
||||||
|
|||||||
@@ -319,7 +319,7 @@
|
|||||||
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||||
var res = await fetch('/api/v2/admin/users/activate', {
|
var res = await fetch('/api/v2/admin/users/activate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id })
|
body: JSON.stringify({ user_id: id })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
|
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
|
||||||
var res = await fetch('/api/v2/admin/ban', {
|
var res = await fetch('/api/v2/admin/ban', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
|
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||||
var res = await fetch('/api/v2/admin/unban', {
|
var res = await fetch('/api/v2/admin/unban', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id })
|
body: JSON.stringify({ user_id: id })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -379,7 +379,7 @@
|
|||||||
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
|
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
|
||||||
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
|
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
|
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
|
||||||
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
|
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
ModAction.confirm('Set Display Name', hint, async (newName) => {
|
ModAction.confirm('Set Display Name', hint, async (newName) => {
|
||||||
var res = await fetch('/api/v2/admin/users/set-display-name', {
|
var res = await fetch('/api/v2/admin/users/set-display-name', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, display_name: newName || '' })
|
body: JSON.stringify({ user_id: id, display_name: newName || '' })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
ModAction.confirm('Unlock Layout', 'Unlock comment layout for <strong>' + escHTML(userName) + '</strong>? They will be able to change it again.', async () => {
|
ModAction.confirm('Unlock Layout', 'Unlock comment layout for <strong>' + escHTML(userName) + '</strong>? They will be able to change it again.', async () => {
|
||||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, lock: false })
|
body: JSON.stringify({ user_id: id, lock: false })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
var mode = document.getElementById('force-mode-select').value;
|
var mode = document.getElementById('force-mode-select').value;
|
||||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
|
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
try {
|
try {
|
||||||
var res = await fetch('/api/v2/admin/users/create', {
|
var res = await fetch('/api/v2/admin/users/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ username, email: email || null, password, role })
|
body: JSON.stringify({ username, email: email || null, password, role })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -662,7 +662,7 @@
|
|||||||
var role = selectEl.value;
|
var role = selectEl.value;
|
||||||
var res = await fetch('/api/v2/admin/users/set-role', {
|
var res = await fetch('/api/v2/admin/users/set-role', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||||
body: JSON.stringify({ user_id: id, role })
|
body: JSON.stringify({ user_id: id, role })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@include(snippets/page-title)
|
@include(snippets/page-title)
|
||||||
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
||||||
@each(items as item)
|
@each(items as item)
|
||||||
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||||
<div class="thumb-indicators">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="item-main-content">
|
<div class="item-main-content">
|
||||||
|
|
||||||
<div class="_204863">
|
<div class="_204863">
|
||||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
|
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||||
<div class="gapLeft"></div>
|
<div class="gapLeft"></div>
|
||||||
</div>
|
</div>
|
||||||
@if(enable_item_title)
|
@if(enable_item_title)
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<div class="user-infobox-username-container">
|
<div class="user-infobox-username-container">
|
||||||
<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
|
<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="user-infobox-timestamp"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
<span class="user-infobox-timestamp"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-infobox-body">
|
<div class="user-infobox-body">
|
||||||
<div class="user-infobox-description">
|
<div class="user-infobox-description">
|
||||||
@@ -111,9 +111,9 @@
|
|||||||
<div class="user-infobox-actions">
|
<div class="user-infobox-actions">
|
||||||
@if(session)
|
@if(session)
|
||||||
@if(user_has_favorited)
|
@if(user_has_favorited)
|
||||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@else
|
@else
|
||||||
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
|
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||||
@@ -125,13 +125,15 @@
|
|||||||
|
|
||||||
<span class="badge badge-dark">
|
<span class="badge badge-dark">
|
||||||
|
|
||||||
<a href="/{{ item.id }}" class="id-link" @if(user_alternative_infobox)style="display:none"@endif>{{ item.id }}</a>
|
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}" @if(user_alternative_infobox)style="display:none"@endif>{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a>
|
||||||
@if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
|
@if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
|
||||||
@if(!user_alternative_infobox)
|
@if(!user_alternative_infobox)
|
||||||
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(halls_enabled && item.primaryHall) — @endif @endif
|
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(item.expires_at) — <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>@endif@if(halls_enabled && item.primaryHall) — @endif @endif
|
||||||
|
|
||||||
|
|
||||||
@if(halls_enabled && item.primaryHall)
|
@if(halls_enabled && item.primaryHall)
|
||||||
<span class="badge hall-badge-wrap">
|
<span class="badge hall-badge-wrap">
|
||||||
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
||||||
@@ -143,9 +145,9 @@
|
|||||||
@if(session)
|
@if(session)
|
||||||
@if(!user_alternative_infobox)
|
@if(!user_alternative_infobox)
|
||||||
@if(user_has_favorited)
|
@if(user_has_favorited)
|
||||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@else
|
@else
|
||||||
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
|
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||||
@@ -197,7 +199,11 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
|
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
|
||||||
@each(item.favorites as fav)
|
@each(item.favorites as fav)
|
||||||
|
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
||||||
|
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
|
||||||
|
@else
|
||||||
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
|
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
|
||||||
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
</span>
|
</span>
|
||||||
@if(enable_xd_score && item.xd_score > 0)
|
@if(enable_xd_score && item.xd_score > 0)
|
||||||
@@ -251,6 +257,58 @@
|
|||||||
<td>{!! item.title !!}</td>
|
<td>{!! item.title !!}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
|
<tr class="info-id-row">
|
||||||
|
<th>ID</th>
|
||||||
|
<td><code>{{ item.id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="info-visibility-row">
|
||||||
|
<th>Visibility</th>
|
||||||
|
<td>
|
||||||
|
<span id="info-visibility-label" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||||
|
@if(item.visibility === 2)
|
||||||
|
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
|
||||||
|
@elseif(item.visibility === 1)
|
||||||
|
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
|
||||||
|
@else
|
||||||
|
<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@if(can_manage_item)
|
||||||
|
<button id="info-visibility-edit-btn" class="btn-secondary btn-sm" style="margin-left: 8px; padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}"><i class="fa-solid fa-pen"></i> Edit</button>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if(enable_expiring_uploads !== false)
|
||||||
|
<tr class="info-expiry-row">
|
||||||
|
<th>Expires</th>
|
||||||
|
<td>
|
||||||
|
<div class="info-expiry-wrap" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<span id="info-expiry-label" style="display: inline-flex; align-items: center; gap: 6px; color: #ffaa00;" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||||
|
@if(item.expires_at)
|
||||||
|
<i class="fa-solid fa-clock"></i> {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
|
||||||
|
@else
|
||||||
|
<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Never
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@if(can_manage_item)
|
||||||
|
<div class="info-expiry-edit-controls" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||||
|
<select id="info-expiry-select" class="info-expiry-select" style="padding: 2px 6px; font-size: 0.85em; background: rgba(0,0,0,0.4); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px;" data-item-id="{{ item.id }}">
|
||||||
|
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent (Never)</option>
|
||||||
|
<option value="30minutes">30 Minutes</option>
|
||||||
|
<option value="1hour">1 Hour</option>
|
||||||
|
<option value="24hours">24 Hours</option>
|
||||||
|
<option value="1week">1 Week</option>
|
||||||
|
<option value="1month">1 Month</option>
|
||||||
|
</select>
|
||||||
|
<button id="info-set-expiry-btn" class="btn-secondary btn-sm" style="padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}"><i class="fa-solid fa-check"></i> Save</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||||
<td>{{ item.size }}</td>
|
<td>{{ item.size }}</td>
|
||||||
@@ -268,18 +326,18 @@
|
|||||||
@if(item.checksum)
|
@if(item.checksum)
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
<td><pre class="info-hash-codeblock"><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></pre></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
|
@if(item.show_repost_row)
|
||||||
<tr class="info-repost-row">
|
<tr class="info-repost-row">
|
||||||
<th>Repost</th>
|
<th>Repost</th>
|
||||||
<td>
|
<td>
|
||||||
@each(item.reposts as rp)
|
@each(item.reposts as rp)
|
||||||
@if(rp.match_type === 'phash')
|
@if(rp.match_type === 'phash')
|
||||||
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
|
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.slug || rp.id }}</a>
|
||||||
@else
|
@else
|
||||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.slug || rp.id }}</a>
|
||||||
@endif
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
</td>
|
</td>
|
||||||
@@ -296,13 +354,56 @@
|
|||||||
@if(item.src.short)
|
@if(item.src.short)
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||||
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
|
@if(can_manage_item)
|
||||||
|
<button class="btn-secondary" id="info-rethumb-btn" data-item-id="{{ item.id }}"><i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail</button>
|
||||||
|
@endif
|
||||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-content" style="max-width: 420px;">
|
||||||
|
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
||||||
|
<form id="visibility-form" onsubmit="return false;">
|
||||||
|
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||||
|
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
|
||||||
|
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
|
||||||
|
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="item-main-content">
|
<div class="item-main-content">
|
||||||
|
|
||||||
<div class="_204863">
|
<div class="_204863">
|
||||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
|
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||||
<div class="gapLeft"></div>
|
<div class="gapLeft"></div>
|
||||||
</div>
|
</div>
|
||||||
@if(enable_item_title)
|
@if(enable_item_title)
|
||||||
@@ -119,20 +119,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="blahlol">
|
<div class="blahlol">
|
||||||
<span class="badge badge-dark">
|
<span class="badge badge-dark">
|
||||||
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||||
</span>@if(halls_enabled && item.primaryHall) — @endif
|
</span>@if(halls_enabled && item.primaryHall) — @endif
|
||||||
@if(halls_enabled && item.primaryHall)
|
@if(halls_enabled && item.primaryHall)
|
||||||
<span class="badge hall-badge-wrap">
|
<span class="badge hall-badge-wrap">
|
||||||
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
||||||
</span> —
|
</span> —
|
||||||
@endif
|
@endif
|
||||||
<span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
<span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||||
|
@if(item.expires_at)
|
||||||
|
— <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
<div class="gapRight">
|
<div class="gapRight">
|
||||||
@if(session)
|
@if(session)
|
||||||
@if(user_has_favorited)
|
@if(user_has_favorited)
|
||||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@else
|
@else
|
||||||
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
|
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@endif
|
@endif
|
||||||
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||||
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
|
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
|
||||||
@@ -157,7 +162,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
|
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
|
||||||
@each(item.favorites as fav)
|
@each(item.favorites as fav)
|
||||||
|
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
||||||
|
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
|
||||||
|
@else
|
||||||
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
|
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
|
||||||
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -193,6 +202,58 @@
|
|||||||
<td>{!! item.title !!}</td>
|
<td>{!! item.title !!}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
|
<tr class="info-id-row">
|
||||||
|
<th>ID</th>
|
||||||
|
<td><code>{{ item.id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="info-visibility-row">
|
||||||
|
<th>Visibility</th>
|
||||||
|
<td>
|
||||||
|
<span id="info-visibility-label" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||||
|
@if(item.visibility === 2)
|
||||||
|
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
|
||||||
|
@elseif(item.visibility === 1)
|
||||||
|
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
|
||||||
|
@else
|
||||||
|
<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@if(can_manage_item)
|
||||||
|
<button id="info-visibility-edit-btn" class="btn-secondary btn-sm" style="margin-left: 8px; padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}"><i class="fa-solid fa-pen"></i> Edit</button>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if(enable_expiring_uploads !== false)
|
||||||
|
<tr class="info-expiry-row">
|
||||||
|
<th>Expires</th>
|
||||||
|
<td>
|
||||||
|
<div class="info-expiry-wrap" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<span id="info-expiry-label" style="display: inline-flex; align-items: center; gap: 6px; color: #ffaa00;" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||||
|
@if(item.expires_at)
|
||||||
|
<i class="fa-solid fa-clock"></i> {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
|
||||||
|
@else
|
||||||
|
<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Never
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@if(can_manage_item)
|
||||||
|
<div class="info-expiry-edit-controls" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||||
|
<select id="info-expiry-select" class="info-expiry-select" style="padding: 2px 6px; font-size: 0.85em; background: rgba(0,0,0,0.4); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px;" data-item-id="{{ item.id }}">
|
||||||
|
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent (Never)</option>
|
||||||
|
<option value="30minutes">30 Minutes</option>
|
||||||
|
<option value="1hour">1 Hour</option>
|
||||||
|
<option value="24hours">24 Hours</option>
|
||||||
|
<option value="1week">1 Week</option>
|
||||||
|
<option value="1month">1 Month</option>
|
||||||
|
</select>
|
||||||
|
<button id="info-set-expiry-btn" class="btn-secondary btn-sm" style="padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}"><i class="fa-solid fa-check"></i> Save</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||||
<td>{{ item.size }}</td>
|
<td>{{ item.size }}</td>
|
||||||
@@ -210,18 +271,18 @@
|
|||||||
@if(item.checksum)
|
@if(item.checksum)
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
<td><pre class="info-hash-codeblock"><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></pre></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
|
@if(item.show_repost_row)
|
||||||
<tr class="info-repost-row">
|
<tr class="info-repost-row">
|
||||||
<th>Repost</th>
|
<th>Repost</th>
|
||||||
<td>
|
<td>
|
||||||
@each(item.reposts as rp)
|
@each(item.reposts as rp)
|
||||||
@if(rp.match_type === 'phash')
|
@if(rp.match_type === 'phash')
|
||||||
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
|
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.slug || rp.id }}</a>
|
||||||
@else
|
@else
|
||||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.slug || rp.id }}</a>
|
||||||
@endif
|
@endif
|
||||||
@endeach
|
@endeach
|
||||||
</td>
|
</td>
|
||||||
@@ -238,13 +299,56 @@
|
|||||||
@if(item.src.short)
|
@if(item.src.short)
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||||
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
|
@if(can_manage_item)
|
||||||
|
<button class="btn-secondary" id="info-rethumb-btn" data-item-id="{{ item.id }}"><i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail</button>
|
||||||
|
@endif
|
||||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-content" style="max-width: 420px;">
|
||||||
|
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
||||||
|
<form id="visibility-form" onsubmit="return false;">
|
||||||
|
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||||
|
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
|
||||||
|
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
|
||||||
|
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
|
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
|
||||||
|
|
||||||
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
|
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
|
||||||
@media (max-width: 950px) {
|
@media (max-width: 1250px) {
|
||||||
/* Target all possible shell triggers */
|
/* Target all possible shell triggers */
|
||||||
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
|
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@each(favotop as favo)
|
@each(favotop as favo)
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/{{ favo.item_id }}">#{{ favo.item_id }}</a></td>
|
<td><a href="/{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}">#{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}</a></td>
|
||||||
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
|
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endeach
|
@endeach
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@each(xdtop as item)
|
@each(xdtop as item)
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/{{ item.id }}">#{{ item.id }}</a></td>
|
<td><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a></td>
|
||||||
<td>
|
<td>
|
||||||
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
|
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
|
||||||
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
|
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
|
||||||
|
|||||||
@@ -320,6 +320,33 @@
|
|||||||
</label>
|
</label>
|
||||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item" style="margin-top: 15px;">
|
||||||
|
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
|
||||||
|
<span>{{ t('settings.favorites_private') }}</span>
|
||||||
|
</label>
|
||||||
|
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
|
||||||
|
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
|
||||||
|
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
|
||||||
|
<span>{{ t('settings.hide_fav_badge') }}</span>
|
||||||
|
</label>
|
||||||
|
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if(allow_user_upload_visibility !== false)
|
||||||
|
<div class="setting-item" style="margin-top: 15px;">
|
||||||
|
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
|
||||||
|
<span>{{ t('settings.default_upload_visibility') }}</span>
|
||||||
|
</label>
|
||||||
|
<select id="default_upload_visibility_select" class="form-control" style="width: 200px; padding: 5px; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--nav-border-color); border-radius: 4px;">
|
||||||
|
<option value="0" @if(session.default_upload_visibility === 0 || (!session.default_upload_visibility && session.default_upload_visibility !== 0)) selected @endif>{{ t('visibility.public') }}</option>
|
||||||
|
<option value="1" @if(session.default_upload_visibility === 1) selected @endif>{{ t('visibility.unlisted') }}</option>
|
||||||
|
<option value="2" @if(session.default_upload_visibility === 2) selected @endif>{{ t('visibility.private') }}</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted" style="display: block; margin-top: 5px;">{{ t('settings.default_upload_visibility_hint') }}</small>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="setting-item" style="margin-top: 15px;">
|
<div class="setting-item" style="margin-top: 15px;">
|
||||||
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||||
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
|
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
|
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.id }}</title>@else<title>{{ domain }}</title>@endif
|
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</title>@else<title>{{ domain }}</title>@endif
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#0096ff">
|
<meta name="theme-color" content="#0096ff">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
|
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
|
||||||
|
<script>(function(){var h=document.documentElement;if(localStorage.getItem('hideItemRatings')==='true')h.classList.add('hide-item-ratings-active');if(localStorage.getItem('blurNsfw')==='true')h.classList.add('blur-nsfw-active');if(localStorage.getItem('blurNsfl')==='true')h.classList.add('blur-nsfl-active');if(localStorage.getItem('blurSfw')==='true')h.classList.add('blur-sfw-active');if(localStorage.getItem('blurUntagged')==='true')h.classList.add('blur-untagged-active');if(localStorage.getItem('blurDetail')!=='false')h.classList.add('blur-detail-active');if(localStorage.getItem('imageExpandOnClick')!=='false')h.classList.add('image-expand-active');})();</script>
|
||||||
<style>
|
<style>
|
||||||
html { background-color: #000; color: #fff; }
|
html { background-color: #000; color: #fff; }
|
||||||
@if(session && session.font)
|
@if(session && session.font)
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
}
|
}
|
||||||
@endif
|
@endif
|
||||||
:root {
|
:root {
|
||||||
|
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
|
||||||
@if(session && session.font)
|
@if(session && session.font)
|
||||||
--font: 'CustomUserFont', monospace !important;
|
--font: 'CustomUserFont', monospace !important;
|
||||||
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
|
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
|
||||||
@@ -70,18 +72,18 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||||
@if(typeof item !== 'undefined')
|
@if(typeof item !== 'undefined')
|
||||||
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" />
|
<link rel="canonical" href="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||||
<meta property="og:site_name" content="{{ domain }}" />
|
<meta property="og:site_name" content="{{ domain }}" />
|
||||||
<meta property="og:title" content="{{ item.id }}" />
|
<meta property="og:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||||
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
|
<meta property="og:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||||
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||||
<meta name="description" content="{{ site_description }}" />
|
<meta name="description" content="{{ site_description }}" />
|
||||||
<meta property="og:description" content="{{ site_description }}" />
|
<meta property="og:description" content="{{ site_description }}" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="twitter:card" content="summary" />
|
<meta property="twitter:card" content="summary" />
|
||||||
<meta property="twitter:title" content="{{ item.id }}" />
|
<meta property="twitter:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||||
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||||
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" />
|
<meta property="twitter:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||||
@else
|
@else
|
||||||
<meta property="og:site_name" content="{{ domain }}" />
|
<meta property="og:site_name" content="{{ domain }}" />
|
||||||
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />
|
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@each(items as item)
|
@each(items as item)
|
||||||
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||||
<div class="thumb-indicators">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
@if(custom_brand_image)
|
@if(custom_brand_image)
|
||||||
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;">
|
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;">
|
||||||
@else
|
@else
|
||||||
<span class="f0ck">{{ custom_navbar_brand_text || domain.split('.')[0] }}</span>
|
<span class="f0ck">{{ custom_navbar_brand_text || domain }}</span>
|
||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
@each(notifications as n)
|
@each(notifications as n)
|
||||||
@if(n.type === 'approve')
|
@if(n.type === 'approve')
|
||||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||||
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_id) }}</div>
|
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
|
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
|
||||||
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_id) }}</div>
|
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -33,46 +33,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@elseif(n.type === 'deny')
|
@elseif(n.type === 'deny')
|
||||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||||
<div class="notif-msg">
|
<div class="notif-msg">
|
||||||
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_id) }}</strong>
|
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_slug || n.item_id) }}</strong>
|
||||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@elseif(n.type === 'item_deleted')
|
@elseif(n.type === 'item_deleted')
|
||||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
|
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
|
||||||
<div class="notif-msg">
|
<div class="notif-msg">
|
||||||
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_id) }}</strong>
|
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_slug || n.item_id) }}</strong>
|
||||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@elseif(n.type === 'upload_comment')
|
@elseif(n.type === 'upload_comment')
|
||||||
<a href="/{{ n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
|
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
|
||||||
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}</div>
|
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@elseif(n.type === 'upload_success')
|
@elseif(n.type === 'upload_success')
|
||||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@elseif(n.type === 'upload_error')
|
@elseif(n.type === 'upload_error')
|
||||||
<a href="{{ n.item_id ? '/' + n.item_id : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
|
<a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
|
||||||
@if(n.item_id)
|
@if(n.item_id)
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<a href="/{{ n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||||
@if(n.item_id)
|
@if(n.item_id)
|
||||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
|
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
|
||||||
@@ -125,9 +125,9 @@
|
|||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
<div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div>
|
<div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div>
|
||||||
<div class="notif-msg">
|
<div class="notif-msg">
|
||||||
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }}
|
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_slug || n.item_id) }}
|
||||||
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }}
|
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_slug || n.item_id) }}
|
||||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_id) }}
|
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_slug || n.item_id) }}
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@each(items as item)
|
@each(items as item)
|
||||||
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
|
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
|
||||||
<a href="/{{ item.id }}" class="sub-link">
|
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="sub-link">
|
||||||
<div class="thumb-indicators">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<img src="{{ item.thumb }}" loading="lazy" />
|
<img src="{{ item.thumb }}" loading="lazy" />
|
||||||
<div class="sub-info">
|
<div class="sub-info">
|
||||||
<span class="sub-id">#{{ item.id }}</span>
|
<span class="sub-id">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</span>
|
||||||
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
|
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -79,6 +79,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(enable_private_uploads !== false && allow_user_upload_visibility !== false)
|
||||||
|
<div class="form-section global-visibility-section">
|
||||||
|
<label>{{ t('upload.visibility') || 'Visibility' }}</label>
|
||||||
|
<div class="visibility-options rating-options">
|
||||||
|
<label class="rating-option">
|
||||||
|
<input type="radio" name="visibility" value="0" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 0) ? 'checked' : '' }}>
|
||||||
|
<span class="rating-label sfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-globe"></i> {{ t('common.public') || 'Public' }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="rating-option">
|
||||||
|
<input type="radio" name="visibility" value="1" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 1) ? 'checked' : '' }}>
|
||||||
|
<span class="rating-label nsfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-link"></i> {{ t('common.unlisted') || 'Unlisted' }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="rating-option">
|
||||||
|
<input type="radio" name="visibility" value="2" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 2) ? 'checked' : '' }}>
|
||||||
|
<span class="rating-label nsfl" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-lock"></i> {{ t('common.private') || 'Private' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(enable_expiring_uploads !== false)
|
||||||
|
<div class="form-section global-expiry-section">
|
||||||
|
<label><i class="fa-solid fa-clock"></i> Expiration / Self-Destruct</label>
|
||||||
|
<select name="expiry" class="expiry-select" style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 6px; cursor: pointer; font-size: 0.95em;">
|
||||||
|
<option value="permanent" selected>Permanent (Never expires)</option>
|
||||||
|
<option value="30minutes">30 Minutes</option>
|
||||||
|
<option value="1hour">1 Hour</option>
|
||||||
|
<option value="24hours">24 Hours</option>
|
||||||
|
<option value="1week">1 Week</option>
|
||||||
|
<option value="1month">1 Month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="form-section global-tag-section">
|
<div class="form-section global-tag-section">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<div class="profile_head_username">
|
<div class="profile_head_username">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
|
||||||
<div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container">
|
<div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container">
|
||||||
<span id="nav-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)⚡ @elseif(user.is_moderator)🛡 @endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
|
<span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)⚡ @elseif(user.is_moderator)🛡 @endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
|
||||||
@if(session && session.id === user.user_id)
|
@if(session && session.id === user.user_id)
|
||||||
<button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color">
|
<button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color">
|
||||||
<i class="fa-solid fa-pen"></i>
|
<i class="fa-solid fa-pen"></i>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
@if(count.f0cks)
|
@if(count.f0cks)
|
||||||
<div class="posts no-infinite-scroll">
|
<div class="posts no-infinite-scroll">
|
||||||
@each(f0cks.items as item)
|
@each(f0cks.items as item)
|
||||||
<a href="{{ f0cks.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
<a href="{{ f0cks.link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||||
<div class="thumb-indicators">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
@@ -142,13 +142,21 @@
|
|||||||
</div>
|
</div>
|
||||||
@if(!user.is_ghost)
|
@if(!user.is_ghost)
|
||||||
<div class="favs">
|
<div class="favs">
|
||||||
|
@if(favs && favs.is_private)
|
||||||
|
<div class="favs-header">
|
||||||
|
{{ t('profile.favs_label') }}
|
||||||
|
</div>
|
||||||
|
<div class="private-favs-msg" style="text-align: center;">
|
||||||
|
{{ t('profile.private_favorites') }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
<div class="favs-header">
|
<div class="favs-header">
|
||||||
{{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a>
|
{{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a>
|
||||||
</div>
|
</div>
|
||||||
@if(count.favs)
|
@if(count.favs)
|
||||||
<div class="posts no-infinite-scroll">
|
<div class="posts no-infinite-scroll">
|
||||||
@each(favs.items as item)
|
@each(favs.items as item)
|
||||||
<a href="{{ favs.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
<a href="{{ favs.link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||||
<div class="thumb-indicators">
|
<div class="thumb-indicators">
|
||||||
@if(item.is_pinned)
|
@if(item.is_pinned)
|
||||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||||
@@ -164,6 +172,7 @@
|
|||||||
@else
|
@else
|
||||||
{{ t('profile.no_favs') }}
|
{{ t('profile.no_favs') }}
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@@ -509,18 +518,27 @@
|
|||||||
nameContainer.style.display = 'flex';
|
nameContainer.style.display = 'flex';
|
||||||
nameEditContainer.style.display = 'none';
|
nameEditContainer.style.display = 'none';
|
||||||
|
|
||||||
const nameSpan = document.getElementById('nav-display-name');
|
const updateNameNode = (el, text) => {
|
||||||
if (nameSpan) {
|
if (!el) return;
|
||||||
nameSpan.style.color = color;
|
for (let i = el.childNodes.length - 1; i >= 0; i--) {
|
||||||
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
|
let node = el.childNodes[i];
|
||||||
let node = nameSpan.childNodes[i];
|
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
||||||
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
|
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
||||||
const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)'));
|
node.nodeValue = (match ? match[1] : '') + text;
|
||||||
node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}');
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const targetName = displayName ? displayName : '{{ user.user }}';
|
||||||
|
const profileSpan = document.getElementById('profile-display-name');
|
||||||
|
if (profileSpan) {
|
||||||
|
profileSpan.style.color = color;
|
||||||
|
updateNameNode(profileSpan, targetName);
|
||||||
|
}
|
||||||
|
const navSpan = document.getElementById('nav-display-name');
|
||||||
|
if (navSpan) {
|
||||||
|
navSpan.style.color = color;
|
||||||
|
updateNameNode(navSpan, targetName);
|
||||||
}
|
}
|
||||||
const bracket = document.getElementById('username-bracket');
|
const bracket = document.getElementById('username-bracket');
|
||||||
if (bracket) {
|
if (bracket) {
|
||||||
|
|||||||
Reference in New Issue
Block a user