Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64c614a448 | |||
| 1eef246bc1 | |||
| 64508b555a | |||
| 9377f05454 | |||
| 162a6bc0fc | |||
| 2fdbb1276a | |||
| dac84a6328 | |||
| 794e21c66f | |||
| 11a4535ca9 | |||
| 3359e9f0d8 | |||
| 5c25182f6a | |||
| c644f7fa2a | |||
| e793b50348 | |||
| 53417063e4 | |||
| d6e273a065 | |||
| 0b2ece9447 | |||
| 63ea313742 | |||
| 619a6f9557 | |||
| 39646aa8c5 | |||
| 3e4f8c80b2 | |||
| d700ed1aa5 | |||
| 8b5da35341 | |||
| 3d1579eed9 | |||
| ef44808210 | |||
| 5ebc3adff9 | |||
| fcf55b0156 | |||
| febf0f9ae5 | |||
| 6fb5cc5c35 | |||
| 972371e7c8 | |||
| 7dbd7fe270 | |||
| 57d41913d1 | |||
| d872e7bae3 | |||
| 4a2bd0bd9a | |||
| 82298681af | |||
| 22db479bc2 | |||
| 9ec07e8e6c | |||
| 402a043b57 | |||
| 9f07dae200 | |||
| 23cbc689fb | |||
| b710f606ec | |||
| c92306867f | |||
| 97604bda70 | |||
| 3f7958f869 | |||
| ebd7855f15 | |||
| fb3cbc76ec | |||
| 2865a2d4e7 | |||
| f074d82bf6 | |||
| 07a874ec99 | |||
| e894795fe0 | |||
| 2e53e7e173 | |||
| 8cdd4fa69a | |||
| 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 |
11
build.bash
11
build.bash
@@ -16,7 +16,7 @@ fi
|
||||
# Parse arguments
|
||||
TARGET="master"
|
||||
CACHE_FLAG=""
|
||||
BRANCH_TARGET=""
|
||||
CUSTOM_BRANCH=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
@@ -27,8 +27,8 @@ for arg in "$@"; do
|
||||
CACHE_FLAG="--no-cache"
|
||||
;;
|
||||
*)
|
||||
if [ -z "$BRANCH_TARGET" ]; then
|
||||
BRANCH_TARGET=$arg
|
||||
if [ -z "$CUSTOM_BRANCH" ]; then
|
||||
CUSTOM_BRANCH=$arg
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -43,7 +43,10 @@ fi
|
||||
# Set Image name and branch defaults
|
||||
IMAGE_NAME="f0ckm"
|
||||
|
||||
if [ "$TARGET" == "master" ]; then
|
||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
||||
BRANCH_TARGET="$CUSTOM_BRANCH"
|
||||
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
|
||||
elif [ "$TARGET" == "master" ]; then
|
||||
echo "--- PRODUCTION DEPLOYMENT (master) ---"
|
||||
BRANCH_TARGET="master"
|
||||
elif [ "$TARGET" == "stg" ]; then
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
client_max_body_size 10000M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
|
||||
@@ -29,11 +29,30 @@
|
||||
],
|
||||
"enable_pdf": 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,
|
||||
"allowedMimes": [
|
||||
"audio",
|
||||
"image",
|
||||
"video"
|
||||
"video",
|
||||
"application/x-shockwave-flash",
|
||||
"application/vnd.adobe.flash.movie",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/x-rar",
|
||||
"application/rar",
|
||||
"application/x-rar-archive",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-bzip2",
|
||||
"application/x-xz"
|
||||
],
|
||||
"nsfp": [
|
||||
2,
|
||||
@@ -123,7 +142,10 @@
|
||||
"enable_profile_description": true,
|
||||
"user_alternative_infobox": false,
|
||||
"user_banner_enabled": true,
|
||||
"comment_banner_enabled": true,
|
||||
"comment_banner_max_height": 300,
|
||||
"user_alternative_steuerung": false,
|
||||
"expose_repost_links_to_guests": false,
|
||||
"enable_swf": false,
|
||||
"swf_thumb": "/s/img/swf.png",
|
||||
"enable_archive": true,
|
||||
@@ -219,6 +241,9 @@
|
||||
"application/x-zip-compressed": "zip",
|
||||
"application/x-rar-compressed": "rar",
|
||||
"application/vnd.rar": "rar",
|
||||
"application/x-rar": "rar",
|
||||
"application/rar": "rar",
|
||||
"application/x-rar-archive": "rar",
|
||||
"application/x-7z-compressed": "7z",
|
||||
"application/x-tar": "tar",
|
||||
"application/gzip": "gz",
|
||||
|
||||
@@ -31,6 +31,8 @@ services:
|
||||
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
|
||||
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
|
||||
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
|
||||
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
|
||||
environment:
|
||||
@@ -39,6 +41,12 @@ services:
|
||||
- VIRTUAL_PORT=1337
|
||||
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
|
||||
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
|
||||
- DB_HOST=f0ckm-db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${POSTGRES_USER:-f0ckm}
|
||||
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
|
||||
- DB_NAME=${POSTGRES_DB:-f0ckm}
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "1337:1337"
|
||||
restart: unless-stopped
|
||||
@@ -102,21 +110,17 @@ services:
|
||||
f0ckm-nginx:
|
||||
image: nginxproxy/nginx-proxy:latest
|
||||
container_name: f0ckm-nginx
|
||||
network_mode: "host"
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- vhost:/etc/nginx/vhost.d:rw
|
||||
- html:/usr/share/nginx/html:rw
|
||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: unless-stopped
|
||||
|
||||
acme-companion:
|
||||
|
||||
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;
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
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"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -492,9 +492,9 @@
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"build": "node scripts/build-css.mjs",
|
||||
"copy-vendor": "node scripts/copy-vendor.mjs",
|
||||
"seed": "node scripts/seed.mjs",
|
||||
"create-admin": "node scripts/create-admin.mjs"
|
||||
"create-admin": "node scripts/create-admin.mjs",
|
||||
"import": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/import.mjs"
|
||||
},
|
||||
"author": "Kibi Kelburton",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1043,7 +1043,7 @@ html[theme='95'] .comment,
|
||||
html[theme='95'] .login-form,
|
||||
html[theme='95'] .user-infobox-block {
|
||||
border: 2px inset #dfdfdf !important;
|
||||
background: #c0c0c0 !important;
|
||||
background-color: #c0c0c0 !important;
|
||||
color: #000 !important;
|
||||
padding: 10px !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -1872,6 +1872,11 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.layout-legacy .login-placeholder {
|
||||
border-left: 1px solid var(--nav-border-color) !important;
|
||||
border-right: 1px solid var(--nav-border-color) !important;
|
||||
}
|
||||
|
||||
body.layout-legacy.sidebar-right-hidden .item-layout-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1969,6 +1974,7 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
font-size: 1em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
contain: layout style;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.sidebar-activity .comment-content-inner {
|
||||
@@ -3100,14 +3106,47 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 10px;
|
||||
background: var(--comment-bg);
|
||||
background-color: var(--comment-bg);
|
||||
/* Very light seethrough */
|
||||
border: 1px solid var(--nav-border-color);
|
||||
border-radius: 0;
|
||||
/* No rounded corners */
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
padding-bottom: 25px;
|
||||
/* Room for absolute permalink */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comment > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comment::before {
|
||||
content: "";
|
||||
display: var(--comment-banner-display, block);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: var(--comment-banner-max-height, 300px);
|
||||
background-image: var(--author-banner, none);
|
||||
background-position: var(--author-banner-position, center top);
|
||||
background-size: var(--author-banner-size, 100% auto);
|
||||
background-repeat: var(--author-banner-repeat, no-repeat);
|
||||
opacity: var(--author-banner-opacity, 0.08);
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
-webkit-mask-image: var(--comment-banner-mask, linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%));
|
||||
mask-image: var(--comment-banner-mask, linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%));
|
||||
}
|
||||
|
||||
.comment.tall-comment::before {
|
||||
-webkit-mask-image: var(--comment-banner-mask, linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 50%));
|
||||
mask-image: var(--comment-banner-mask, linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 50%));
|
||||
}
|
||||
|
||||
.comment.deleted {
|
||||
@@ -3385,7 +3424,7 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
box-shadow: 0 20px 50px #000000;
|
||||
/* Fully opaque black shadow */
|
||||
padding: 15px;
|
||||
max-width: 600px;
|
||||
max-width: min(600px, calc(100vw - 30px));
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
@@ -7795,7 +7834,7 @@ button#togglebg {
|
||||
.fader-in {
|
||||
/*opacity: 0.4 !important;*/
|
||||
/* default */
|
||||
opacity: 0.2 !important;
|
||||
opacity: 0.08 !important;
|
||||
/* darker */
|
||||
}
|
||||
|
||||
@@ -8414,11 +8453,6 @@ input#s_avatar {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
.nav-user-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Avatar-style button variant */
|
||||
.nav-avatar-btn {
|
||||
background: none;
|
||||
@@ -8433,18 +8467,13 @@ input#s_avatar {
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.nav-avatar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nav-avatar-btn.is-active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.nav-avatar-btn.is-active .nav-avatar-caret {
|
||||
@@ -8461,6 +8490,12 @@ input#s_avatar {
|
||||
height: 22px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-avatar-img:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nav-avatar-name {
|
||||
@@ -8513,7 +8548,7 @@ input#s_avatar {
|
||||
|
||||
.nav-user-menu a {
|
||||
display: block;
|
||||
padding: 10px 15px;
|
||||
padding: 5px 10px;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
@@ -8536,6 +8571,13 @@ input#s_avatar {
|
||||
}
|
||||
}
|
||||
|
||||
#nav-user-toggle {
|
||||
min-width: 100px;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
/* nav-links base: horizontal flex row on desktop */
|
||||
.nav-links {
|
||||
display: flex;
|
||||
@@ -9655,7 +9697,7 @@ body.layout-legacy .comment-content img.emoji {
|
||||
|
||||
.comment-highlighted {
|
||||
box-shadow: inset 3px 0px 0px var(--accent) !important;
|
||||
background: var(--bg) !important;
|
||||
background-color: var(--bg) !important;
|
||||
}
|
||||
|
||||
.anchor-target {
|
||||
@@ -14432,6 +14474,8 @@ body.layout-modern .xd-score-wrapper {
|
||||
padding-left: 5px;
|
||||
background: var(--bg);
|
||||
padding-right: 5px;
|
||||
border-left: 1px solid var(--nav-border-color);
|
||||
border-right: 1px solid var(--nav-border-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -16115,72 +16159,414 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
POST & FILE INFO MODAL STYLES
|
||||
POST & FILE INFO MODAL (RETHOUGHT DESIGN)
|
||||
============================================= */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
|
||||
/* Modal Container & Animation */
|
||||
.infobox-modal-content {
|
||||
max-width: 580px !important;
|
||||
width: 92% !important;
|
||||
padding: 0 !important;
|
||||
background: rgba(22, 23, 27, 0.94) !important;
|
||||
backdrop-filter: blur(16px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.7), 0 0 40px rgba(var(--accent-rgb, 153, 255, 0), 0.08) !important;
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
text-align: left !important;
|
||||
animation: infobox-pop-in 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px 8px 0;
|
||||
font-weight: bold;
|
||||
@keyframes infobox-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.infobox-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.infobox-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.infobox-header-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--white, #fff);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.infobox-sub-heading {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #999);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.infobox-sub-heading code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent, #9f0);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.infobox-close-icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.infobox-close-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.9em;
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
text-align: left;
|
||||
padding: 8px 0;
|
||||
color: #ccc;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.9em;
|
||||
/* Modal Body */
|
||||
.infobox-modal-body {
|
||||
padding: 18px 20px;
|
||||
max-height: 72vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Modal Body */
|
||||
.infobox-modal-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.infobox-modal-body::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.infobox-modal-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.infobox-modal-body::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.infobox-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.infobox-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent, #9f0);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.infobox-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.infobox-card {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.infobox-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.infobox-card.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.infobox-card-label {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.infobox-card-label i {
|
||||
font-size: 0.85em;
|
||||
color: var(--accent, #9f0);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.infobox-card-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.infobox-card-value {
|
||||
font-size: 0.92rem;
|
||||
color: #eee;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.info-table td a {
|
||||
.infobox-card-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* Title Input */
|
||||
.info-title-edit-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-title-input {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.info-title-input:focus {
|
||||
border-color: var(--accent, #9f0);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #9f0) 25%, transparent);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.info-title-save-btn {
|
||||
background: var(--accent, #9f0);
|
||||
color: #111;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: transform 0.12s ease, opacity 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-title-save-btn:hover {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.info-title-status {
|
||||
font-size: 0.78rem;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Visibility Badge & Action Buttons */
|
||||
.visibility-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.infobox-action-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.infobox-action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Expiration Controls */
|
||||
.infobox-expiry-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.expiry-status-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-expiry-edit-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.infobox-select {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.infobox-select:focus {
|
||||
border-color: var(--accent, #9f0);
|
||||
}
|
||||
|
||||
/* MIME Pill & Link Pill */
|
||||
.mime-pill {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.82rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.infobox-link-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent, #9f0);
|
||||
background: color-mix(in srgb, var(--accent, #9f0) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #9f0) 30%, transparent);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.infobox-link-pill:hover {
|
||||
background: color-mix(in srgb, var(--accent, #9f0) 22%, transparent);
|
||||
border-color: var(--accent, #9f0);
|
||||
color: var(--accent, #9f0);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#info-modal .modal-content .info-table td a {
|
||||
.infobox-source-link {
|
||||
color: var(--accent, #9f0);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.info-table td a:hover {
|
||||
.infobox-source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-table td code {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
/* Hash Codeblock & Copy Button */
|
||||
.copy-hash-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: #bbb;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
color: #eee;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.copy-hash-btn:hover {
|
||||
background: var(--accent, #9f0);
|
||||
border-color: var(--accent, #9f0);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: 2px 0 0 0;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color, #eee);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
color: #ddd;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
@@ -16190,66 +16576,97 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.info-table tr:last-child th,
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
/* Repost Badges */
|
||||
.infobox-reposts-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ─── Info Modal: Inline Title Editor ─────────────────────────────────────── */
|
||||
.repost-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.info-title-edit-wrap {
|
||||
.repost-pill.phash {
|
||||
background: rgba(255, 170, 0, 0.12);
|
||||
border: 1px solid rgba(255, 170, 0, 0.3);
|
||||
color: #ffb732;
|
||||
}
|
||||
|
||||
.repost-pill.exact {
|
||||
background: rgba(0, 200, 81, 0.12);
|
||||
border: 1px solid rgba(0, 200, 81, 0.3);
|
||||
color: #00e676;
|
||||
}
|
||||
|
||||
.repost-pill:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.infobox-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-infobox-action {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #eee;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.info-title-input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: var(--white, #eee);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.88em;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
.btn-infobox-action:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.info-title-input:focus {
|
||||
border-color: var(--accent, #9f0);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.info-title-save-btn {
|
||||
flex-shrink: 0;
|
||||
.btn-infobox-close {
|
||||
background: var(--accent, #9f0);
|
||||
border: none;
|
||||
border: 1px solid var(--accent, #9f0);
|
||||
color: #111;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: opacity 0.15s;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.info-title-save-btn:hover {
|
||||
opacity: 0.85;
|
||||
.btn-infobox-close:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.info-title-save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.info-title-status {
|
||||
font-size: 0.8em;
|
||||
margin-top: 3px;
|
||||
display: block;
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 540px) {
|
||||
.infobox-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
@@ -16959,3 +17376,177 @@ a.lazy-thumb.filtered-upload-ghost:hover::after {
|
||||
filter: brightness(0.6);
|
||||
}
|
||||
.edit-mode-active #inline-banner-edit-wrapper { display: block !important; }
|
||||
|
||||
/* Settings Direct-Editable Profile Card */
|
||||
.editable-profile-card {
|
||||
position: relative;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.editable-profile-card:hover {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.card-banner-controls-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-overlay-btn {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.card-overlay-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.card-overlay-btn.btn-danger:hover {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.editable-avatar-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.avatar-hover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.editable-avatar-container:hover .avatar-hover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.avatar-quick-remove {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #ff4757;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.avatar-quick-remove:hover {
|
||||
transform: scale(1.15);
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.inline-edit-username-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.user-infobox-username[contenteditable="true"] {
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.3);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.user-infobox-username[contenteditable="true"]:hover {
|
||||
border-bottom-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.user-infobox-username[contenteditable="true"]:focus {
|
||||
outline: none;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.color-picker-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
font-size: 0.68rem;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 2px rgba(0,0,0,0.8);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
}
|
||||
.color-picker-badge:hover {
|
||||
transform: scale(1.18);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.user-infobox-description[contenteditable="true"] {
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
.user-infobox-description[contenteditable="true"]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.user-infobox-description[contenteditable="true"]:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.user-infobox-description[contenteditable="true"]:focus {
|
||||
outline: none;
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.inline-save-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.inline-save-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.item-main-content {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
@@ -362,6 +362,8 @@
|
||||
}
|
||||
|
||||
.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-tag-section {
|
||||
display: none !important;
|
||||
@@ -419,6 +421,26 @@
|
||||
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 {
|
||||
@@ -662,6 +684,32 @@
|
||||
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 */
|
||||
.tag-input-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
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 inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
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.
|
||||
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
||||
poster: (usernameEl?.dataset?.username || '').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)) : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -180,6 +186,9 @@
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
|
||||
@@ -468,9 +468,15 @@ class CommentSystem {
|
||||
// 2. Check for duplicates (if we just posted it ourselves via optimistic insert).
|
||||
// Even on early return, ensure the button is present if body was truncated.
|
||||
if (document.getElementById('c' + data.id)) {
|
||||
const el = document.getElementById('c' + data.id);
|
||||
if (el && data.banner_file && data.banner_file !== 'null') {
|
||||
el.style.setProperty('--author-banner', `url('/a/${data.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', data.banner_position === 'center' ? 'center top' : (data.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (data.banner_size && data.banner_size !== 'cover') ? data.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
if (data.body && data.body.endsWith('\u2026')) {
|
||||
console.log('[handleLiveComment] duplicate+truncated, ensuring button for', data.id);
|
||||
const el = document.getElementById('c' + data.id);
|
||||
const contentEl = el?.querySelector('.comment-content');
|
||||
if (contentEl && !contentEl.querySelector('.load-full-comment-btn')) {
|
||||
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
|
||||
@@ -507,6 +513,10 @@ class CommentSystem {
|
||||
avatar: data.avatar,
|
||||
avatar_file: data.avatar_file,
|
||||
username_color: data.username_color,
|
||||
banner_file: data.banner_file || null,
|
||||
banner_position: data.banner_position || null,
|
||||
banner_size: data.banner_size || null,
|
||||
banner_repeat: data.banner_repeat || null,
|
||||
video_time: data.video_time ?? null,
|
||||
is_new: true,
|
||||
is_deleted: false,
|
||||
@@ -593,7 +603,20 @@ class CommentSystem {
|
||||
// Update in-memory data so future reconciles use the full content
|
||||
if (this.lastData) {
|
||||
const cached = this.lastData.find(c => String(c.id) === String(commentId));
|
||||
if (cached) cached.content = fullContent;
|
||||
if (cached) {
|
||||
cached.content = fullContent;
|
||||
if (json.comment.banner_file) cached.banner_file = json.comment.banner_file;
|
||||
if (json.comment.banner_position) cached.banner_position = json.comment.banner_position;
|
||||
if (json.comment.banner_size) cached.banner_size = json.comment.banner_size;
|
||||
if (json.comment.banner_repeat) cached.banner_repeat = json.comment.banner_repeat;
|
||||
}
|
||||
}
|
||||
|
||||
if (json.comment && json.comment.banner_file) {
|
||||
el.style.setProperty('--author-banner', `url('/a/${json.comment.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', json.comment.banner_position === 'center' ? 'center top' : (json.comment.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (json.comment.banner_size && json.comment.banner_size !== 'cover') ? json.comment.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
|
||||
contentEl.dataset.raw = fullContent;
|
||||
@@ -1039,10 +1062,6 @@ class CommentSystem {
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.zIndex = (100000 + level).toString();
|
||||
|
||||
// Try to place it to the right of the link, or top/bottom if needed
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
document.body.appendChild(preview);
|
||||
CommentSystem.playEmojiVideos(preview);
|
||||
|
||||
@@ -1067,16 +1086,28 @@ class CommentSystem {
|
||||
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
|
||||
// Boundary checks
|
||||
if (left + previewRect.width > window.innerWidth) {
|
||||
// Default: position preview to the right of the link
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
// If there's NOT enough space on the right, open to the left
|
||||
if (left + previewRect.width > window.innerWidth - 10) {
|
||||
left = rect.left - previewRect.width - 10;
|
||||
}
|
||||
if (left < 0) left = 10;
|
||||
|
||||
if (top + previewRect.height > window.innerHeight) {
|
||||
// Clamp to screen boundaries if left/right are both tight
|
||||
if (left < 10) {
|
||||
left = 10;
|
||||
}
|
||||
if (left + previewRect.width > window.innerWidth - 10) {
|
||||
left = Math.max(10, window.innerWidth - previewRect.width - 10);
|
||||
}
|
||||
|
||||
// Clamp top & bottom boundary
|
||||
if (top + previewRect.height > window.innerHeight - 10) {
|
||||
top = window.innerHeight - previewRect.height - 10;
|
||||
}
|
||||
if (top < 0) top = 10;
|
||||
if (top < 10) top = 10;
|
||||
|
||||
preview.style.left = left + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
@@ -1140,6 +1171,7 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
quoteComment(id, openerEl, body) {
|
||||
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
|
||||
// If no reply input open anywhere, open the local one
|
||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||
let isNew = false;
|
||||
@@ -1501,6 +1533,13 @@ class CommentSystem {
|
||||
el.classList.toggle('pinned', !!incoming.is_pinned);
|
||||
const pinIcon = el.querySelector('.pin-icon');
|
||||
if (pinIcon) pinIcon.style.display = incoming.is_pinned ? 'block' : 'none';
|
||||
|
||||
if (incoming && incoming.banner_file && incoming.banner_file !== 'null') {
|
||||
el.style.setProperty('--author-banner', `url('/a/${incoming.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', incoming.banner_position === 'center' ? 'center top' : (incoming.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (incoming.banner_size && incoming.banner_size !== 'cover') ? incoming.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2169,7 +2208,15 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}"><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
const bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${comment.banner_file}'); --author-banner-position: ${comment.banner_position === 'center' ? 'center top' : (comment.banner_position || 'center top')}; --author-banner-size: ${(comment.banner_size && comment.banner_size !== 'cover') ? comment.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null);
|
||||
const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null);
|
||||
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
@@ -2405,10 +2452,50 @@ class CommentSystem {
|
||||
}
|
||||
}, { 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) => {
|
||||
const isLink = e.target.closest('.comment-context-link');
|
||||
const isPopup = e.target.closest('.comment-preview-popup');
|
||||
const target = e.target;
|
||||
|
||||
// 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) {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
@@ -2823,7 +2910,10 @@ class CommentSystem {
|
||||
if (loadFullBtn) {
|
||||
const contentEl = loadFullBtn.closest('.comment-content');
|
||||
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) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||
// Append "see less" button after full content
|
||||
@@ -2831,6 +2921,7 @@ class CommentSystem {
|
||||
contentEl.insertAdjacentHTML('beforeend',
|
||||
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
||||
);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2841,9 +2932,13 @@ class CommentSystem {
|
||||
if (collapseBtn) {
|
||||
const contentEl = collapseBtn.closest('.comment-content');
|
||||
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) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -3164,6 +3259,9 @@ class CommentSystem {
|
||||
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
|
||||
const el = target.closest('.comment-permalink, .comment-time');
|
||||
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 commentEl = el.closest('[id^="c"]');
|
||||
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
||||
@@ -3442,6 +3540,50 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedBannerFile = (json.comment?.banner_file && json.comment.banner_file !== 'null') ? json.comment.banner_file : ((session.banner_file && session.banner_file !== 'null') ? session.banner_file : null);
|
||||
let resolvedBannerPosition = (json.comment?.banner_position && json.comment.banner_position !== 'null') ? json.comment.banner_position : ((session.banner_position && session.banner_position !== 'null') ? session.banner_position : null);
|
||||
let resolvedBannerSize = (json.comment?.banner_size && json.comment.banner_size !== 'null') ? json.comment.banner_size : ((session.banner_size && session.banner_size !== 'null') ? session.banner_size : null);
|
||||
let resolvedBannerRepeat = (json.comment?.banner_repeat && json.comment.banner_repeat !== 'null') ? json.comment.banner_repeat : ((session.banner_repeat && session.banner_repeat !== 'null') ? session.banner_repeat : null);
|
||||
|
||||
if (!resolvedBannerFile && this.lastData && currentUsername) {
|
||||
const existingByMe = this.lastData.find(c =>
|
||||
c.username && c.username.toLowerCase() === currentUsername.toLowerCase() && c.banner_file && c.banner_file !== 'null'
|
||||
);
|
||||
if (existingByMe) {
|
||||
resolvedBannerFile = existingByMe.banner_file || null;
|
||||
resolvedBannerPosition = existingByMe.banner_position || null;
|
||||
resolvedBannerSize = existingByMe.banner_size || null;
|
||||
resolvedBannerRepeat = existingByMe.banner_repeat || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBannerFile && currentUsername) {
|
||||
const existingCommentEl = document.querySelector(`.comment-author[href="/user/${currentUsername}"], .user-infobox-block[style*="--author-banner"]`);
|
||||
if (existingCommentEl) {
|
||||
const commentDiv = existingCommentEl.closest('.comment') || existingCommentEl.closest('.user-infobox-block');
|
||||
if (commentDiv) {
|
||||
const style = commentDiv.getAttribute('style') || '';
|
||||
const bannerMatch = style.match(/--author-banner:\s*url\(['"]?\/a\/([^'"]+)['"]?\)/);
|
||||
if (bannerMatch) {
|
||||
resolvedBannerFile = bannerMatch[1];
|
||||
const posMatch = style.match(/--author-banner-position:\s*([^;]+)/);
|
||||
if (posMatch) resolvedBannerPosition = posMatch[1].trim();
|
||||
const sizeMatch = style.match(/--author-banner-size:\s*([^;]+)/);
|
||||
if (sizeMatch) resolvedBannerSize = sizeMatch[1].trim();
|
||||
const repeatMatch = style.match(/--author-banner-repeat:\s*([^;]+)/);
|
||||
if (repeatMatch) resolvedBannerRepeat = repeatMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedBannerFile && window.f0ckSession) {
|
||||
window.f0ckSession.banner_file = resolvedBannerFile;
|
||||
if (resolvedBannerPosition) window.f0ckSession.banner_position = resolvedBannerPosition;
|
||||
if (resolvedBannerSize) window.f0ckSession.banner_size = resolvedBannerSize;
|
||||
if (resolvedBannerRepeat) window.f0ckSession.banner_repeat = resolvedBannerRepeat;
|
||||
}
|
||||
|
||||
const newComment = {
|
||||
id: json.comment.id,
|
||||
item_id: this.itemId,
|
||||
@@ -3450,11 +3592,15 @@ class CommentSystem {
|
||||
files: files,
|
||||
created_at: json.comment.created_at || new Date().toISOString(),
|
||||
username: currentUsername,
|
||||
user_id: session.id || null,
|
||||
user_id: (json.comment && json.comment.user_id) || session.id || session.user_id || null,
|
||||
display_name: session.display_name || null,
|
||||
avatar: resolvedAvatar,
|
||||
avatar_file: resolvedAvatarFile,
|
||||
username_color: session.username_color || null,
|
||||
username_color: (json.comment && json.comment.username_color) || session.username_color || null,
|
||||
banner_file: resolvedBannerFile,
|
||||
banner_position: resolvedBannerPosition,
|
||||
banner_size: resolvedBannerSize,
|
||||
banner_repeat: resolvedBannerRepeat,
|
||||
is_deleted: false,
|
||||
is_pinned: false,
|
||||
video_time: json.comment.video_time ?? null,
|
||||
@@ -3481,7 +3627,7 @@ class CommentSystem {
|
||||
if (!this.lastData) this.lastData = [];
|
||||
const existingInLastData = this.lastData.find(c => c.id === newComment.id);
|
||||
if (existingInLastData) {
|
||||
existingInLastData.files = files;
|
||||
Object.assign(existingInLastData, newComment);
|
||||
} else {
|
||||
if (this.sort === 'new') this.lastData.unshift(newComment);
|
||||
else this.lastData.push(newComment);
|
||||
@@ -4707,3 +4853,44 @@ document.addEventListener('f0ck:contentLoaded', () => {
|
||||
|
||||
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
|
||||
// Looking at f0ck.js, it seems to just replace innerHTML.
|
||||
|
||||
(function initCommentHeightObserver() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const height = entry.contentRect ? entry.contentRect.height : entry.target.offsetHeight;
|
||||
if (height > 90) {
|
||||
entry.target.classList.add('tall-comment');
|
||||
} else {
|
||||
entry.target.classList.remove('tall-comment');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const observeAll = (root = document) => {
|
||||
root.querySelectorAll('.comment').forEach(el => observer.observe(el));
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => observeAll());
|
||||
} else {
|
||||
observeAll();
|
||||
}
|
||||
|
||||
if (window.MutationObserver) {
|
||||
const mutObs = new MutationObserver(mutations => {
|
||||
for (const m of mutations) {
|
||||
m.addedNodes.forEach(node => {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.classList && node.classList.contains('comment')) observer.observe(node);
|
||||
if (node.querySelectorAll) node.querySelectorAll('.comment').forEach(el => observer.observe(el));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
mutObs.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -38,11 +38,23 @@ window.cancelAnimFrame = (function () {
|
||||
};
|
||||
|
||||
window.getCurrentItemId = () => {
|
||||
const idEl = document.querySelector('a.id-link[data-item-id], #a_toggle[data-item-id], #a_favo[data-item-id], #comments-container[data-item-id], [data-item-id]');
|
||||
if (idEl && idEl.dataset && idEl.dataset.itemId) {
|
||||
const parsed = parseInt(idEl.dataset.itemId, 10);
|
||||
if (!isNaN(parsed)) return String(parsed);
|
||||
}
|
||||
const path = window.location.pathname;
|
||||
// Explicitly ignore admin/mod/settings paths to avoid false positives from user IDs, etc.
|
||||
if (path.includes('/admin/') || path.includes('/mod/') || path.includes('/settings') || path.includes('/user/')) return null;
|
||||
const match = path.match(/\/(\d+)\/?$/);
|
||||
return match ? match[1] : null;
|
||||
if (match) return match[1];
|
||||
|
||||
const idLink = document.querySelector('a.id-link');
|
||||
if (idLink && idLink.innerText) {
|
||||
const parsed = parseInt(idLink.innerText.trim(), 10);
|
||||
if (!isNaN(parsed)) return String(parsed);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// OS and Browser detection for CSS targeting
|
||||
@@ -834,6 +846,18 @@ window.cancelAnimFrame = (function () {
|
||||
if (userToggle && userMenu) {
|
||||
userToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.target.closest('.nav-avatar-img')) {
|
||||
const username = window.f0ckSession?.user;
|
||||
if (username) {
|
||||
const url = `/user/${username.toLowerCase()}`;
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(url, true);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const opening = !userMenu.classList.contains('show');
|
||||
userMenu.classList.toggle('show');
|
||||
userToggle.classList.toggle('is-active', opening);
|
||||
@@ -965,7 +989,7 @@ window.cancelAnimFrame = (function () {
|
||||
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
|
||||
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
|
||||
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
|
||||
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal'
|
||||
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal'
|
||||
];
|
||||
modalIds.forEach(id => {
|
||||
// Don't close the filter modal during a background mime-filter reload
|
||||
@@ -1429,9 +1453,9 @@ window.cancelAnimFrame = (function () {
|
||||
if (!btn || !btn.href || btn.href.endsWith('#')) return;
|
||||
|
||||
const pathSegments = new URL(btn.href, window.location.origin).pathname.split('/');
|
||||
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
|
||||
if (numericSegments.length === 0) return;
|
||||
const itemId = numericSegments.pop();
|
||||
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
|
||||
if (keySegments.length === 0) return;
|
||||
const itemId = keySegments.pop();
|
||||
|
||||
let ajaxUrl = `/ajax/item/${itemId}`;
|
||||
const params = new URLSearchParams();
|
||||
@@ -2442,8 +2466,8 @@ window.cancelAnimFrame = (function () {
|
||||
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const isItem = !pathname.match(/\/p\//) && (
|
||||
pathname.match(/^\/\d+/) ||
|
||||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1]))
|
||||
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]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||
);
|
||||
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
|
||||
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
||||
@@ -3234,9 +3258,9 @@ window.cancelAnimFrame = (function () {
|
||||
*/
|
||||
const updateNavForMode = async (mode) => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
|
||||
if (numericSegments.length === 0) return;
|
||||
const itemid = numericSegments[numericSegments.length - 1];
|
||||
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
|
||||
if (keySegments.length === 0) return;
|
||||
const itemid = keySegments[keySegments.length - 1];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('mode', mode);
|
||||
@@ -3356,19 +3380,19 @@ window.cancelAnimFrame = (function () {
|
||||
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
||||
// Split path, filter numeric, pop last.
|
||||
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)
|
||||
// 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');
|
||||
|
||||
if (numericSegments.length === 0) {
|
||||
console.warn("loadItemAjax: No ID match found in URL", url);
|
||||
if (keySegments.length === 0) {
|
||||
console.warn("loadItemAjax: No ID or slug match found in URL", url);
|
||||
// fallback for weird/external links
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
const itemid = numericSegments.pop();
|
||||
const itemid = keySegments.pop();
|
||||
|
||||
// Extract context from Target URL first
|
||||
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
|
||||
@@ -3551,18 +3575,19 @@ window.cancelAnimFrame = (function () {
|
||||
}
|
||||
|
||||
const _hash = new URL(url, window.location.origin).hash;
|
||||
let _pushUrl = `/${itemid}`;
|
||||
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
||||
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
|
||||
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
|
||||
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
|
||||
const itemKey = _cachedItem.slug || itemid;
|
||||
let _pushUrl = `/${itemKey}`;
|
||||
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; }
|
||||
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 (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
||||
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||
if (_hash) _pushUrl += _hash;
|
||||
|
||||
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 (!options.keepMedia) {
|
||||
@@ -3587,14 +3612,14 @@ window.cancelAnimFrame = (function () {
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(freshText => {
|
||||
if (!freshText) return;
|
||||
try { JSON.parse(freshText); } catch(_) {}
|
||||
// Re-parse to get html string
|
||||
let freshHtml = freshText;
|
||||
let freshSlug = null;
|
||||
try {
|
||||
const d = JSON.parse(freshText);
|
||||
if (d && typeof d.html === 'string') freshHtml = d.html;
|
||||
if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug;
|
||||
} 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);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -3619,7 +3644,7 @@ window.cancelAnimFrame = (function () {
|
||||
- Total Network: ${(tBody - tStart).toFixed(2)}ms
|
||||
- Content Size: ${(rawText.length / 1024).toFixed(2)} KB`);
|
||||
|
||||
let html, paginationHtml;
|
||||
let html, paginationHtml, responseSlug = null;
|
||||
|
||||
try {
|
||||
// Optimistically try to parse as JSON first
|
||||
@@ -3632,6 +3657,7 @@ window.cancelAnimFrame = (function () {
|
||||
if (data && typeof data.html === 'string') {
|
||||
html = data.html;
|
||||
paginationHtml = data.pagination;
|
||||
responseSlug = data.slug || data.item?.slug || null;
|
||||
} else {
|
||||
html = rawText;
|
||||
}
|
||||
@@ -3642,7 +3668,7 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
// ── Store in item cache (stale-while-revalidate) ───────────────────────
|
||||
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) {
|
||||
// Evict oldest entry
|
||||
itemCacheMap.delete(itemCacheMap.keys().next().value);
|
||||
@@ -3749,26 +3775,25 @@ window.cancelAnimFrame = (function () {
|
||||
// Construct proper History URL (Context Aware)
|
||||
// If we inherited context, we should reflect it in the URL
|
||||
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:
|
||||
if (userHall && userHallOwner) {
|
||||
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
||||
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||
} else if (user) {
|
||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
|
||||
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
|
||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
|
||||
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`;
|
||||
}
|
||||
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
|
||||
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
|
||||
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`;
|
||||
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`;
|
||||
// Append tagger filter so item nav stays in tagger context
|
||||
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||
|
||||
if (mime) {
|
||||
// If it already has itemid at the end, insert mime before it
|
||||
pushUrl = pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
||||
// If it already has itemKey at the end, insert mime before it
|
||||
pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Re-append hash if present
|
||||
if (hash) pushUrl += hash;
|
||||
|
||||
@@ -3788,8 +3813,7 @@ window.cancelAnimFrame = (function () {
|
||||
if (window.initVisualizer) window.initVisualizer();
|
||||
}
|
||||
// Try to extract ID from response if possible or just use itemid
|
||||
document.title = `${window.f0ckDomain} - ${itemid}`;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
document.title = `${window.f0ckDomain} - ${itemKey}`;
|
||||
window.f0ckDebug("AJAX load complete");
|
||||
|
||||
// Notify extensions — also triggers CommentSystem init which renders comments
|
||||
@@ -3821,6 +3845,7 @@ window.cancelAnimFrame = (function () {
|
||||
console.error("AJAX load failed:", err);
|
||||
} finally {
|
||||
isNavigating = false;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3999,15 +4024,16 @@ window.cancelAnimFrame = (function () {
|
||||
fetch(randomUrl)
|
||||
.then(r => r.json())
|
||||
.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.)
|
||||
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) {
|
||||
// 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 {
|
||||
loadItemAjax(`/${data.items.id}`, true);
|
||||
loadItemAjax(`/${targetKey}`, true);
|
||||
}
|
||||
} else {
|
||||
window.location.href = link.href;
|
||||
@@ -4079,7 +4105,7 @@ window.cancelAnimFrame = (function () {
|
||||
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.stopImmediatePropagation();
|
||||
|
||||
@@ -4092,9 +4118,10 @@ window.cancelAnimFrame = (function () {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const isItemLink = !pathname.match(/\/p\//) && (
|
||||
pathname.match(/^\/\d+/) ||
|
||||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
|
||||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) ||
|
||||
(parts.length >= 3 && parts[0] === 'h' && /^\d+$/.test(parts[parts.length - 1]))
|
||||
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
(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])))
|
||||
);
|
||||
if (isItemLink) {
|
||||
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
|
||||
@@ -4130,6 +4157,7 @@ window.cancelAnimFrame = (function () {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (window.invalidateItemCache) window.invalidateItemCache(id);
|
||||
const newOc = data.is_oc;
|
||||
ocBtn.dataset.isOc = newOc;
|
||||
ocBtn.setAttribute('title', newOc ? 'Remove OC status' : 'Mark as OC');
|
||||
@@ -4162,6 +4190,25 @@ window.cancelAnimFrame = (function () {
|
||||
})
|
||||
.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')) {
|
||||
e.preventDefault();
|
||||
const reBtn = target.closest('#a_rethumb');
|
||||
@@ -4347,9 +4394,10 @@ window.cancelAnimFrame = (function () {
|
||||
} else if (target.closest('button#a_toggle')) {
|
||||
e.preventDefault();
|
||||
const toggleBtn = target.closest('button#a_toggle');
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return;
|
||||
const postid = +idLink.innerText;
|
||||
const itemIdStr = toggleBtn.dataset.itemId || window.getCurrentItemId();
|
||||
if (!itemIdStr) return;
|
||||
const postid = parseInt(itemIdStr, 10);
|
||||
if (isNaN(postid)) return;
|
||||
|
||||
// Determine current rating
|
||||
let currentRating = 'sfw';
|
||||
@@ -4451,10 +4499,16 @@ window.cancelAnimFrame = (function () {
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
toggleBtn._activeRequestsCount = Math.max(0, (toggleBtn._activeRequestsCount || 1) - 1);
|
||||
|
||||
|
||||
// Evict cached item HTML immediately on success so navigating back fetches fresh content,
|
||||
// even if the user already navigated to a different item while this request was in flight.
|
||||
if (res && res.success && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
|
||||
// Verify we are still on the same post (prevent dynamic/PJAX page leaks)
|
||||
const activeIdLink = document.querySelector("a.id-link");
|
||||
const currentPostId = activeIdLink ? +activeIdLink.innerText : null;
|
||||
const currentIdStr = window.getCurrentItemId();
|
||||
const currentPostId = currentIdStr ? parseInt(currentIdStr, 10) : null;
|
||||
if (currentPostId !== postid) return;
|
||||
|
||||
if (toggleBtn._lastCycleReqId !== reqId) return; // ignore stale responses
|
||||
@@ -4467,10 +4521,6 @@ window.cancelAnimFrame = (function () {
|
||||
if (window.renderTags) {
|
||||
window.renderTags(res.tags);
|
||||
}
|
||||
// Evict the cached item HTML so navigating back fetches fresh content
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
} else {
|
||||
revert();
|
||||
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
|
||||
@@ -4480,8 +4530,8 @@ window.cancelAnimFrame = (function () {
|
||||
toggleBtn._activeRequestsCount = Math.max(0, (toggleBtn._activeRequestsCount || 1) - 1);
|
||||
|
||||
// Verify we are still on the same post (prevent dynamic/PJAX page leaks)
|
||||
const activeIdLink = document.querySelector("a.id-link");
|
||||
const currentPostId = activeIdLink ? +activeIdLink.innerText : null;
|
||||
const currentIdStr = window.getCurrentItemId();
|
||||
const currentPostId = currentIdStr ? parseInt(currentIdStr, 10) : null;
|
||||
if (currentPostId !== postid) return;
|
||||
|
||||
if (toggleBtn._lastCycleReqId !== reqId) return;
|
||||
@@ -4530,8 +4580,10 @@ window.cancelAnimFrame = (function () {
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
const isItem = !p.match(/\/p\//) && (
|
||||
p.match(/^\/\d+/) ||
|
||||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
|
||||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1]))
|
||||
p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
(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 isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
|
||||
@@ -7111,14 +7163,21 @@ window.cancelAnimFrame = (function () {
|
||||
window.loadPageAjax = loadPageAjax;
|
||||
window.loadItemAjax = loadItemAjax;
|
||||
|
||||
// Invalidate all itemCacheMap entries that contain a given item ID.
|
||||
// Call this after mutating an item (e.g. adding a tag) so the next
|
||||
// Invalidate all itemCacheMap entries that match a given item ID or slug.
|
||||
// Call this after mutating an item (e.g. rating, favorite, tags, OC) so the next
|
||||
// AJAX navigation fetches fresh HTML instead of serving stale cache.
|
||||
window.invalidateItemCache = (itemId) => {
|
||||
if (!itemId) return;
|
||||
const needle = `/${itemId}`;
|
||||
for (const key of itemCacheMap.keys()) {
|
||||
if (key.includes(needle)) {
|
||||
if (itemId === undefined || itemId === null || itemId === '') return;
|
||||
const strId = String(itemId).trim();
|
||||
for (const [key, val] of itemCacheMap.entries()) {
|
||||
const urlPath = key.split('?')[0];
|
||||
const segments = urlPath.split('/').filter(Boolean);
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
if (lastSegment === strId) {
|
||||
itemCacheMap.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (val && typeof val.html === 'string' && val.html.includes(`data-item-id="${strId}"`)) {
|
||||
itemCacheMap.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -7389,6 +7448,9 @@ class NotificationSystem {
|
||||
} else if (data.type === 'comments') {
|
||||
window.f0ckDebug(`[SSE] Comment update received:`, data.data);
|
||||
if (data.data.type === 'comment') {
|
||||
if (window.commentSystem && typeof window.commentSystem.handleLiveComment === 'function') {
|
||||
window.commentSystem.handleLiveComment(data.data);
|
||||
}
|
||||
// New comment posted — update xD badge from server-authoritative score
|
||||
if (typeof data.data.xd_score === 'number') {
|
||||
updateXdBadgeFromScore(data.data.item_id, data.data.xd_score);
|
||||
@@ -8103,7 +8165,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);
|
||||
} else if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(href, true);
|
||||
@@ -8389,7 +8451,8 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
renderHistoryItem(n) {
|
||||
let link = `/${n.item_id}`;
|
||||
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||
let link = `/${itemKey}`;
|
||||
let msg = '';
|
||||
let user = n.from_display_name || n.from_user || 'System';
|
||||
|
||||
@@ -8398,14 +8461,14 @@ class NotificationSystem {
|
||||
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 userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System');
|
||||
const itemLink = `/${n.item_id}`;
|
||||
const itemLink = `/${itemKey}`;
|
||||
return `
|
||||
<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-content">
|
||||
<div class="notif-user"><strong>${userLabel}</strong></div>
|
||||
<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>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
@@ -8425,7 +8488,7 @@ class NotificationSystem {
|
||||
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>`;
|
||||
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') {
|
||||
link = '/mod/approve';
|
||||
user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin';
|
||||
@@ -8441,7 +8504,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>`;
|
||||
} else {
|
||||
// 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';
|
||||
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';
|
||||
@@ -8482,8 +8545,9 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
renderItem(n) {
|
||||
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||
if (n.type === 'approve') {
|
||||
const link = `/${n.item_id}`;
|
||||
const link = `/${itemKey}`;
|
||||
return `
|
||||
<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>
|
||||
@@ -8498,7 +8562,7 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
if (n.type === 'upload_success') {
|
||||
const link = `/${n.item_id}`;
|
||||
const link = `/${itemKey}`;
|
||||
return `
|
||||
<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>
|
||||
@@ -8517,7 +8581,7 @@ class NotificationSystem {
|
||||
const errMsg = n.data?.msg || '';
|
||||
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 link = n.item_id ? `/${n.item_id}` : '#';
|
||||
const link = n.item_id ? `/${itemKey}` : '#';
|
||||
return `
|
||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
|
||||
<div class="notif-content">
|
||||
@@ -8541,7 +8605,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-content">
|
||||
<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>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
@@ -8600,10 +8664,10 @@ class NotificationSystem {
|
||||
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 === '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 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>` : '';
|
||||
return `
|
||||
@@ -8658,9 +8722,13 @@ class NotificationSystem {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(data.item_id);
|
||||
}
|
||||
|
||||
// Check if we are currently viewing this item
|
||||
const idLink = document.querySelector('.id-link');
|
||||
const currentId = idLink ? parseInt(idLink.innerText) : null;
|
||||
const currentIdStr = window.getCurrentItemId();
|
||||
const currentId = currentIdStr ? parseInt(currentIdStr, 10) : null;
|
||||
|
||||
window.f0ckDebug(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
|
||||
|
||||
@@ -8741,9 +8809,13 @@ class NotificationSystem {
|
||||
handleFavoritesUpdate(data) {
|
||||
if (!data || !data.item_id) return;
|
||||
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(data.item_id);
|
||||
}
|
||||
|
||||
// Check if we are currently viewing this item
|
||||
const idLink = document.querySelector('#main .id-link');
|
||||
if (!idLink || parseInt(idLink.innerText) !== parseInt(data.item_id)) return;
|
||||
const currentIdStr = window.getCurrentItemId();
|
||||
if (!currentIdStr || parseInt(currentIdStr, 10) !== parseInt(data.item_id, 10)) return;
|
||||
|
||||
const favsContainer = document.querySelector('#favs');
|
||||
if (!favsContainer) return;
|
||||
@@ -8768,10 +8840,11 @@ class NotificationSystem {
|
||||
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
|
||||
const fragment = document.createDocumentFragment();
|
||||
data.favs.forEach(fav => {
|
||||
if (fav.hide_fav_badge) {
|
||||
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', 'Ghost Fav');
|
||||
a.setAttribute('tooltip', '?');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
@@ -8884,12 +8957,14 @@ class NotificationSystem {
|
||||
|
||||
if (!isOwnUpload) return; // Silently drop as before for other users' uploads
|
||||
|
||||
const itemKey = data.slug || data.id;
|
||||
|
||||
// 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
|
||||
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
|
||||
const i18n = window.f0ckI18n || {};
|
||||
@@ -8904,7 +8979,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 ghost = document.createElement('a');
|
||||
ghost.href = `${linkBase}${data.id}`;
|
||||
ghost.href = `${linkBase}${itemKey}`;
|
||||
ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded';
|
||||
ghost.dataset.file = data.dest;
|
||||
ghost.dataset.mime = data.mime;
|
||||
@@ -8935,19 +9010,21 @@ class NotificationSystem {
|
||||
}
|
||||
}
|
||||
|
||||
const itemKey = data.slug || data.id;
|
||||
|
||||
// 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
|
||||
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
|
||||
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 thumb = document.createElement('a');
|
||||
thumb.href = `${linkBase}${data.id}`;
|
||||
thumb.href = `${linkBase}${itemKey}`;
|
||||
thumb.className = 'thumb lazy-thumb';
|
||||
thumb.dataset.file = data.dest;
|
||||
thumb.dataset.mime = data.mime;
|
||||
@@ -9129,10 +9206,11 @@ class NotificationSystem {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
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);">
|
||||
<a href="/${c.item_id}">${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}">${mediaHtml}</a>
|
||||
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context »</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -9199,6 +9277,18 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var menu = document.getElementById(menuId);
|
||||
if (btn && menu) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.nav-avatar-img')) {
|
||||
const username = window.f0ckSession?.user;
|
||||
if (username) {
|
||||
const url = `/user/${username.toLowerCase()}`;
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(url, true);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.innerWidth <= 700) {
|
||||
e.preventDefault();
|
||||
menu.classList.toggle('show-mobile');
|
||||
@@ -11291,7 +11381,7 @@ document.addEventListener('click', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeBtn = e.target.closest('#info-modal-close');
|
||||
const closeBtn = e.target.closest('#info-modal-close, #info-modal-close-icon');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
const modal = document.getElementById('info-modal');
|
||||
@@ -11302,6 +11392,98 @@ document.addEventListener('click', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const copyHashBtn = e.target.closest('#info-copy-hash-btn');
|
||||
if (copyHashBtn) {
|
||||
e.preventDefault();
|
||||
const hash = copyHashBtn.dataset.hash;
|
||||
if (hash && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(hash).then(() => {
|
||||
const origText = copyHashBtn.innerHTML;
|
||||
copyHashBtn.innerHTML = '<i class="fa-solid fa-check"></i> Copied!';
|
||||
setTimeout(() => { copyHashBtn.innerHTML = origText; }, 2000);
|
||||
}).catch(err => console.error('Copy failed', err));
|
||||
}
|
||||
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
|
||||
const saveBtn = e.target.closest('#info-title-save');
|
||||
if (saveBtn) {
|
||||
@@ -11371,6 +11553,125 @@ document.addEventListener('click', (e) => {
|
||||
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
|
||||
const infoModal = document.getElementById('info-modal');
|
||||
if (infoModal && e.target === infoModal) {
|
||||
|
||||
@@ -137,10 +137,13 @@
|
||||
const statusDiv = document.getElementById('avatar-upload-status');
|
||||
const preview = document.getElementById('avatar-preview');
|
||||
|
||||
const showStatus = (msg, type) => {
|
||||
const showStatus = (msg, type = 'info') => {
|
||||
if (msg && window.flashMessage) {
|
||||
window.flashMessage(msg, 2500, type);
|
||||
}
|
||||
if (statusDiv) {
|
||||
statusDiv.textContent = msg;
|
||||
statusDiv.className = 'avatar-status ' + type;
|
||||
statusDiv.className = 'avatar-status ' + (type || '');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -177,6 +180,7 @@
|
||||
filenameSpan.textContent = file.name;
|
||||
uploadBtn.disabled = false;
|
||||
showStatus('', '');
|
||||
uploadBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1181,6 +1185,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
const layoutToggle = document.getElementById('use_new_layout_toggle');
|
||||
if (layoutToggle) {
|
||||
@@ -1645,8 +1678,11 @@
|
||||
|
||||
// Two-way sync: swatch → text input
|
||||
if (colorPicker && colorHex) {
|
||||
const colorBadge = document.getElementById('color-picker-badge');
|
||||
|
||||
colorPicker.addEventListener('input', () => {
|
||||
colorHex.value = colorPicker.value;
|
||||
if (colorBadge) colorBadge.style.background = colorPicker.value;
|
||||
const liveBox = document.getElementById('live-profile-preview-box');
|
||||
if (liveBox) {
|
||||
liveBox.style.setProperty('--author-accent', colorPicker.value);
|
||||
@@ -1654,6 +1690,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
colorPicker.addEventListener('change', () => {
|
||||
if (colorBadge) colorBadge.style.background = colorPicker.value;
|
||||
if (saveColorBtn) saveColorBtn.click();
|
||||
});
|
||||
|
||||
// Text input → swatch (validate on each keystroke)
|
||||
colorHex.addEventListener('input', () => {
|
||||
const val = colorHex.value.trim();
|
||||
@@ -1823,7 +1864,9 @@
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await applyFontLive(font);
|
||||
showStatus('Website font updated!', 'success');
|
||||
if (window.flashMessage) {
|
||||
window.flashMessage('Website font updated!', 2500, 'success');
|
||||
}
|
||||
} else {
|
||||
alert(data.msg || 'Error saving font preference');
|
||||
}
|
||||
@@ -1839,6 +1882,35 @@
|
||||
const saveDescriptionBtn = document.getElementById('btn-save-description');
|
||||
const descriptionStatus = document.getElementById('description-status');
|
||||
|
||||
const liveDesc = document.getElementById('live-preview-description');
|
||||
const descControls = document.getElementById('desc-edit-controls');
|
||||
|
||||
if (liveDesc && descriptionTextarea) {
|
||||
liveDesc.addEventListener('focus', () => {
|
||||
if (descControls) descControls.style.display = 'flex';
|
||||
});
|
||||
|
||||
liveDesc.addEventListener('input', () => {
|
||||
descriptionTextarea.value = liveDesc.innerText.trim();
|
||||
});
|
||||
|
||||
liveDesc.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
liveDesc.blur();
|
||||
if (saveDescriptionBtn) saveDescriptionBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptionTextarea) {
|
||||
descriptionTextarea.addEventListener('input', () => {
|
||||
if (liveDesc && document.activeElement !== liveDesc) {
|
||||
liveDesc.textContent = descriptionTextarea.value || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (saveDescriptionBtn && descriptionTextarea) {
|
||||
saveDescriptionBtn.addEventListener('click', async () => {
|
||||
const description = descriptionTextarea.value;
|
||||
@@ -1919,10 +1991,13 @@
|
||||
const emailStatus = document.getElementById('email-status');
|
||||
const displayEmail = document.getElementById('display-email');
|
||||
|
||||
const showAccountStatus = (el, msg, type) => {
|
||||
const showAccountStatus = (el, msg, type = 'info') => {
|
||||
if (msg && window.flashMessage) {
|
||||
window.flashMessage(msg, 2500, type);
|
||||
}
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.className = 'avatar-status ' + type;
|
||||
el.className = 'avatar-status ' + (type || '');
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { el.textContent = ''; el.className = 'avatar-status'; }, 5000);
|
||||
}
|
||||
@@ -2008,11 +2083,36 @@
|
||||
const displayNameBtn = document.getElementById('btn-update-display-name');
|
||||
const displayNameInput = document.getElementById('display_name_input');
|
||||
const displayNameStatus = document.getElementById('display-name-status');
|
||||
const liveUsernameEl = document.getElementById('live-preview-username');
|
||||
|
||||
if (liveUsernameEl && displayNameInput && displayNameBtn) {
|
||||
liveUsernameEl.addEventListener('input', () => {
|
||||
displayNameInput.value = liveUsernameEl.textContent.trim();
|
||||
});
|
||||
|
||||
liveUsernameEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
liveUsernameEl.blur();
|
||||
}
|
||||
});
|
||||
|
||||
liveUsernameEl.addEventListener('blur', () => {
|
||||
const val = liveUsernameEl.textContent.trim();
|
||||
if (!val) {
|
||||
liveUsernameEl.textContent = window.f0ckSession?.user || 'Username';
|
||||
displayNameInput.value = window.f0ckSession?.user || '';
|
||||
} else {
|
||||
displayNameInput.value = val;
|
||||
}
|
||||
displayNameBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (displayNameBtn && displayNameInput) {
|
||||
displayNameInput.addEventListener('input', () => {
|
||||
const liveName = document.getElementById('live-preview-username');
|
||||
if (liveName) {
|
||||
liveName.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||
if (liveUsernameEl && document.activeElement !== liveUsernameEl) {
|
||||
liveUsernameEl.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -479,8 +479,9 @@
|
||||
};
|
||||
|
||||
const renderActivityItem = (c) => {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
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;
|
||||
|
||||
@@ -497,7 +498,7 @@
|
||||
}
|
||||
|
||||
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
|
||||
let avatarSrc = '/a/default.png';
|
||||
@@ -542,14 +543,27 @@
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</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>`;
|
||||
}
|
||||
|
||||
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
let bannerFile = c.banner_file;
|
||||
let bannerPos = c.banner_position;
|
||||
let bannerSz = c.banner_size;
|
||||
if (!bannerFile && window.f0ckSession && window.f0ckSession.id && (c.user_id === window.f0ckSession.id || c.username === window.f0ckSession.user)) {
|
||||
bannerFile = window.f0ckSession.banner_file;
|
||||
bannerPos = window.f0ckSession.banner_position;
|
||||
bannerSz = window.f0ckSession.banner_size;
|
||||
}
|
||||
const bannerStyle = (bannerEnabled && bannerFile && bannerFile !== 'null')
|
||||
? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="comment" id="sc${c.id}">
|
||||
<div class="comment" id="sc${c.id}" ${bannerStyle}>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
|
||||
@@ -168,11 +168,48 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
|
||||
if (row) { row.classList.add('uut-job--fading'); setTimeout(() => row.remove(), 300); }
|
||||
setTimeout(() => { _active.delete(jobId); _sync(); }, 350);
|
||||
|
||||
// Toast flash notification
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
const msg = (window.f0ckI18n && window.f0ckI18n.notif_upload_success) || 'Your background upload is finished!';
|
||||
window.flashMessage(msg, 3500, 'success');
|
||||
}
|
||||
|
||||
// Refresh notification bell count and items
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
|
||||
window.NotificationSystemInstance.pollDebounced();
|
||||
}
|
||||
|
||||
const itemId = notif.item_id;
|
||||
if (itemId && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
const grid = document.querySelector('.posts');
|
||||
if (grid && !grid.querySelector(`a[href$="/${itemId}"]`)) {
|
||||
fetch(`/api/v2/item/${itemId}`)
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res && res.success && res.rows) {
|
||||
const item = res.rows;
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: item.id,
|
||||
dest: item.dest,
|
||||
mime: item.mime,
|
||||
username: item.username,
|
||||
display_name: item.display_name || null,
|
||||
tag_id: item.rating_tag_id || item.tag_id || 0,
|
||||
is_oc: !!item.is_oc,
|
||||
slug: item.slug,
|
||||
visibility: item.visibility || 0
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[UrlTracker] Failed to fetch item for live injection:', err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Failure: update the row to show the error, keep until user closes
|
||||
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
|
||||
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
||||
if (row) {
|
||||
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
||||
const stageEl = row.querySelector('.uut-stage');
|
||||
if (stageEl) {
|
||||
if (notif.item_id) {
|
||||
@@ -188,6 +225,13 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
if (barFill) { barFill.style.width = '100%'; barFill.style.background = '#ff5050'; barFill.style.backgroundImage = 'none'; barFill.style.animation = 'none'; }
|
||||
}
|
||||
_sync(); // update badge (count stays 0 for resolved jobs)
|
||||
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('✕ ' + errMsg, 4500, 'error');
|
||||
}
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
|
||||
window.NotificationSystemInstance.pollDebounced();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -908,7 +952,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
lines.forEach(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 = '';
|
||||
@@ -931,7 +975,7 @@ window.initUploadForm = (selector) => {
|
||||
const val = urlInput.value.trim();
|
||||
if (!val || !/^https?:\/\//i.test(val)) return;
|
||||
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 = '';
|
||||
if (urlBadge) urlBadge.style.display = 'none';
|
||||
@@ -1177,7 +1221,7 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
||||
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 {
|
||||
selectedFiles.push(file); // Legacy single file mode uses raw File
|
||||
}
|
||||
@@ -1490,6 +1534,8 @@ window.initUploadForm = (selector) => {
|
||||
infoRow.className = 'file-meta-row-small';
|
||||
|
||||
let ratingSwitch = '';
|
||||
let visibilitySwitch = '';
|
||||
let expirySwitch = '';
|
||||
let tagsUI = '';
|
||||
let ocUI = '';
|
||||
let commentUI = '';
|
||||
@@ -1517,6 +1563,56 @@ window.initUploadForm = (selector) => {
|
||||
</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 minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
|
||||
tagsUI = `
|
||||
@@ -1560,19 +1656,37 @@ window.initUploadForm = (selector) => {
|
||||
</div>
|
||||
${titleUI}
|
||||
${ratingSwitch}
|
||||
${visibilitySwitch}
|
||||
${expirySwitch}
|
||||
${tagsUI}
|
||||
${commentUI}
|
||||
`;
|
||||
|
||||
if (isShitpost) {
|
||||
// Handle Rating
|
||||
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
|
||||
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
|
||||
radio.onchange = () => {
|
||||
item.rating = radio.value;
|
||||
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
|
||||
const commentInput = infoRow.querySelector('.item-comment-input');
|
||||
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
|
||||
@@ -2436,6 +2550,11 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2445,7 +2564,9 @@ window.initUploadForm = (selector) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
rating: globalRatingEl.value,
|
||||
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
|
||||
visibility: visibilityVal,
|
||||
expiry: expiryVal,
|
||||
tags: tags.join(','),
|
||||
comment: comment,
|
||||
is_oc: isOc,
|
||||
@@ -2552,9 +2673,13 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; 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 file = !isUrlItem ? (isShitpost ? item.file : item) : null;
|
||||
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 fileComment = isShitpost ? item.comment : comment;
|
||||
const fileTitle = isShitpost ? (item.title || '') : titleVal;
|
||||
@@ -2571,6 +2696,8 @@ window.initUploadForm = (selector) => {
|
||||
formData.append('file', file);
|
||||
}
|
||||
formData.append('rating', fileRating);
|
||||
formData.append('visibility', fileVisibility);
|
||||
formData.append('expiry', fileExpiry);
|
||||
formData.append('tags', fileTags.join(','));
|
||||
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
|
||||
if (isShitpost) formData.append('is_shitpost', 'true');
|
||||
@@ -2622,6 +2749,8 @@ window.initUploadForm = (selector) => {
|
||||
xhr.send(JSON.stringify({
|
||||
url: item.url,
|
||||
rating: fileRating,
|
||||
visibility: fileVisibility,
|
||||
expiry: fileExpiry,
|
||||
tags: fileTags.join(','),
|
||||
is_oc: (isShitpost ? item.is_oc : isOc),
|
||||
comment: fileComment,
|
||||
@@ -2717,39 +2846,14 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: inject the new item into the grid (deduplication is handled inside handleNewItem)
|
||||
const injectNewItem = () => {
|
||||
if (lastData && !lastData.manual_approval && !lastData.pending &&
|
||||
lastData.itemid && lastData.dest && lastData.mime &&
|
||||
window.NotificationSystemInstance &&
|
||||
typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: lastData.itemid,
|
||||
dest: lastData.dest,
|
||||
mime: lastData.mime,
|
||||
username: lastData.username || window.f0ckSession?.user || '',
|
||||
display_name: lastData.display_name || window.f0ckSession?.display_name || null,
|
||||
tag_id: lastData.tag_id ?? 0,
|
||||
is_oc: !!lastData.is_oc
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Skip redirect if every item was a background URL job
|
||||
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (!allPending) {
|
||||
// Inject now if the grid is already in the DOM (upload modal open on main page)
|
||||
injectNewItem();
|
||||
// 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') {
|
||||
await window.loadPageAjax('/', true, { bypassCache: true });
|
||||
injectNewItem();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
// URL background uploads do not redirect; standard file uploads redirect back to main page ('/')
|
||||
const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (isBgUrlUpload) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
restoreBtn();
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
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 inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
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)) : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -169,6 +175,9 @@
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
|
||||
@@ -410,11 +410,16 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
|
||||
renderComment(c) {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
const timeAgo = this.timeAgo(c.created_at);
|
||||
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>`;
|
||||
const bannerStyle = (c.banner_file && c.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `<div class="comment" id="c${c.id}" ${bannerStyle}><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() {
|
||||
|
||||
529
scripts/import.mjs
Normal file
529
scripts/import.mjs
Normal file
@@ -0,0 +1,529 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import db from "../src/inc/sql.mjs";
|
||||
import lib from "../src/inc/lib.mjs";
|
||||
import cfg from "../src/inc/config.mjs";
|
||||
import queue from "../src/inc/queue.mjs";
|
||||
import f0cklib from "../src/inc/routeinc/f0cklib.mjs";
|
||||
import { getBypassDuplicateCheck } from "../src/inc/settings.mjs";
|
||||
|
||||
// Helper for parsing CLI flags
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
user: null,
|
||||
rating: null,
|
||||
tags: [],
|
||||
dir: cfg.paths.import || path.resolve("f0ckm-data/import"),
|
||||
keep: false,
|
||||
bypass: null, // null means use config default, true = force bypass, false = force check
|
||||
is_oc: false,
|
||||
title: null,
|
||||
help: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else if (arg === '--user' || arg === '-u') {
|
||||
options.user = args[++i];
|
||||
} else if (arg === '--rating' || arg === '-r') {
|
||||
options.rating = args[++i]?.toLowerCase();
|
||||
} else if (arg === '--tags' || arg === '-t') {
|
||||
const rawTags = args[++i];
|
||||
if (rawTags) {
|
||||
options.tags = rawTags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (arg === '--dir' || arg === '-d') {
|
||||
options.dir = path.resolve(args[++i]);
|
||||
} else if (arg === '--keep' || arg === '-k') {
|
||||
options.keep = true;
|
||||
} else if (arg === '--bypass' || arg === '-b') {
|
||||
options.bypass = true;
|
||||
} else if (arg === '--check') {
|
||||
options.bypass = false;
|
||||
} else if (arg === '--oc') {
|
||||
options.is_oc = true;
|
||||
} else if (arg === '--title') {
|
||||
options.title = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
f0ckm Bulk Import Script
|
||||
========================
|
||||
Imports all media files in f0ckm-data/import (or custom directory) into f0ckm.
|
||||
|
||||
Usage:
|
||||
node scripts/import.mjs [options]
|
||||
|
||||
Options:
|
||||
--user, -u <username> User to attribute uploads to (defaults to first admin)
|
||||
--rating, -r <sfw|nsfw|nsfl|untagged> Default rating for imported items
|
||||
--tags, -t <tag1,tag2> Global tags to assign to imported items
|
||||
--dir, -d <path> Directory to scan (default: f0ckm-data/import)
|
||||
--keep, -k Keep source files in import dir after import (default: delete)
|
||||
--bypass, -b Force bypass of duplicate checksum/pHash checks
|
||||
--check Force duplicate checking even if config enables bypass
|
||||
--oc Mark all imported files as Original Content (OC)
|
||||
--title <title> Set title for imported files
|
||||
--help, -h Show this help screen
|
||||
|
||||
Folder & Sidecar JSON Features:
|
||||
- Rating Subfolders: Files in 'import/sfw/', 'import/nsfw/', or 'import/nsfl/' automatically receive that rating.
|
||||
- Sidecar Metadata: Place a file.jpg.json or file.json alongside file.jpg to supply metadata:
|
||||
{ "title": "My Post", "rating": "sfw", "tags": ["funny", "cat"], "comment": "First comment", "is_oc": true }
|
||||
`);
|
||||
}
|
||||
|
||||
// Derive archive MIME types from cfg.mimes
|
||||
const ARCHIVE_MIMES = new Set(
|
||||
Object.entries(cfg.mimes || {})
|
||||
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
|
||||
.map(([mime]) => mime)
|
||||
);
|
||||
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||
|
||||
// Recursively collect all target files in directory (follows symlinked dirs and files)
|
||||
async function collectFiles(dir, visitedDirs = new Set()) {
|
||||
const results = [];
|
||||
|
||||
let canonicalDir;
|
||||
try {
|
||||
canonicalDir = await fs.promises.realpath(dir);
|
||||
} catch (_) {
|
||||
canonicalDir = dir;
|
||||
}
|
||||
|
||||
if (visitedDirs.has(canonicalDir)) {
|
||||
return results; // Avoid infinite loops on circular symlink references
|
||||
}
|
||||
visitedDirs.add(canonicalDir);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(dir);
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: unable to read directory '${dir}':`, e.message);
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const name of entries) {
|
||||
if (name.startsWith('.')) continue; // skip hidden files & .gitkeep
|
||||
const fullPath = path.join(dir, name);
|
||||
|
||||
try {
|
||||
// fs.promises.stat follows symlinks to inspect the underlying file/dir
|
||||
const st = await fs.promises.stat(fullPath);
|
||||
|
||||
if (st.isDirectory()) {
|
||||
const subResults = await collectFiles(fullPath, visitedDirs);
|
||||
results.push(...subResults);
|
||||
} else if (st.isFile()) {
|
||||
if (name.endsWith('.json')) continue; // skip sidecar metadata files
|
||||
results.push(fullPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[IMPORT] Warning: unable to stat '${fullPath}' (broken symlink?):`, err.message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Read optional sidecar JSON metadata
|
||||
async function readSidecarMetadata(filePath) {
|
||||
const candidates = [
|
||||
`${filePath}.json`,
|
||||
path.join(path.dirname(filePath), `${path.parse(filePath).name}.json`)
|
||||
];
|
||||
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(cand)) {
|
||||
const raw = await fs.promises.readFile(cand, 'utf-8');
|
||||
return { meta: JSON.parse(raw), sidecarPath: cand };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: failed to parse sidecar JSON at ${cand}:`, e.message);
|
||||
}
|
||||
}
|
||||
return { meta: {}, sidecarPath: null };
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`--- f0ckm Bulk Import Starting ---`);
|
||||
console.log(`Scan Directory: ${opts.dir}`);
|
||||
|
||||
if (!fs.existsSync(opts.dir)) {
|
||||
console.error(`[IMPORT ERROR] Directory does not exist: ${opts.dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. Resolve Target User
|
||||
let targetUser = null;
|
||||
if (opts.user) {
|
||||
const userRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE LOWER(login) = ${opts.user.toLowerCase()} OR LOWER("user") = ${opts.user.toLowerCase()}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (userRows.length > 0) {
|
||||
targetUser = userRows[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] Specified user '${opts.user}' not found in database.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const adminRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE admin = true
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (adminRows.length > 0) {
|
||||
targetUser = adminRows[0];
|
||||
} else {
|
||||
const anyUser = await db`SELECT id, "user", login, admin, is_moderator FROM "user" ORDER BY id ASC LIMIT 1`;
|
||||
if (anyUser.length > 0) {
|
||||
targetUser = anyUser[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] No users found in database. Please run npm run create-admin first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Attributing uploads to user: ${targetUser.user} (ID: ${targetUser.id})`);
|
||||
|
||||
// Ensure target directories exist
|
||||
await fs.promises.mkdir(cfg.paths.b, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.t, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.ca, { recursive: true });
|
||||
|
||||
// 2. Discover files
|
||||
const fileList = await collectFiles(opts.dir);
|
||||
|
||||
if (fileList.length === 0) {
|
||||
console.log(`[IMPORT] No files found in ${opts.dir}. Place files in '${opts.dir}' and re-run.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${fileList.length} file(s) to process.\n`);
|
||||
|
||||
const allowedCats = Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.map(c => c.toLowerCase()) : null;
|
||||
const allowedMimes = allowedCats
|
||||
? Object.keys(cfg.mimes).filter(m => allowedCats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)))
|
||||
: Object.keys(cfg.mimes);
|
||||
|
||||
let successCount = 0;
|
||||
let skippedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 3. Process each file sequentially
|
||||
for (let index = 0; index < fileList.length; index++) {
|
||||
const filePath = fileList[index];
|
||||
const baseName = path.basename(filePath);
|
||||
const relPath = path.relative(opts.dir, filePath);
|
||||
const prefix = `[${index + 1}/${fileList.length}]`;
|
||||
|
||||
try {
|
||||
// Check sidecar JSON metadata
|
||||
const { meta: sidecarMeta, sidecarPath } = await readSidecarMetadata(filePath);
|
||||
|
||||
// Determine Rating: Sidecar -> CLI option -> Folder name (import/sfw/ etc.) -> Default
|
||||
let effectiveRating = sidecarMeta.rating || opts.rating;
|
||||
if (!effectiveRating) {
|
||||
const parentDirName = path.basename(path.dirname(filePath)).toLowerCase();
|
||||
if (['sfw', 'nsfw', 'nsfl', 'untagged'].includes(parentDirName)) {
|
||||
effectiveRating = parentDirName;
|
||||
}
|
||||
}
|
||||
if (effectiveRating === 'untagged') effectiveRating = null;
|
||||
if (!effectiveRating && !cfg.websrv.shitpost_mode) {
|
||||
effectiveRating = 'sfw'; // Default fallback if rating required
|
||||
}
|
||||
|
||||
// Determine Title & Comments & OC
|
||||
const effectiveTitle = sidecarMeta.title || opts.title || null;
|
||||
const effectiveComment = sidecarMeta.comment || null;
|
||||
const effectiveIsOc = (sidecarMeta.is_oc !== undefined) ? !!sidecarMeta.is_oc : opts.is_oc;
|
||||
const effectiveVisibility = (typeof sidecarMeta.visibility === 'number') ? sidecarMeta.visibility : 0;
|
||||
|
||||
// Collect tags
|
||||
let mergedTags = [...opts.tags];
|
||||
if (Array.isArray(sidecarMeta.tags)) {
|
||||
mergedTags.push(...sidecarMeta.tags);
|
||||
} else if (typeof sidecarMeta.tags === 'string') {
|
||||
mergedTags.push(...sidecarMeta.tags.split(',').map(t => t.trim()));
|
||||
}
|
||||
mergedTags = mergedTags.filter(t => t && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
|
||||
// Verify file stats & MIME
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size === 0) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} (0 bytes)`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect actual MIME via file command (with -L to follow symlinks)
|
||||
let actualMime = (await queue.spawn('file', ['-L', '--mime-type', '-b', filePath])).stdout.trim();
|
||||
|
||||
if ((actualMime === 'application/octet-stream' || !actualMime) && baseName.toLowerCase().endsWith('.swf')) {
|
||||
actualMime = 'application/x-shockwave-flash';
|
||||
}
|
||||
|
||||
if (!allowedMimes.includes(actualMime)) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Unsupported MIME type: '${actualMime}'`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase A: SHA-256, PHash, Dimensions, Audio-MP4 probe
|
||||
const needsPHash = !isArchiveMime(actualMime);
|
||||
const needsDims = !isArchiveMime(actualMime);
|
||||
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
|
||||
|
||||
const [checksum, phash, dimResult, audioMp4Result] = await Promise.all([
|
||||
queue.spawn('sha256sum', [filePath]).then(r => r.stdout.trim().split(' ')[0]),
|
||||
needsPHash ? queue.generatePHash(filePath).catch(e => { console.error(`[IMPORT] PHash error:`, e.message); return null; }) : Promise.resolve(null),
|
||||
needsDims ? (async () => {
|
||||
try {
|
||||
if (actualMime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { quiet: true, ignoreExitCode: true });
|
||||
const match = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath], { quiet: true, ignoreExitCode: true });
|
||||
const dimParts = probeOut.trim().split(',');
|
||||
if (dimParts.length >= 2) {
|
||||
const w = parseInt(dimParts[0], 10);
|
||||
const h = parseInt(dimParts[1], 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null),
|
||||
needsAudioMP4 ? (async () => {
|
||||
const ext = baseName.split('.').pop().toLowerCase();
|
||||
if (['m4a', 'aac'].includes(ext)) return 'audio/mp4';
|
||||
try {
|
||||
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath]);
|
||||
if (!probe.stdout.trim()) return 'audio/mp4';
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (audioMp4Result) {
|
||||
actualMime = audioMp4Result;
|
||||
}
|
||||
|
||||
// Phase B: Duplicate check
|
||||
const bypassDupes = (opts.bypass !== null) ? opts.bypass : (getBypassDuplicateCheck() || cfg.websrv.bypass_duplicate_check === true);
|
||||
if (!bypassDupes) {
|
||||
const [repostBySum, repostByPhash] = await Promise.all([
|
||||
queue.checkrepostsum(checksum),
|
||||
phash ? queue.checkrepostphash(phash) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (repostBySum) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Duplicate file (matches existing Item #${repostBySum})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (repostByPhash) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Visual duplicate (matches existing Item #${repostByPhash})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate target filename & UUID
|
||||
const uuid = await queue.genuuid();
|
||||
const ext = cfg.mimes[actualMime] || 'bin';
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const destPath = path.join(cfg.paths.b, filename);
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
// Copy file to public/b
|
||||
await fs.promises.copyFile(filePath, destPath);
|
||||
|
||||
const insertChecksum = bypassDupes ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
|
||||
// Insert into items table
|
||||
await db`
|
||||
INSERT INTO items ${db({
|
||||
src: '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: stat.size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: targetUser.user,
|
||||
userchannel: 'import_script',
|
||||
usernetwork: 'local',
|
||||
stamp: nowStamp,
|
||||
active: true,
|
||||
is_oc: effectiveIsOc,
|
||||
original_filename: baseName,
|
||||
title: effectiveTitle,
|
||||
width: dimResult?.width ?? null,
|
||||
height: dimResult?.height ?? null,
|
||||
visibility: effectiveVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: null
|
||||
}, '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);
|
||||
|
||||
// Subscribe uploader to comments
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${targetUser.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
} catch (_) {}
|
||||
|
||||
// Generate thumbnails
|
||||
try {
|
||||
await queue.genThumbnail(filename, actualMime, itemid, '', false, 512);
|
||||
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${prefix} [WARN] Thumbnail generation warning for #${itemid}:`, err.message);
|
||||
}
|
||||
|
||||
// Blurred thumbnail
|
||||
await queue.genBlurredThumbnail(itemid, false);
|
||||
|
||||
// Assign Rating Tag
|
||||
if (effectiveRating) {
|
||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: targetUser.id })}
|
||||
`;
|
||||
}
|
||||
|
||||
// Assign Custom Tags
|
||||
for (const tagName of mergedTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
const tagId = tagRow[0].id;
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
// Auto OC tags
|
||||
if (effectiveIsOc) {
|
||||
for (const tagname of ['oc', 'original content']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto Flash tags
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
for (const tagname of ['Flash', 'SWF']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional first comment
|
||||
if (effectiveComment && effectiveComment.trim().length > 0) {
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: targetUser.id,
|
||||
content: effectiveComment.trim()
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
// PostgreSQL Live Notification
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
username: targetUser.user,
|
||||
display_name: targetUser.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!effectiveIsOc
|
||||
})})`;
|
||||
} catch (_) {}
|
||||
|
||||
// Clear cache
|
||||
f0cklib.clearCountCache();
|
||||
|
||||
// Cleanup imported source file unless --keep was passed
|
||||
if (!opts.keep) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
if (sidecarPath) {
|
||||
await fs.promises.unlink(sidecarPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${prefix} [SUCCESS] Imported '${relPath}' -> Item #${itemid} (Rating: ${effectiveRating || 'untagged'}, MIME: ${actualMime})`);
|
||||
successCount++;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`${prefix} [ERROR] Failed to import '${relPath}':`, err.message);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`Import Complete!`);
|
||||
console.log(`Total Scanned : ${fileList.length}`);
|
||||
console.log(`Successful : ${successCount}`);
|
||||
console.log(`Skipped : ${skippedCount}`);
|
||||
console.log(`Failed : ${failedCount}`);
|
||||
console.log(`========================================`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runImport().catch(err => {
|
||||
console.error(`[IMPORT FATAL ERROR]`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -190,15 +190,20 @@ export const handleBannerUpload = async (req, res) => {
|
||||
update user_options
|
||||
set banner_file = ${finalFilename},
|
||||
banner_position = ${parts.banner_position || 'center'},
|
||||
banner_size = ${parts.banner_size || 'cover'}
|
||||
banner_size = ${parts.banner_size || 'cover'},
|
||||
banner_repeat = ${parts.banner_repeat || 'repeat'}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
banner_file: finalFilename,
|
||||
banner_position: parts.banner_position || 'center',
|
||||
banner_size: parts.banner_size || 'cover'
|
||||
banner_size: parts.banner_size || 'cover',
|
||||
banner_repeat: parts.banner_repeat || 'repeat'
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
} catch (dbErr) {
|
||||
@@ -273,6 +278,9 @@ export const handleBannerDelete = async (req, res) => {
|
||||
set banner_file = null
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
|
||||
@@ -12,10 +12,23 @@ config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.en
|
||||
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
|
||||
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const isDocker = process.env.DB_HOST === 'f0ckm-db' || config.sql.host === 'f0ckm-db' || process.env.CONTAINER === 'true';
|
||||
if (process.env.NODE_ENV === 'production' || isDocker) {
|
||||
config.main.development = false;
|
||||
}
|
||||
|
||||
if (config.main.development) {
|
||||
if (!process.env.DB_HOST && (config.sql.host === 'f0ckm-db' || !config.sql.host)) {
|
||||
config.sql.host = 'localhost';
|
||||
}
|
||||
if (!process.env.DB_PORT && config.sql.port === 5432) {
|
||||
config.sql.port = 5454;
|
||||
}
|
||||
if (!process.env.STORAGE_DIR) {
|
||||
process.env.STORAGE_DIR = 'f0ckm-data';
|
||||
}
|
||||
}
|
||||
|
||||
// Set timezone from config if not already set via environment variable
|
||||
if (!process.env.TZ && config.main.timezone) {
|
||||
process.env.TZ = config.main.timezone;
|
||||
@@ -53,11 +66,12 @@ config.paths = {
|
||||
koepfe: resolvePath('public/s/koepfe'),
|
||||
fonts: resolvePath('public/s/fonts'),
|
||||
memes: resolvePath('public/memes'),
|
||||
e: resolvePath('e'),
|
||||
e: resolvePath('public/e'),
|
||||
pending: resolvePath('pending'),
|
||||
deleted: resolvePath('deleted'),
|
||||
logs: resolvePath('logs'),
|
||||
tmp: resolvePath('tmp')
|
||||
tmp: resolvePath('tmp'),
|
||||
import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'))
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -38,6 +38,16 @@ export default new class {
|
||||
.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))) {
|
||||
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 });
|
||||
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) {
|
||||
return crypto.createHash('md5').update(str).digest("hex");
|
||||
};
|
||||
@@ -62,21 +95,22 @@ export default new class {
|
||||
getMode(mode) {
|
||||
let tmp;
|
||||
mode = Number(mode);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
switch (mode) {
|
||||
case 1: // nsfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
|
||||
break;
|
||||
case 2: // untagged
|
||||
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
|
||||
tmp = `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
|
||||
break;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
break;
|
||||
case 4: // nsfl
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${nsflId})` : "1 = 0";
|
||||
break;
|
||||
default: // sfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
tmp = `(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 and tag_id in (1, 2, ${nsflId})))`;
|
||||
break;
|
||||
}
|
||||
return tmp;
|
||||
@@ -107,7 +141,8 @@ export default new class {
|
||||
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
|
||||
}
|
||||
if (filtered.includes('untagged')) {
|
||||
parts.push('not exists (select 1 from tags_assign where item_id = items.id)');
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
parts.push(`not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return '(' + parts.join(' OR ') + ')';
|
||||
@@ -167,27 +202,28 @@ export default new class {
|
||||
const tagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const untagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
|
||||
where not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const sfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const nsfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const nsfl = cfg.enable_nsfl ? +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total : 0;
|
||||
const deleted = +(await db`
|
||||
select count(*) as total
|
||||
@@ -246,18 +282,22 @@ export default new class {
|
||||
|
||||
return false;
|
||||
};
|
||||
async getTags(itemid) {
|
||||
async getTags(itemid, session = null) {
|
||||
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
|
||||
const tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join user_options uo on uo.user_id = "user".id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
tags[t].badge = this.getBadge(tags[t]);
|
||||
if (!hasSession) {
|
||||
delete tags[t].user;
|
||||
delete tags[t].display_name;
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
@@ -204,3 +204,46 @@ export async function moveToDeleted(dest, deletedId) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,8 @@
|
||||
"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_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",
|
||||
@@ -799,6 +801,12 @@
|
||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,8 @@
|
||||
"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_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",
|
||||
@@ -801,6 +803,12 @@
|
||||
"delete_confirm": "Delete this invite token?",
|
||||
"slot_refreshes_on": "slot refreshes on {date}",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,69 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import { updateHallsCache } from "../halls_cache.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
import fs from "fs";
|
||||
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 computeBaseMode = (mode, ratings, session) => {
|
||||
const effMode = Number(mode ?? 0);
|
||||
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
|
||||
let baseMode;
|
||||
if (effMode === 2) {
|
||||
if (ratingsArr && ratingsArr.includes('untagged') && ratingsArr.length > 1) {
|
||||
baseMode = lib.getMultiRatingMode(ratingsArr);
|
||||
} else {
|
||||
baseMode = lib.getMode(2);
|
||||
}
|
||||
} else if (effMode === 3) {
|
||||
baseMode = (ratingsArr && ratingsArr.length > 1) ? lib.getMultiRatingMode(ratingsArr) : lib.getMode(3);
|
||||
} else {
|
||||
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
baseMode = multiRatingSQL ?? lib.getMode(effMode);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if ((effMode === 0 || effMode === 3 || mode === undefined || mode === null) && (!ratingsArr || ratingsArr.length <= 1)) {
|
||||
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 and tag_id in (1, 2, ${nsflId})))`
|
||||
: "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 and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged) {
|
||||
if (effMode === 2 || (ratingsArr && ratingsArr.length === 1 && ratingsArr[0] === 'untagged')) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
}
|
||||
return baseMode;
|
||||
};
|
||||
|
||||
|
||||
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)
|
||||
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
||||
@@ -244,10 +301,7 @@ export default {
|
||||
|
||||
const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
|
||||
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
const modequery = computeBaseMode(mode, ratings, session);
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -302,6 +356,13 @@ export default {
|
||||
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``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(items.visibility, 0) = 0`);
|
||||
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
||||
let total = getCachedCount(cacheKey);
|
||||
|
||||
@@ -313,6 +374,7 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -355,6 +417,7 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -385,6 +448,8 @@ export default {
|
||||
const rows = (await db`
|
||||
select
|
||||
items.id,
|
||||
items.slug,
|
||||
items.visibility,
|
||||
items.mime,
|
||||
items.dest,
|
||||
items.username as username,
|
||||
@@ -521,7 +586,17 @@ export default {
|
||||
if (uhData.length) userHallObj = uhData[0];
|
||||
}
|
||||
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 mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
@@ -535,18 +610,16 @@ export default {
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
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 multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
||||
|
||||
if (itemid === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const itemModeQuery = session
|
||||
? computeBaseMode(mode, ratings, session)
|
||||
: (cfg.websrv.public_nsfw
|
||||
? `not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`
|
||||
: `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`
|
||||
);
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -585,9 +658,18 @@ export default {
|
||||
|
||||
// Helper to construct shared filter conditions
|
||||
const buildConditions = () => {
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(items.visibility, 0) = 0`);
|
||||
|
||||
return db`
|
||||
${db.unsafe(modequery)}
|
||||
${db.unsafe(itemModeQuery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
|
||||
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${hallFilter}
|
||||
@@ -601,11 +683,9 @@ export default {
|
||||
};
|
||||
|
||||
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
|
||||
// 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`
|
||||
select distinct on (items.id)
|
||||
items.*,
|
||||
@@ -629,15 +709,48 @@ export default {
|
||||
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``}
|
||||
where
|
||||
items.id = ${itemid} and
|
||||
${itemLookup} and
|
||||
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
|
||||
`;
|
||||
|
||||
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`
|
||||
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
||||
values (${user_id}, ${itemid}, 1, now())
|
||||
@@ -646,15 +759,12 @@ export default {
|
||||
last_viewed = now()
|
||||
`.catch(e => console.error('Failed to track view:', e));
|
||||
}
|
||||
|
||||
if (!actitem) {
|
||||
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
||||
if (!session && getGlobalfilter()) {
|
||||
const unfilteredItem = await db`
|
||||
select id from items where id = ${itemid} and active = true limit 1
|
||||
// Guest global filter check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
|
||||
if (!session && getGlobalfilter() && (actitem.visibility || 0) === 0) {
|
||||
const filteredItem = await db`
|
||||
select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
|
||||
`;
|
||||
if (unfilteredItem[0]) {
|
||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
||||
if (filteredItem.length > 0) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -669,11 +779,6 @@ export default {
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible."
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
||||
@@ -681,11 +786,11 @@ export default {
|
||||
|
||||
// Determine the effective mode for optimization check (similar to Random)
|
||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
||||
const useTagsDriver = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
|
||||
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
||||
return db`
|
||||
select items.id
|
||||
select items.id, items.slug
|
||||
from items
|
||||
left join tags_assign on tags_assign.item_id = items.id
|
||||
left join tags on tags.id = tags_assign.tag_id
|
||||
@@ -696,7 +801,7 @@ export default {
|
||||
where
|
||||
${buildConditions()}
|
||||
${whereClause}
|
||||
group by items.id
|
||||
group by items.id, items.slug
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`;
|
||||
@@ -711,7 +816,7 @@ export default {
|
||||
const checkFilter = !session && nsfpIds.length > 0;
|
||||
|
||||
const query = db`
|
||||
SELECT ta.item_id as id
|
||||
SELECT ta.item_id as id, items.slug
|
||||
FROM tags_assign ta
|
||||
INNER JOIN items ON items.id = ta.item_id
|
||||
${checkFilter
|
||||
@@ -720,6 +825,7 @@ export default {
|
||||
}
|
||||
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${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``}
|
||||
@@ -746,7 +852,7 @@ export default {
|
||||
// Cheat array should include current item and neighbors, sorted
|
||||
const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b);
|
||||
|
||||
const tags = await lib.getTags(itemid);
|
||||
const tags = await lib.getTags(itemid, session);
|
||||
const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`;
|
||||
const userHallsForItem = user_id
|
||||
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
|
||||
@@ -781,24 +887,24 @@ export default {
|
||||
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
||||
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
||||
const repostRows = await db`
|
||||
SELECT id, username, stamp FROM items
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
WHERE active = true
|
||||
AND id != ${itemid}
|
||||
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
|
||||
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) {
|
||||
// Even without bypass, check if other bypass-entries exist with this same hash
|
||||
const baseChecksum = actitem.checksum;
|
||||
const repostRows = await db`
|
||||
SELECT id, username, stamp FROM items
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
WHERE active = true
|
||||
AND id != ${itemid}
|
||||
AND checksum LIKE ${baseChecksum + '_bypass_%'}
|
||||
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
|
||||
@@ -808,7 +914,7 @@ export default {
|
||||
const existingIds = new Set(repostItems.map(r => r.id));
|
||||
for (const pm of phashMatches) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -831,19 +937,14 @@ export default {
|
||||
const isNsfw = tags.some(t => t.id == 2);
|
||||
const isSfw = tags.some(t => t.id == 1);
|
||||
const isTagged = tags.length > 0;
|
||||
// Guest rating restriction check for public uploads (visibility === 0)
|
||||
// Direct links to public unrated/untagged posts are allowed for guests.
|
||||
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
|
||||
let guestBlocked = false;
|
||||
if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
|
||||
else if (isNsfl) guestBlocked = true;
|
||||
|
||||
// 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) {
|
||||
if (guestBlocked) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -860,6 +961,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 = {
|
||||
success: true,
|
||||
user: {
|
||||
@@ -871,6 +1005,8 @@ export default {
|
||||
},
|
||||
item: {
|
||||
id: actitem.id,
|
||||
slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
|
||||
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
|
||||
username: actitem.username,
|
||||
author_id: actitem.author_id,
|
||||
author_color: actitem.author_color,
|
||||
@@ -929,17 +1065,21 @@ export default {
|
||||
is_oc: actitem.is_oc || false,
|
||||
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
|
||||
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,
|
||||
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: {
|
||||
end: endItem[0]?.id || itemid,
|
||||
start: startItem[0]?.id || itemid,
|
||||
next: nextItem[0]?.id || null,
|
||||
prev: prevItem[0]?.id || null,
|
||||
page: actitem.id,
|
||||
end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
|
||||
start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
|
||||
next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
|
||||
prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
|
||||
page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
|
||||
cheat: cheat
|
||||
},
|
||||
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
|
||||
@@ -994,9 +1134,9 @@ export default {
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
const modequery = computeBaseMode(mode, ratings, session);
|
||||
|
||||
let item;
|
||||
|
||||
@@ -1008,6 +1148,7 @@ export default {
|
||||
WHERE
|
||||
${db.unsafe(modequery)}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
||||
AND items.title IS NOT NULL
|
||||
${mimeSQL}
|
||||
@@ -1030,6 +1171,7 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and "user".user ilike ${user}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!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
|
||||
@@ -1069,6 +1211,7 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${tagFilter}
|
||||
${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``}
|
||||
@@ -1091,6 +1234,7 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and h.slug = ${hall}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!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``}
|
||||
@@ -1107,6 +1251,7 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and uha.hall_id = ${userHallId}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${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``}
|
||||
order by random()
|
||||
@@ -1115,9 +1260,9 @@ export default {
|
||||
} else {
|
||||
// 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.
|
||||
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
// tagId optimisation only applies for single native modes (not multi-rating)
|
||||
const tagId = !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||
const globalModeQuery = modequery;
|
||||
// tagId optimisation only applies for single native modes for logged-in users (not multi-rating or guest mode)
|
||||
const tagId = session && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
|
||||
: null;
|
||||
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
||||
@@ -1133,6 +1278,7 @@ export default {
|
||||
${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``}
|
||||
WHERE items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${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``}
|
||||
@@ -1158,7 +1304,8 @@ export default {
|
||||
};
|
||||
},
|
||||
getComments: async (itemId, sort = 'new', process = true) => {
|
||||
if (!itemId) return [];
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!numericId) return [];
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
const comments = await db`
|
||||
@@ -1166,12 +1313,12 @@ export default {
|
||||
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
|
||||
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE c.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,
|
||||
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
|
||||
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
||||
@@ -1285,7 +1432,7 @@ export default {
|
||||
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -1314,11 +1461,12 @@ export default {
|
||||
}
|
||||
},
|
||||
getSubscriptionStatus: async (userId, itemId) => {
|
||||
if (!userId || !itemId) return false;
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return false;
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${itemId} AND is_subscribed = true`;
|
||||
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${itemId} in ${Date.now() - tStart}ms`);
|
||||
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 ${numericId} in ${Date.now() - tStart}ms`);
|
||||
return sub.length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -1326,9 +1474,10 @@ export default {
|
||||
},
|
||||
processMentions,
|
||||
markNotificationsRead: async (userId, itemId) => {
|
||||
if (!userId || !itemId) return;
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return;
|
||||
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) {
|
||||
console.error('[F0CKLIB] Error marking notifications as read:', e);
|
||||
}
|
||||
@@ -1645,8 +1794,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
computeBaseMode,
|
||||
getGlobalfilter,
|
||||
processMentions,
|
||||
processEmbeds,
|
||||
computeXdScore,
|
||||
xdScoreMeta,
|
||||
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
|
||||
clearCountCache: () => countCache.clear()
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, 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) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -282,6 +282,10 @@ export default (router, tpl) => {
|
||||
totals: await lib.countf0cks(),
|
||||
session: req.session,
|
||||
manual_approval: getManualApproval(),
|
||||
min_tags: getMinTags(),
|
||||
trusted_uploads: getTrustedUploads(),
|
||||
registration_open: getRegistrationOpen(),
|
||||
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
|
||||
log_user_ips: getLogUserIps(),
|
||||
hash_user_ips: getHashUserIps(),
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
@@ -319,7 +323,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 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' });
|
||||
@@ -650,7 +654,78 @@ export default (router, tpl) => {
|
||||
|
||||
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) => {
|
||||
if (!getEnableCleanup()) {
|
||||
return res.redirect("/admin");
|
||||
@@ -661,6 +736,7 @@ export default (router, tpl) => {
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
cleanup_start_date: getCleanupStartDate(),
|
||||
cleanup_end_date: getCleanupEndDate(),
|
||||
cleanup_include_engaged: getCleanupIncludeEngaged(),
|
||||
totals: await lib.countf0cks(),
|
||||
tmp: null
|
||||
};
|
||||
@@ -674,15 +750,18 @@ export default (router, tpl) => {
|
||||
try {
|
||||
const cleanup_start_date = req.post.cleanup_start_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_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);
|
||||
setCleanupEndDate(cleanup_end_date);
|
||||
setCleanupIncludeEngaged(cleanup_include_engaged);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -701,17 +780,23 @@ export default (router, tpl) => {
|
||||
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
// 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 isEnabled = settingsMap['enable_cleanup'] === 'true';
|
||||
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
|
||||
const startDate = settingsMap['cleanup_start_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
|
||||
setEnableCleanup(isEnabled);
|
||||
setCleanupStartDate(startDate);
|
||||
setCleanupEndDate(endDate);
|
||||
setCleanupIncludeEngaged(includeEngaged);
|
||||
|
||||
if (!isEnabled) {
|
||||
throw new Error('Cleanup is disabled in settings.');
|
||||
@@ -721,7 +806,7 @@ export default (router, tpl) => {
|
||||
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 end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
|
||||
@@ -737,7 +822,7 @@ export default (router, tpl) => {
|
||||
AND i.is_pinned = false
|
||||
AND i.stamp >= ${start_stamp}
|
||||
AND i.stamp <= ${end_stamp}
|
||||
AND (
|
||||
${includeEngaged ? db`` : db`AND (
|
||||
-- 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))
|
||||
OR
|
||||
@@ -746,7 +831,7 @@ export default (router, tpl) => {
|
||||
OR
|
||||
-- Case 3: Pending posts (not yet approved) that are old enough
|
||||
(i.active = false AND i.is_deleted = false)
|
||||
)
|
||||
)`}
|
||||
`;
|
||||
|
||||
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
|
||||
@@ -764,12 +849,16 @@ export default (router, tpl) => {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
await db`DELETE FROM tags_assign WHERE item_id = ${item.id}`;
|
||||
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining orphan tag assignments for deleted or inactive items
|
||||
await db`DELETE FROM tags_assign WHERE item_id IN (SELECT id FROM items WHERE is_deleted = true OR active = false)`;
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
|
||||
@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
|
||||
import { createI18n } from "../i18n.mjs";
|
||||
|
||||
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();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
@@ -32,15 +32,16 @@ export default (router, tpl) => {
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (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({
|
||||
itemid: itemid,
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
session: req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
@@ -170,7 +171,8 @@ export default (router, tpl) => {
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid
|
||||
id: itemid,
|
||||
slug: data.item?.slug || null
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -194,7 +196,7 @@ export default (router, tpl) => {
|
||||
|
||||
|
||||
// Infinite scroll endpoint for index thumbnails
|
||||
router.get(/\/ajax\/items/, async (req, res) => {
|
||||
router.get(/^\/ajax\/items/, async (req, res) => {
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
@@ -2,15 +2,22 @@ import { promises as fs } from "fs";
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import search from '../../routeinc/search.mjs';
|
||||
import path from "path";
|
||||
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||
import audit from '../../audit.mjs';
|
||||
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
import { purgeExpiredUploads } from '../../lib_delete.mjs';
|
||||
import { calculateExpiresAt } from './upload.mjs';
|
||||
|
||||
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 MAX_META_CACHE = 2000;
|
||||
|
||||
@@ -594,10 +601,12 @@ export default router => {
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
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({
|
||||
success: false,
|
||||
items: []
|
||||
@@ -636,6 +645,7 @@ export default router => {
|
||||
items: {
|
||||
...safeItem,
|
||||
id: item.id,
|
||||
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
||||
dest: relativeDest,
|
||||
url: directUrl,
|
||||
direct_url: directUrl
|
||||
@@ -700,10 +710,17 @@ export default router => {
|
||||
};
|
||||
|
||||
const excludedTags = req.session?.excluded_tags || [];
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (sessionUser
|
||||
? db`and (coalesce("items".visibility, 0) = 0 or lower("items".username) = ${sessionUser})`
|
||||
: db`and coalesce("items".visibility, 0) = 0`);
|
||||
|
||||
const newest = (await db`select max(id) as id from "items"`)[0].id;
|
||||
const oldest = (await db`select min(id) as id from "items"`)[0].id;
|
||||
const modequery = lib.getMode(opt.mode);
|
||||
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 || 0;
|
||||
const modequery = f0cklib.computeBaseMode(opt.mode, null, req.session);
|
||||
|
||||
const rows = (await db`
|
||||
select "items".id, "items".mime, coalesce("tags_assign".tag_id, 0) as tag_id
|
||||
@@ -712,6 +729,7 @@ export default router => {
|
||||
where
|
||||
${db.unsafe(modequery)} and
|
||||
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``}
|
||||
${opt.older
|
||||
? db`and id <= ${opt.older}`
|
||||
@@ -727,34 +745,25 @@ export default router => {
|
||||
`).sort((a, b) => b.id - a.id);
|
||||
|
||||
return res.json({
|
||||
atEnd: rows[0].id === newest,
|
||||
atStart: rows[rows.length - 1].id === oldest,
|
||||
atEnd: rows.length > 0 && rows[0].id === newest,
|
||||
atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
|
||||
success: true,
|
||||
items: rows
|
||||
}, 200);
|
||||
});
|
||||
|
||||
group.get(/\/item\/(?<id>[0-9]+)$/, async (req, res) => {
|
||||
const id = +req.params.id;
|
||||
group.get(/\/item\/(?<id>[a-zA-Z0-9_-]{11}|\d+)$/, async (req, res) => {
|
||||
const rawIdOrSlug = req.params.id;
|
||||
const isNumeric = /^\d+$/.test(rawIdOrSlug);
|
||||
const id = isNumeric ? +rawIdOrSlug : (await db`SELECT id FROM items WHERE slug = ${rawIdOrSlug} LIMIT 1`)[0]?.id;
|
||||
if (!id) {
|
||||
return res.json({ success: false, msg: 'no items found' });
|
||||
}
|
||||
|
||||
const item = await db`
|
||||
select *
|
||||
from "items"
|
||||
where id = ${+id} and active = true
|
||||
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
|
||||
where id = ${id} and active = true
|
||||
limit 1
|
||||
`;
|
||||
|
||||
@@ -765,12 +774,74 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
const rows = {
|
||||
...item[0],
|
||||
...{
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
const effMode = req.query.mode !== undefined ? +req.query.mode : (req.mode ?? 3);
|
||||
const ratingsRaw = req.cookies?.ratings || req.query.ratings;
|
||||
const ratingsArr = (effMode === 3 || effMode === 2) ? null : (ratingsRaw ? (Array.isArray(ratingsRaw) ? ratingsRaw : decodeURIComponent(ratingsRaw).split(/[|,]/)).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
|
||||
let guestTagFilter = db``;
|
||||
if (!session) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`;
|
||||
} else {
|
||||
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`;
|
||||
}
|
||||
}
|
||||
|
||||
let modeCondition = db``;
|
||||
if (effMode === 2) {
|
||||
modeCondition = db`and not exists (select 1 from tags_assign where item_id = items.id)`;
|
||||
} else if (effMode === 0) {
|
||||
modeCondition = 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))`;
|
||||
} else if (effMode === 1) {
|
||||
modeCondition = db`and id in (select item_id from tags_assign where tag_id = 2)`;
|
||||
} else if (effMode === 4) {
|
||||
modeCondition = db`and id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`;
|
||||
} else if (ratingsArr && ratingsArr.length > 0) {
|
||||
modeCondition = db`and ${db.unsafe(lib.getMultiRatingMode(ratingsArr))}`;
|
||||
}
|
||||
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(visibility, 0) = 0 or lower(username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(visibility, 0) = 0`);
|
||||
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const rows = {
|
||||
...actitem,
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
};
|
||||
|
||||
return res.json({
|
||||
@@ -1038,7 +1109,24 @@ export default router => {
|
||||
});
|
||||
|
||||
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
|
||||
const existing = await db`
|
||||
@@ -1168,6 +1256,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) => {
|
||||
const itemid = +req.params.id;
|
||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||
|
||||
@@ -353,6 +353,30 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||
const { color } = req.post;
|
||||
|
||||
@@ -19,7 +19,7 @@ export default router => {
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
tags: await lib.getTags(+req.params.postid)
|
||||
tags: await lib.getTags(+req.params.postid, req.session)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { spawn as _spawnRaw } from 'child_process';
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import path from "path";
|
||||
import f0cklib from "../../routeinc/f0cklib.mjs";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// In-memory job progress map (keyed by jobId string)
|
||||
@@ -137,6 +139,71 @@ const parseMultipart = (buffer, boundary) => {
|
||||
|
||||
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
|
||||
const collectBody = (req) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -369,6 +436,11 @@ export default router => {
|
||||
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
||||
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`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
@@ -380,11 +452,14 @@ export default router => {
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
stamp: nowStamp,
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
title: 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
|
||||
`;
|
||||
|
||||
@@ -429,6 +504,26 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
f0cklib.clearCountCache();
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!',
|
||||
@@ -437,6 +532,9 @@ export default router => {
|
||||
});
|
||||
} else {
|
||||
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
const session = {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
@@ -674,6 +772,8 @@ export default router => {
|
||||
await fs.unlink(source).catch(() => { });
|
||||
|
||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
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`
|
||||
insert into items ${db({
|
||||
@@ -686,11 +786,14 @@ export default router => {
|
||||
username: session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
stamp: nowStamp,
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
title: 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
|
||||
`;
|
||||
|
||||
@@ -735,15 +838,17 @@ export default router => {
|
||||
username: session.user,
|
||||
display_name: session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
// Push to Matrix Channel (only if auto-approved and public)
|
||||
if (!isApprovalRequired && targetVisibility === 0) {
|
||||
try {
|
||||
const self = router.self;
|
||||
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
|
||||
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -69,7 +70,7 @@ export default (router, tpl) => {
|
||||
comments,
|
||||
is_subscribed,
|
||||
is_locked,
|
||||
user_id: req.session ? req.session.user : null,
|
||||
user_id: req.session ? req.session.id : null,
|
||||
is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false
|
||||
})
|
||||
})
|
||||
@@ -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 || []) : [];
|
||||
/* <mode-override> */
|
||||
// prioritize query mode (from AJAX) over session default
|
||||
@@ -171,10 +173,10 @@ export default (router, tpl) => {
|
||||
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
|
||||
// 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 = f0cklib.computeBaseMode(mode, ratingsArr, req.session).replace(/items\.id/g, 'i.id');
|
||||
|
||||
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
|
||||
LEFT JOIN items i ON c.item_id = i.id
|
||||
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
||||
@@ -208,6 +210,7 @@ export default (router, tpl) => {
|
||||
let processedComments = mentionsProcessed.map(c => {
|
||||
return {
|
||||
...c,
|
||||
item_slug: getEnableItemSlugs() ? c.item_slug : null,
|
||||
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)
|
||||
const itemQuery = await db`
|
||||
SELECT
|
||||
i.slug,
|
||||
i.xd_score,
|
||||
COALESCE(i.visibility, 0) as visibility,
|
||||
(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[])
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||
@@ -588,16 +593,24 @@ export default (router, tpl) => {
|
||||
// Large comments would silently drop the notification. The client fetches
|
||||
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
|
||||
const notifyBody = content.length > 500 ? content.substring(0, 500) + '…' : content;
|
||||
const uo = await db`SELECT banner_file, banner_position, banner_size, banner_repeat FROM user_options WHERE user_id = ${req.session.id}`;
|
||||
const bannerOpt = uo[0] || {};
|
||||
|
||||
const livePayload = {
|
||||
type: 'comment',
|
||||
id: commentId,
|
||||
item_id: item_id,
|
||||
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
|
||||
parent_id: parent_id || null,
|
||||
body: notifyBody,
|
||||
username: req.session.user,
|
||||
user_id: req.session.id,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||
created_at: new Date().toISOString(),
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
@@ -610,8 +623,7 @@ export default (router, tpl) => {
|
||||
db.notify('comments', JSON.stringify(livePayload));
|
||||
|
||||
// 2. Sidebar activity update
|
||||
// Compute is_long using the full content (not the truncated notifyBody) so the
|
||||
// sidebar renders the correct clamped state immediately on first paint.
|
||||
const itemVisibility = itemQuery[0]?.visibility ?? 0;
|
||||
const activityIsLong = content.length > 120
|
||||
|| content.split('\n').length > 2
|
||||
|| activityFiles.length > 0
|
||||
@@ -626,6 +638,10 @@ export default (router, tpl) => {
|
||||
item_rating_label: ratingLabel,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||
username: req.session.user,
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
@@ -648,8 +664,14 @@ export default (router, tpl) => {
|
||||
success: true,
|
||||
comment: {
|
||||
...newComment[0],
|
||||
user_id: req.session.id,
|
||||
username_color: req.session.username_color || null,
|
||||
content,
|
||||
files: activityFiles
|
||||
files: activityFiles,
|
||||
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null
|
||||
},
|
||||
xd_score: xdRow?.xd_score ?? null,
|
||||
is_new_subscription
|
||||
@@ -995,14 +1017,26 @@ export default (router, tpl) => {
|
||||
|
||||
// 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 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 isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session ? (req.session.user || '').toLowerCase() : null;
|
||||
const sessionUserId = req.session ? req.session.id : null;
|
||||
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (req.session
|
||||
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
|
||||
: db`AND COALESCE(i.visibility, 0) = 0`);
|
||||
|
||||
const comments = await db`
|
||||
SELECT
|
||||
c.*,
|
||||
i.mime,
|
||||
i.id as item_id,
|
||||
i.slug as item_slug,
|
||||
i.dest as item_dest,
|
||||
(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[])
|
||||
@@ -1010,6 +1044,10 @@ export default (router, tpl) => {
|
||||
u.user as username,
|
||||
uo.avatar,
|
||||
uo.avatar_file,
|
||||
uo.banner_file,
|
||||
uo.banner_position,
|
||||
uo.banner_size,
|
||||
uo.banner_repeat,
|
||||
uo.username_color,
|
||||
uo.display_name
|
||||
FROM comments c
|
||||
@@ -1019,6 +1057,7 @@ export default (router, tpl) => {
|
||||
WHERE c.is_deleted = false
|
||||
AND i.active = true
|
||||
AND i.is_deleted = false
|
||||
${visibilityFilter}
|
||||
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``}
|
||||
${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 +1066,15 @@ export default (router, tpl) => {
|
||||
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
|
||||
const filesMap = new Map();
|
||||
if (comments.length > 0) {
|
||||
if (processedCommentsList.length > 0) {
|
||||
const commentIds = comments.map(c => c.id);
|
||||
try {
|
||||
const files = await db`
|
||||
|
||||
@@ -437,8 +437,9 @@ export default (router) => {
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
|
||||
original_filename: original_filename || null,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export default (router, tpl) => {
|
||||
ratings: ratingsArr,
|
||||
mime: mime,
|
||||
fav: false,
|
||||
session: !!req.session,
|
||||
session: req.session,
|
||||
user_id: req.session?.id,
|
||||
random: isRandom
|
||||
});
|
||||
@@ -236,7 +236,7 @@ export default (router, tpl) => {
|
||||
hall: req.params.hall,
|
||||
fav: req.params.mode == 'favs',
|
||||
mode: req.mode,
|
||||
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
|
||||
ratings: (() => { if (req.mode === 2 || req.mode === 3) return null; const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
|
||||
session: req.session,
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin,
|
||||
@@ -253,7 +253,7 @@ export default (router, tpl) => {
|
||||
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
|
||||
|
||||
if (!data.success) {
|
||||
if (data.is_private) {
|
||||
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,
|
||||
@@ -390,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_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
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');
|
||||
@@ -414,11 +416,11 @@ export default (router, tpl) => {
|
||||
|
||||
// Specific route for direct item links: /user/:user/:itemid
|
||||
// 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)
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) 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);
|
||||
// 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|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> */
|
||||
|
||||
router.get(/^\/(about)$/, (req, res) => {
|
||||
|
||||
@@ -114,7 +114,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -156,65 +156,69 @@ export default (router, tpl) => {
|
||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||
}
|
||||
|
||||
// Push to Discord Webhook (Direct)
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
const payload = JSON.stringify({ content: message });
|
||||
const url = new URL(discordClient.webhook_url);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
const reqDiscord = https.request(options, (resDiscord) => {
|
||||
if (resDiscord.statusCode >= 400) {
|
||||
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
||||
}
|
||||
});
|
||||
reqDiscord.on('error', (err) => {
|
||||
console.error('[MOD APPROVE] Webhook failed:', err);
|
||||
});
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
||||
}
|
||||
const isPublic = (f0ck[0].visibility || 0) === 0;
|
||||
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
||||
const clients = await Promise.all(router.self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
if (isPublic) {
|
||||
// Push to Discord Webhook (Direct)
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
||||
const payload = JSON.stringify({ content: message });
|
||||
const url = new URL(discordClient.webhook_url);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
const reqDiscord = https.request(options, (resDiscord) => {
|
||||
if (resDiscord.statusCode >= 400) {
|
||||
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
||||
}
|
||||
});
|
||||
reqDiscord.on('error', (err) => {
|
||||
console.error('[MOD APPROVE] Webhook failed:', err);
|
||||
});
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: id,
|
||||
dest: f0ck[0].dest,
|
||||
mime: f0ck[0].mime,
|
||||
username: f0ck[0].username,
|
||||
tag_id: f0ck[0].tag_id,
|
||||
is_oc: !!f0ck[0].is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
||||
const clients = await Promise.all(router.self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: id,
|
||||
dest: f0ck[0].dest,
|
||||
mime: f0ck[0].mime,
|
||||
username: f0ck[0].username,
|
||||
tag_id: f0ck[0].tag_id,
|
||||
is_oc: !!f0ck[0].is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
|
||||
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
|
||||
// trigger only gave us user_id and item_id
|
||||
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, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat, i.mime, i.slug as item_slug, COALESCE(i.visibility, 0) as visibility, LOWER(i.username) as item_username,
|
||||
(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
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
@@ -134,17 +135,34 @@ db.listen('activity', async (payload) => {
|
||||
data.username = details.username;
|
||||
data.avatar = details.avatar;
|
||||
data.avatar_file = details.avatar_file;
|
||||
data.banner_file = data.banner_file || details.banner_file || null;
|
||||
data.banner_position = data.banner_position || details.banner_position || null;
|
||||
data.banner_size = data.banner_size || details.banner_size || null;
|
||||
data.banner_repeat = data.banner_repeat || details.banner_repeat || null;
|
||||
data.mime = details.mime;
|
||||
data.username_color = details.username_color;
|
||||
data.display_name = details.display_name || null;
|
||||
data.tag_id = details.tag_id;
|
||||
data.item_slug = getEnableItemSlugs() ? details.item_slug : null;
|
||||
data.visibility = details.visibility;
|
||||
data.item_username = details.item_username;
|
||||
} else {
|
||||
data.username = 'System';
|
||||
data.visibility = 0;
|
||||
data.item_username = null;
|
||||
}
|
||||
|
||||
// Broadcast to ALL connected clients
|
||||
// Broadcast to clients permitted to view this activity
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'activity', data });
|
||||
const itemVisibility = data.visibility ?? 0;
|
||||
const isPublic = itemVisibility === 0;
|
||||
const isCommentAuthor = client.userId && client.userId === data.user_id;
|
||||
const isItemOwner = client.username && data.item_username && client.username.toLowerCase() === data.item_username.toLowerCase();
|
||||
const isAdminOrMod = client.admin || client.is_moderator;
|
||||
|
||||
if (isPublic || isCommentAuthor || isItemOwner || isAdminOrMod) {
|
||||
client.send({ type: 'activity', data });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Activity broadcast error:', e);
|
||||
@@ -210,9 +228,13 @@ db.listen('motd', (payload) => {
|
||||
db.listen('new_item', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
|
||||
const itemVis = data.visibility || 0;
|
||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}, visibility: ${itemVis}) to connected clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'new_item', data });
|
||||
const isOwner = client.username && data.username && client.username.toLowerCase() === data.username.toLowerCase();
|
||||
if (itemVis === 0 || isOwner) {
|
||||
client.send({ type: 'new_item', data });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('New item broadcast error:', e);
|
||||
@@ -367,7 +389,7 @@ export default (router, tpl) => {
|
||||
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
||||
const notifications = typeFilter
|
||||
? 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(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -392,7 +414,7 @@ export default (router, tpl) => {
|
||||
OFFSET ${offset}
|
||||
`
|
||||
: 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(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -426,7 +448,7 @@ export default (router, tpl) => {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, reason };
|
||||
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -443,7 +465,7 @@ export default (router, tpl) => {
|
||||
|
||||
try {
|
||||
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(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -480,7 +502,7 @@ export default (router, tpl) => {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, reason };
|
||||
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
@@ -588,6 +610,8 @@ export default (router, tpl) => {
|
||||
avatar_file: req.session?.avatar_file || null,
|
||||
avatar: req.session?.avatar || null,
|
||||
username_color: req.session?.username_color || null,
|
||||
admin: !!req.session?.admin,
|
||||
is_moderator: !!req.session?.is_moderator,
|
||||
receive_system_notifications: req.session?.receive_system_notifications !== false,
|
||||
receive_user_notifications: req.session?.receive_user_notifications !== false,
|
||||
do_not_disturb: req.session?.do_not_disturb === true,
|
||||
|
||||
@@ -97,11 +97,11 @@ export default (router, tpl) => {
|
||||
`;
|
||||
|
||||
const favotop = await db`
|
||||
select favorites.item_id, count(*) favs
|
||||
select favorites.item_id as id, items.slug, count(*) favs
|
||||
from favorites
|
||||
join items on items.id = favorites.item_id
|
||||
where items.active = true
|
||||
group by favorites.item_id
|
||||
group by favorites.item_id, items.slug
|
||||
having count(*) > 1
|
||||
order by favs desc
|
||||
limit 10
|
||||
@@ -110,7 +110,7 @@ export default (router, tpl) => {
|
||||
let xdtop = [];
|
||||
if (config.websrv.enable_xd_score) {
|
||||
const xdRows = await db`
|
||||
select id, xd_score
|
||||
select id, slug, xd_score
|
||||
from items
|
||||
where active = true and is_deleted = false and xd_score > 0
|
||||
order by xd_score desc
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Serve the scroller page
|
||||
@@ -115,10 +116,13 @@ export default (router, tpl) => {
|
||||
try {
|
||||
const slug = '%' + lib.slugify(q) + '%';
|
||||
const rows = await db`
|
||||
SELECT t.tag, t.normalized, COUNT(ta.item_id) as uses
|
||||
SELECT t.tag, t.normalized, COUNT(DISTINCT items.id) as uses
|
||||
FROM tags t
|
||||
JOIN tags_assign ta ON ta.tag_id = t.id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.id > 2
|
||||
AND items.active = true
|
||||
AND items.is_deleted = false
|
||||
AND lower(t.normalized) ILIKE ${slug}
|
||||
GROUP BY t.tag, t.normalized
|
||||
ORDER BY uses DESC
|
||||
@@ -160,7 +164,7 @@ export default (router, tpl) => {
|
||||
const excludeIds = qs.exclude
|
||||
? qs.exclude.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 500)
|
||||
: [];
|
||||
const modeQuery = lib.getMode(mode ?? 0);
|
||||
const modeQuery = f0cklib.computeBaseMode(mode ?? 0, null, req.session);
|
||||
const nsfp = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
// anchor= is a specific item ID to include first in the batch (used for hash-based deep links)
|
||||
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
|
||||
@@ -236,6 +240,13 @@ export default (router, tpl) => {
|
||||
${sessionId ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sessionId})` : db`false`} AS is_faved
|
||||
`;
|
||||
const sid = req.session ? +req.session.id : null;
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (sessionUser
|
||||
? db`AND (COALESCE(items.visibility, 0) = 0 OR LOWER(items.username) = ${sessionUser})`
|
||||
: db`AND COALESCE(items.visibility, 0) = 0`);
|
||||
|
||||
try {
|
||||
let rows;
|
||||
@@ -268,6 +279,7 @@ export default (router, tpl) => {
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
${visibilityFilter}
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
@@ -292,6 +304,7 @@ export default (router, tpl) => {
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
${visibilityFilter}
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
|
||||
@@ -24,9 +24,16 @@ export default (router, tpl) => {
|
||||
`;
|
||||
|
||||
// Get custom avatar file and banner file if exists
|
||||
const userOptions = (await db`
|
||||
select avatar_file, banner_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
let userOptions = null;
|
||||
try {
|
||||
userOptions = (await db`
|
||||
select avatar_file, banner_file, banner_position, banner_size from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
} catch (err) {
|
||||
userOptions = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
}
|
||||
|
||||
// Get full user info
|
||||
const user = (await db`
|
||||
|
||||
@@ -34,7 +34,7 @@ export default (router, tpl) => {
|
||||
const subs = await db`
|
||||
SELECT
|
||||
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
|
||||
JOIN items i ON s.item_id = i.id
|
||||
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 => ({
|
||||
id: i.id,
|
||||
slug: i.slug || null,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
@@ -108,7 +109,7 @@ export default (router, tpl) => {
|
||||
const subs = await db`
|
||||
SELECT
|
||||
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
|
||||
JOIN items i ON s.item_id = i.id
|
||||
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 => ({
|
||||
id: i.id,
|
||||
slug: i.slug || null,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
|
||||
@@ -31,7 +31,10 @@ export async function regenerateTagImage(tag, mode) {
|
||||
JOIN tags_assign ta ON ta.item_id = i.id
|
||||
JOIN tags t ON t.id = ta.tag_id
|
||||
${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 i.is_deleted = false
|
||||
AND COALESCE(i.visibility, 0) = 0
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -9,7 +9,24 @@ export default (router, tpl) => {
|
||||
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
|
||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||
const isGuest = !sessionObj;
|
||||
const modequery = lib.getMode(mode);
|
||||
let baseMode = lib.getMode(mode);
|
||||
if (isGuest) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
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 and tag_id in (1, 2, ${nsflId})))`
|
||||
: "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 and tag_id in (1, 2, ${nsflId})))`
|
||||
: "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``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
@@ -35,6 +52,8 @@ export default (router, tpl) => {
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE items.active = true
|
||||
AND items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
@@ -58,6 +77,8 @@ export default (router, tpl) => {
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||
AND items.active = true
|
||||
AND items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
${userExcludeFilter}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default (router, tpl) => {
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: req.params.page,
|
||||
mode: req.mode,
|
||||
session: !!req.session,
|
||||
session: req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
@@ -138,7 +138,7 @@ export default (router, tpl) => {
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: req.params.itemid,
|
||||
mode: req.mode,
|
||||
session: !!req.session,
|
||||
session: req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
@@ -260,7 +260,7 @@ export default (router, tpl) => {
|
||||
FROM items i
|
||||
JOIN user_halls_assign uha ON uha.item_id = i.id
|
||||
${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()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -9,7 +9,24 @@ export default (router, tpl) => {
|
||||
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
|
||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||
const isGuest = !sessionObj;
|
||||
const modequery = lib.getMode(mode);
|
||||
let baseMode = lib.getMode(mode);
|
||||
if (isGuest) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
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 and tag_id in (1, 2, ${nsflId})))`
|
||||
: "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 and tag_id in (1, 2, ${nsflId})))`
|
||||
: "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``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
@@ -35,6 +52,8 @@ export default (router, tpl) => {
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE items.active = true
|
||||
AND items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ta.user_id = ${userId}
|
||||
AND ${db.unsafe(modequery)}
|
||||
@@ -59,6 +78,8 @@ export default (router, tpl) => {
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||
AND items.active = true
|
||||
AND items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND ta.user_id = ${userId}
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
|
||||
@@ -26,14 +26,17 @@ export default new class {
|
||||
* @returns {string}
|
||||
*/
|
||||
getRealIP(req) {
|
||||
let ip = req.headers['cf-connecting-ip'] ||
|
||||
let rawIp = req.headers['cf-connecting-ip'] ||
|
||||
req.headers['true-client-ip'] ||
|
||||
req.headers['x-client-ip'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
||||
req.socket.remoteAddress;
|
||||
req.headers['x-forwarded-for'] ||
|
||||
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
|
||||
if (ip === "::1") ip = "127.0.0.1";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import cfg from "./config.mjs";
|
||||
import db from "./sql.mjs";
|
||||
import lib from "./lib.mjs";
|
||||
|
||||
let manual_approval = true;
|
||||
let min_tags = 3;
|
||||
@@ -14,9 +16,37 @@ let enable_pdf = false;
|
||||
let enable_cleanup = false;
|
||||
let cleanup_start_date = '';
|
||||
let cleanup_end_date = '';
|
||||
let cleanup_include_engaged = false;
|
||||
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
|
||||
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 = () => {
|
||||
if (cfg.websrv.enable_cleanup === false) return false;
|
||||
return enable_cleanup;
|
||||
@@ -29,6 +59,9 @@ export const setCleanupStartDate = (val) => cleanup_start_date = val || '';
|
||||
export const getCleanupEndDate = () => cleanup_end_date;
|
||||
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 setEnablePdf = (val) => enable_pdf = !!val;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where id >= ${randomId} and active = true
|
||||
where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
@@ -43,7 +43,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where active = true
|
||||
where active = true and coalesce(visibility, 0) = 0
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
@@ -54,7 +54,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where
|
||||
where active = true and coalesce(visibility, 0) = 0 and
|
||||
${args.map(a => a.charAt(0) === "!"
|
||||
? db`username not ilike ${a.slice(1)}`
|
||||
: db`username ilike ${a}`
|
||||
|
||||
@@ -752,8 +752,9 @@ export default async bot => {
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
active: !getManualApproval(),
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
@@ -858,8 +859,9 @@ export default async bot => {
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
active: !getManualApproval(),
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
|
||||
161
src/index.mjs
161
src/index.mjs
@@ -20,9 +20,11 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_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 { createI18n } from "./inc/i18n.mjs";
|
||||
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
||||
|
||||
import security from "./inc/security.mjs";
|
||||
|
||||
import { createRequire } from 'module';
|
||||
@@ -432,6 +434,31 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for database & schema initialization to complete (auto-bootstrap if tables are missing)
|
||||
let dbReady = false;
|
||||
let dbAttempts = 0;
|
||||
while (!dbReady && dbAttempts < 30) {
|
||||
try {
|
||||
dbAttempts++;
|
||||
const tableCheck = await db`SELECT 1 FROM information_schema.tables WHERE table_name = 'items' LIMIT 1`;
|
||||
if (tableCheck.length === 0) {
|
||||
console.log('[BOOT] Database tables missing! Initializing schema from migrations/f0ckm_schema.sql...');
|
||||
await db.file('./migrations/f0ckm_schema.sql');
|
||||
console.log('[BOOT] Database schema initialization complete!');
|
||||
}
|
||||
dbReady = true;
|
||||
} catch (e) {
|
||||
if (dbAttempts === 1) {
|
||||
console.log(`[BOOT] Waiting for database connection... (${e.message})`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
if (dbReady) {
|
||||
console.log(`[BOOT] Database schema ready (took ${dbAttempts} attempt(s))`);
|
||||
} else {
|
||||
console.error('[BOOT] Database schema check timed out after 30 attempts!');
|
||||
}
|
||||
const timeout = 10000; // 10s module load timeout
|
||||
|
||||
// Ensure clients are resolved (Safe-guard for Docker/Node versions)
|
||||
@@ -467,9 +494,27 @@ process.on('uncaughtException', err => {
|
||||
self._trigger.set(trigger.name, new self.trigger(trigger));
|
||||
});
|
||||
|
||||
// Auto-migrate schema: ensure user_options & items columns exist
|
||||
const runMigration = async (query) => {
|
||||
try { await query; } catch (e) { /* ignore if table doesn't exist yet */ }
|
||||
};
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_repeat character varying(50) DEFAULT 'repeat'`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`);
|
||||
|
||||
// Initial halls cache (only if halls are enabled)
|
||||
if (cfg.websrv.halls_enabled !== false) {
|
||||
await updateHallsCache();
|
||||
try {
|
||||
await updateHallsCache();
|
||||
} catch (e) {
|
||||
// ignore during early init
|
||||
}
|
||||
}
|
||||
|
||||
// Log feature flags
|
||||
@@ -516,6 +561,37 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -672,7 +748,7 @@ process.on('uncaughtException', err => {
|
||||
user = [_cachedRow];
|
||||
} else {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||
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".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "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"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
@@ -875,13 +951,28 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
});
|
||||
|
||||
// CSRF validation helper — used by route handlers that have already populated req.session
|
||||
// NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers
|
||||
// because flummpress runs ALL middlewares in parallel (Promise.all), so the session
|
||||
// middleware hasn't finished by the time these run. Those handlers validate CSRF inline.
|
||||
const validateCsrf = (req, res) => {
|
||||
// Intercept app.readBody to allow memoized reading of request body.
|
||||
// flummpress runs app.use() in parallel via Promise.all before routing/body-reading,
|
||||
// so validateCsrf needs to be able to read req.post without breaking subsequent router handler body parsing.
|
||||
const _originalReadBody = app.readBody.bind(app);
|
||||
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) {
|
||||
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) {
|
||||
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' }));
|
||||
@@ -903,7 +994,7 @@ process.on('uncaughtException', err => {
|
||||
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
|
||||
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
|
||||
@@ -1108,11 +1199,11 @@ process.on('uncaughtException', err => {
|
||||
let gitHash = process.env.GIT_HASH || 'unknown';
|
||||
if (gitHash === 'unknown') {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
||||
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
gitHash = `${branch}-${hash}`;
|
||||
} catch (e) {
|
||||
// console.warn('Could not get git hash:', e.message);
|
||||
// Not a git repository
|
||||
}
|
||||
}
|
||||
console.log('Git hash:', gitHash);
|
||||
@@ -1224,6 +1315,12 @@ process.on('uncaughtException', err => {
|
||||
setCleanupEndDate(endSetting[0].value);
|
||||
}
|
||||
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) {
|
||||
console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message);
|
||||
setEnableCleanup(!!cfg.websrv.enable_cleanup);
|
||||
@@ -1309,6 +1406,9 @@ process.on('uncaughtException', err => {
|
||||
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Ensure all items in database have a unique slug backfilled
|
||||
ensureAllItemsHaveSlugs();
|
||||
|
||||
const globals = {
|
||||
lul: cfg.websrv.lul,
|
||||
themes: cfg.websrv.themes,
|
||||
@@ -1362,10 +1462,16 @@ process.on('uncaughtException', err => {
|
||||
default_font: cfg.websrv.default_font || "",
|
||||
site_description: cfg.websrv.description || "The webs dumpster",
|
||||
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,
|
||||
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 || []),
|
||||
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 dm_attachments() { return getDmAttachments(); },
|
||||
get dm_unencrypted() { return getDmUnencrypted(); },
|
||||
@@ -1374,6 +1480,7 @@ process.on('uncaughtException', err => {
|
||||
get enable_cleanup() { return getEnableCleanup(); },
|
||||
get cleanup_start_date() { return getCleanupStartDate(); },
|
||||
get cleanup_end_date() { return getCleanupEndDate(); },
|
||||
get cleanup_include_engaged() { return getCleanupIncludeEngaged(); },
|
||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
|
||||
ts: Date.now(),
|
||||
@@ -1482,23 +1589,18 @@ process.on('uncaughtException', err => {
|
||||
globals.recaptcha_enabled = perRequestRecaptcha;
|
||||
|
||||
// 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 = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
|
||||
? activeReq.session.use_alternative_infobox
|
||||
: (activeReq && !activeReq.session
|
||||
? false
|
||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||
? data.user_alternative_infobox
|
||||
: (cfg.websrv.user_alternative_infobox !== false)));
|
||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||
? data.user_alternative_infobox
|
||||
: (cfg.websrv.user_alternative_infobox !== false));
|
||||
|
||||
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
|
||||
? activeReq.session.use_alternative_steuerung
|
||||
: (activeReq && !activeReq.session
|
||||
? false
|
||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||
? data.user_alternative_steuerung
|
||||
: (cfg.websrv.user_alternative_steuerung !== false)));
|
||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||
? data.user_alternative_steuerung
|
||||
: (cfg.websrv.user_alternative_steuerung !== false));
|
||||
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
@@ -1507,6 +1609,8 @@ process.on('uncaughtException', err => {
|
||||
user_alternative_infobox: useAltInfobox,
|
||||
user_alternative_steuerung: useAltSteuerung,
|
||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||
comment_banner_enabled: (cfg.websrv.user_banner_enabled !== false) && (cfg.websrv.comment_banner_enabled !== false),
|
||||
comment_banner_max_height: typeof cfg.websrv.comment_banner_max_height === 'number' ? cfg.websrv.comment_banner_max_height : 300,
|
||||
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
|
||||
? activeReq.session.comment_display_mode
|
||||
: (data && typeof data.comment_display_mode === 'number'
|
||||
@@ -1527,7 +1631,8 @@ process.on('uncaughtException', err => {
|
||||
if (!data.url) data.url = activeReq.url;
|
||||
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
|
||||
data.user_logged_in_bool = !!activeReq.session;
|
||||
data.csrf_token = activeReq.session?.csrf_token || '';
|
||||
data.csrf_token = activeReq.session?.csrf_token || data.csrf_token || '';
|
||||
globals.csrf_token = data.csrf_token;
|
||||
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
||||
@@ -1536,6 +1641,7 @@ process.on('uncaughtException', err => {
|
||||
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
||||
data.user_strict_bool = false;
|
||||
data.user_logged_in_bool = false;
|
||||
globals.csrf_token = data.csrf_token || '';
|
||||
}
|
||||
|
||||
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
|
||||
@@ -1568,6 +1674,11 @@ process.on('uncaughtException', err => {
|
||||
setTimeout(cleanupStaleSessions, 30_000);
|
||||
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
|
||||
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
|
||||
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');
|
||||
}
|
||||
|
||||
await fs.mkdir(cfg.paths.koepfe, { recursive: true });
|
||||
const newName = crypto.randomBytes(8).toString('hex') + ext;
|
||||
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 path from "path";
|
||||
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 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.
|
||||
// Adding a new archive type to config.json is sufficient; no code change needed.
|
||||
@@ -19,6 +21,26 @@ const ARCHIVE_MIMES = new Set(
|
||||
);
|
||||
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||
|
||||
const autoTagsFromUrl = (urlString) => {
|
||||
const tags = [];
|
||||
try {
|
||||
const { hostname } = new URL(urlString);
|
||||
const host = hostname.replace(/:\d+$/, '').toLowerCase();
|
||||
const parts = host.split('.');
|
||||
const shortSlds = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne']);
|
||||
let domain;
|
||||
if (parts.length >= 3 && shortSlds.has(parts[parts.length - 2])) {
|
||||
domain = parts.slice(-3).join('.');
|
||||
} else {
|
||||
domain = parts.slice(-2).join('.');
|
||||
}
|
||||
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
|
||||
tags.push('youtube');
|
||||
}
|
||||
} catch (e) {}
|
||||
return [...new Set(tags)];
|
||||
};
|
||||
|
||||
// Helper for JSON response
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
@@ -34,6 +56,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
|
||||
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 expires_at bigint DEFAULT NULL`.catch(() => {});
|
||||
|
||||
|
||||
// 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)
|
||||
@@ -43,6 +67,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 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) => {
|
||||
// Manual session lookup is required here because this handler is called from a
|
||||
// bypass middleware that runs in parallel with the main session middleware.
|
||||
@@ -101,15 +129,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
|
||||
// Robust boundary extraction (handles both quoted and unquoted boundaries)
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
|
||||
if (!contentType || !contentType.includes('multipart/form-data') || !boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
let parts = {};
|
||||
|
||||
// Determine max file size early for collectBody
|
||||
let effectiveMaxBytes = cfg.main.maxfilesize || (150 * 1024 * 1024);
|
||||
@@ -124,31 +144,106 @@ export const handleUpload = async (req, res, self) => {
|
||||
throw bodyErr;
|
||||
}
|
||||
|
||||
const parts = parseMultipart(body, boundary);
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid multipart boundary' }, 400);
|
||||
}
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
parts = parseMultipart(body, boundary);
|
||||
} else if (contentType.includes('application/json')) {
|
||||
try {
|
||||
parts = JSON.parse(body.toString());
|
||||
} catch (e) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const params = new URLSearchParams(body.toString());
|
||||
for (const [key, value] of params.entries()) {
|
||||
parts[key] = value;
|
||||
}
|
||||
} else {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const file = parts.file;
|
||||
let file = (typeof parts.file === 'object' && parts.file !== null && parts.file.data) ? parts.file : null;
|
||||
let inputUrl = (typeof parts.url === 'string' && parts.url.trim()) ? parts.url.trim() : null;
|
||||
|
||||
if (!inputUrl && typeof parts.file === 'string' && /^https?:\/\//i.test(parts.file.trim())) {
|
||||
inputUrl = parts.file.trim();
|
||||
}
|
||||
|
||||
if (inputUrl) {
|
||||
file = null;
|
||||
try {
|
||||
const parsed = new URL(inputUrl);
|
||||
if (parsed.searchParams.has('igsh')) {
|
||||
parsed.searchParams.delete('igsh');
|
||||
inputUrl = parsed.toString();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const rating = parts.rating;
|
||||
const tagsRaw = parts.tags;
|
||||
const comment = parts.comment ? parts.comment.trim() : '';
|
||||
const rawTitle = parts.title ? parts.title.trim() : '';
|
||||
const comment = parts.comment ? String(parts.comment).trim() : '';
|
||||
const rawTitle = parts.title ? String(parts.title).trim() : '';
|
||||
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
|
||||
|
||||
const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1');
|
||||
const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
|
||||
const is_shitpost = (parts.is_shitpost === true || 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;
|
||||
if (!file && !inputUrl) {
|
||||
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
|
||||
}
|
||||
|
||||
if (inputUrl && cfg.websrv.web_url_upload === false) {
|
||||
return sendJson(res, { success: false, msg: 'URL uploads are disabled' }, 403);
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
|
||||
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
|
||||
}
|
||||
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged).
|
||||
// If shitpost_require_rating is configured to true, a rating is strictly required.
|
||||
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null;
|
||||
|
||||
if (!is_shitpost && !effectiveRating) {
|
||||
@@ -163,20 +258,20 @@ export const handleUpload = async (req, res, self) => {
|
||||
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
|
||||
}
|
||||
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||
const userTags = tagsRaw ? String(tagsRaw).split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||
const autoTags = (inputUrl && !file) ? autoTagsFromUrl(inputUrl) : [];
|
||||
const tags = [...new Set([...userTags, ...autoTags])];
|
||||
|
||||
const minTags = getMinTags();
|
||||
// In shitpost mode, tags are optional by default — unless shitpost_min_tags is configured.
|
||||
const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0;
|
||||
if (!is_shitpost && minTags > 0 && tags.length < minTags) {
|
||||
if (!is_shitpost && minTags > 0 && userTags.length < minTags) {
|
||||
return sendJson(res, { success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
|
||||
}
|
||||
if (is_shitpost && shitpostMinTags > 0 && tags.length < shitpostMinTags) {
|
||||
if (is_shitpost && shitpostMinTags > 0 && userTags.length < shitpostMinTags) {
|
||||
return sendJson(res, { success: false, msg: `At least ${shitpostMinTags} tag${shitpostMinTags !== 1 ? 's' : ''} required` }, 400);
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
// cfg.allowedMimes entries can be category prefixes ("image", "video", "audio")
|
||||
// OR exact MIME types ("application/pdf"). Entries with "/" are matched exactly.
|
||||
// Validate MIME type for attached file
|
||||
const allowedCats = Array.isArray(cfg.allowedMimes)
|
||||
? cfg.allowedMimes.map(c => c.toLowerCase())
|
||||
: null;
|
||||
@@ -187,27 +282,19 @@ export const handleUpload = async (req, res, self) => {
|
||||
)
|
||||
)
|
||||
: Object.keys(cfg.mimes);
|
||||
let mime = file.contentType;
|
||||
|
||||
// Browsers often don't know the SWF MIME type and send application/octet-stream or nothing.
|
||||
// Normalize it here based on extension so the allowedMimes check doesn't spuriously reject.
|
||||
// The server-side `file --mime-type` check on line ~248 is the authoritative validation.
|
||||
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
|
||||
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
|
||||
mime = 'application/x-shockwave-flash';
|
||||
if (file && !inputUrl) {
|
||||
let mime = file.contentType;
|
||||
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
|
||||
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
|
||||
mime = 'application/x-shockwave-flash';
|
||||
}
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||
}
|
||||
|
||||
// Size was already validated by collectBody (effectiveMaxBytes)
|
||||
const size = file.data.length;
|
||||
|
||||
let manualApproval = getManualApproval();
|
||||
|
||||
// Enforce manual approval for untrusted users (configurable threshold)
|
||||
// Admins and moderators are exempt from this check
|
||||
const trustedThreshold = getTrustedUploads();
|
||||
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
|
||||
try {
|
||||
@@ -218,18 +305,13 @@ export const handleUpload = async (req, res, self) => {
|
||||
AND is_deleted = false
|
||||
`;
|
||||
if (parseInt(totalUploads[0].count) < trustedThreshold) {
|
||||
console.log(`[UPLOAD] Forcing manual approval for new user: ${req.session.user} (Upload count: ${totalUploads[0].count}/${trustedThreshold})`);
|
||||
manualApproval = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Failed to check total upload count:', err);
|
||||
// Default to manual approval on error for safety if we are unsure
|
||||
manualApproval = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Rate Limit Check (if manual approval is disabled)
|
||||
// Admins and moderators are exempt from rate limiting
|
||||
if (!manualApproval && !req.session.admin && !req.session.is_moderator) {
|
||||
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
|
||||
const uploadCount = await db`
|
||||
@@ -248,6 +330,130 @@ export const handleUpload = async (req, res, self) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for YouTube Embeds when url is provided without a file attachment
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
const ytMatch = (!file && inputUrl) ? inputUrl.match(ytRegex) : null;
|
||||
|
||||
if (ytMatch && cfg.websrv.enable_youtube_upload !== false) {
|
||||
const videoId = ytMatch[1];
|
||||
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
size: 0,
|
||||
checksum: `yt_${videoId}_${Date.now()}`,
|
||||
phash: null,
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
active: !manualApproval,
|
||||
is_oc: !!is_oc,
|
||||
title: 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
|
||||
`;
|
||||
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) {}
|
||||
|
||||
try {
|
||||
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval);
|
||||
} catch (err) {
|
||||
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
if (effectiveRating) {
|
||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
|
||||
await queue.genBlurredThumbnail(itemid, manualApproval).catch(() => {});
|
||||
}
|
||||
|
||||
for (const tagName of tags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`insert into tags ${db({ tag: tagName }, 'tag')}`;
|
||||
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
}
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
|
||||
}
|
||||
|
||||
if (comment && comment.length > 0) {
|
||||
try {
|
||||
const filteredComment = await applyWordFilter(comment);
|
||||
await db`INSERT INTO comments ${db({ item_id: itemid, user_id: req.session.id, content: filteredComment })}`;
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (is_oc) {
|
||||
const ocTags = ['oc', 'original content'];
|
||||
for (const ocName of ocTags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`insert into tags ${db({ tag: ocName }, 'tag')}`;
|
||||
tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
|
||||
}
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!manualApproval) {
|
||||
f0cklib.clearCountCache();
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] YouTube new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
|
||||
const successMsg = manualApproval ? 'Upload successful! Your upload is pending admin approval.' : 'Upload successful! Your upload is now live.';
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
msg: successMsg,
|
||||
itemid: itemid,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility,
|
||||
manual_approval: manualApproval,
|
||||
redirect: !manualApproval ? itemRoute : null,
|
||||
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
|
||||
file_url: null,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
display_name: req.session.display_name || null,
|
||||
username: req.session.user
|
||||
});
|
||||
}
|
||||
|
||||
// Check repost link if uploading via URL (non-YouTube)
|
||||
if (!file && inputUrl && !getBypassDuplicateCheck()) {
|
||||
const repostLink = await queue.checkrepostlink(inputUrl);
|
||||
if (repostLink) {
|
||||
return sendJson(res, { success: false, msg: 'This URL has already been uploaded', repost: repostLink }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate UUID & Base Paths
|
||||
const uuid = await queue.genuuid();
|
||||
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
@@ -258,8 +464,87 @@ export const handleUpload = async (req, res, self) => {
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 't'), { recursive: true });
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 'ca'), { recursive: true });
|
||||
|
||||
// Save temporarily to detect actual MIME
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
if (file && file.data) {
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
} else if (inputUrl) {
|
||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
||||
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors'];
|
||||
const isInstagram = /instagram\.com/i.test(inputUrl);
|
||||
let downloadedFile = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await queue.spawn('yt-dlp', [
|
||||
...proxyArgs, ...ytdlpArgs,
|
||||
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
|
||||
inputUrl,
|
||||
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
|
||||
'--postprocessor-args', 'ffmpeg:-bitexact',
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath',
|
||||
'--merge-output-format', 'mp4'
|
||||
], { quiet: true });
|
||||
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err1) {
|
||||
if (isInstagram) {
|
||||
return sendJson(res, { success: false, msg: 'Failed to download Instagram URL' }, 400);
|
||||
}
|
||||
try {
|
||||
const { stdout } = await queue.spawn('yt-dlp', [
|
||||
...proxyArgs, ...ytdlpArgs,
|
||||
inputUrl,
|
||||
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath'
|
||||
], { quiet: true });
|
||||
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err2) {
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.curl_tmp`);
|
||||
let referer = inputUrl;
|
||||
try {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
let host = parsedUrl.hostname;
|
||||
if (host.includes('imgur.com')) host = 'imgur.com';
|
||||
referer = `${parsedUrl.protocol}//${host}/`;
|
||||
} catch (e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-S', '-f', '-L', inputUrl, '-o', fallbackTmp,
|
||||
'--max-filesize', `${effectiveMaxBytes}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'--referer', referer,
|
||||
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'-H', 'Accept-Language: en-US,en;q=0.9'
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
try {
|
||||
await queue.spawn('curl', curlArgs);
|
||||
downloadedFile = fallbackTmp;
|
||||
} catch (curlErr) {
|
||||
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exists = false;
|
||||
if (downloadedFile) {
|
||||
try {
|
||||
await fs.access(downloadedFile);
|
||||
exists = true;
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!exists) {
|
||||
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
|
||||
}
|
||||
|
||||
if (downloadedFile !== tmpPath) {
|
||||
await fs.rename(downloadedFile, tmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify actual MIME (second check after file-command detection)
|
||||
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||
@@ -335,7 +620,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
// 4. Audio-only MP4 reclassification probe (only when needed)
|
||||
needsAudioMP4
|
||||
? (async () => {
|
||||
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||
const origExt = file?.filename ? file.filename.split('.').pop().toLowerCase() : (inputUrl ? inputUrl.split('?')[0].split('.').pop().toLowerCase() : '');
|
||||
if (['m4a', 'aac'].includes(origExt)) {
|
||||
return 'audio/mp4'; // Extension-based reclassification
|
||||
}
|
||||
@@ -438,10 +723,18 @@ export const handleUpload = async (req, res, self) => {
|
||||
|
||||
|
||||
// Insert
|
||||
const originalFilename = file.filename || null;
|
||||
const size = file?.data ? file.data.length : (await fs.stat(destPath).then(s => s.size).catch(() => 0));
|
||||
let originalFilename = file ? (file.filename || null) : null;
|
||||
if (!originalFilename && inputUrl) {
|
||||
try {
|
||||
const parsedName = path.basename(new URL(inputUrl).pathname);
|
||||
if (parsedName && parsedName !== '/') originalFilename = parsedName;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
await db`
|
||||
insert into items ${db({
|
||||
src: '',
|
||||
src: inputUrl || '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: size,
|
||||
@@ -450,14 +743,17 @@ export const handleUpload = async (req, res, self) => {
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
stamp: nowStamp,
|
||||
active: !manualApproval,
|
||||
is_oc: is_oc,
|
||||
original_filename: originalFilename,
|
||||
title: title,
|
||||
width: itemWidth,
|
||||
height: itemHeight
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')}
|
||||
height: itemHeight,
|
||||
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);
|
||||
@@ -584,7 +880,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
}
|
||||
|
||||
// Auto-tag SWF uploads with "Flash" and "SWF"
|
||||
if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
const swfTags = ['Flash', 'SWF'];
|
||||
for (const tagname of swfTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
@@ -678,29 +974,31 @@ export const handleUpload = async (req, res, self) => {
|
||||
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
||||
|
||||
|
||||
// Discord Webhook
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
|
||||
const payload = JSON.stringify({ content: message });
|
||||
const url = new URL(discordClient.webhook_url);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
const reqDiscord = https.request(options, (resDiscord) => { });
|
||||
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
// Discord Webhook (only for public uploads)
|
||||
if (targetVisibility === 0) {
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
|
||||
const payload = JSON.stringify({ content: message });
|
||||
const url = new URL(discordClient.webhook_url);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
const reqDiscord = https.request(options, (resDiscord) => { });
|
||||
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
@@ -713,27 +1011,31 @@ export const handleUpload = async (req, res, self) => {
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||
const clients = await Promise.all(self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
|
||||
// Push to Matrix Channel (only if auto-approved and public)
|
||||
if (!manualApproval && targetVisibility === 0) {
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||
const clients = await Promise.all(self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Matrix notification error:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Staff Notifications
|
||||
@@ -768,13 +1070,16 @@ export const handleUpload = async (req, res, self) => {
|
||||
: 'Upload successful! Your upload is now live.';
|
||||
|
||||
const imagesPath = cfg.websrv.paths?.images || '/b';
|
||||
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
msg: successMsg,
|
||||
itemid: itemid,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility,
|
||||
manual_approval: manualApproval,
|
||||
redirect: !manualApproval ? `/${itemid}` : null,
|
||||
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`,
|
||||
redirect: !manualApproval ? itemRoute : null,
|
||||
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
|
||||
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
|
||||
// Fields for immediate client-side grid injection (avoids SSE race condition)
|
||||
dest: filename,
|
||||
|
||||
@@ -117,22 +117,34 @@
|
||||
status.style.color = 'var(--accent)';
|
||||
|
||||
try {
|
||||
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
|
||||
if (!csrfToken || csrfToken.startsWith('{')) {
|
||||
status.textContent = 'CSRF token missing. Please refresh the page.';
|
||||
status.style.color = '#d9534f';
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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({
|
||||
manual_approval: approvalToggle.checked ? 'on' : 'off',
|
||||
manual_approval: approvalToggle ? (approvalToggle.checked ? 'on' : 'off') : 'off',
|
||||
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
|
||||
min_tags: minTagsInput.value,
|
||||
trusted_uploads: trustedUploadsInput.value,
|
||||
csrf_token: '{{ csrf_token }}'
|
||||
min_tags: minTagsInput ? minTagsInput.value : '3',
|
||||
trusted_uploads: trustedUploadsInput ? trustedUploadsInput.value : '3',
|
||||
csrf_token: csrfToken
|
||||
}).toString()
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error('CSRF validation failed or session expired. Please reload the page.');
|
||||
}
|
||||
throw new Error('Server returned ' + res.status);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
</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;">
|
||||
<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>
|
||||
@@ -101,8 +109,13 @@
|
||||
async function runCleanup() {
|
||||
const btn = document.getElementById('run-cleanup-btn');
|
||||
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.textContent = 'CLEANING UP...';
|
||||
@@ -110,12 +123,17 @@
|
||||
status.style.color = 'var(--accent)';
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('include_engaged', includeEngaged ? 'true' : 'false');
|
||||
|
||||
const res = await fetch('/admin/cleanup/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || '{{ csrf_token }}'
|
||||
}
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const cleanup_response = await res.json();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@include(snippets/page-title)
|
||||
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
||||
@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">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<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>
|
||||
@if(enable_item_title)
|
||||
@@ -102,7 +102,7 @@
|
||||
<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>
|
||||
</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 class="user-infobox-body">
|
||||
<div class="user-infobox-description">
|
||||
@@ -111,9 +111,9 @@
|
||||
<div class="user-infobox-actions">
|
||||
@if(session)
|
||||
@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
|
||||
<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
|
||||
<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">
|
||||
|
||||
<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)
|
||||
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
@endif
|
||||
</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)
|
||||
<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
|
||||
@@ -143,9 +145,9 @@
|
||||
@if(session)
|
||||
@if(!user_alternative_infobox)
|
||||
@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
|
||||
<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>
|
||||
@@ -175,7 +177,7 @@
|
||||
<span class="tags-inner">
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span tooltip="{!! tag.display_name || tag.user !!}" class="badge {{ tag.badge }}">
|
||||
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
@@ -190,14 +192,14 @@
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
@if(can_manage_item)
|
||||
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</span>
|
||||
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge)
|
||||
@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>
|
||||
@@ -234,79 +236,45 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div id="info-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-body" style="padding: 20px 0; text-align: left;">
|
||||
<table class="info-table">
|
||||
@if(enable_item_title && can_manage_item)
|
||||
<tr class="info-title-row">
|
||||
<th>Title</th>
|
||||
<td>
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
</td>
|
||||
</tr>
|
||||
@elseif(enable_item_title && item.title)
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{!! item.title !!}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
</tr>
|
||||
@if(item_has_dimensions)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
|
||||
<td>{{ item.width }} × {{ item.height }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
|
||||
<td><code>{{ item.mime }}</code></td>
|
||||
</tr>
|
||||
@if(item.checksum)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
|
||||
<tr class="info-repost-row">
|
||||
<th>Repost</th>
|
||||
<td>
|
||||
@each(item.reposts as rp)
|
||||
@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>
|
||||
@else
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
|
||||
<td>
|
||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
|
||||
{{ t('info_modal.view_file') || 'View File' }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@if(item.src.short)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
@include(snippets/info-modal)
|
||||
|
||||
|
||||
<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-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
<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>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
@if(can_manage_item)
|
||||
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@@ -42,7 +42,7 @@
|
||||
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span @if(session) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
||||
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<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>
|
||||
@if(enable_item_title)
|
||||
@@ -119,20 +119,25 @@
|
||||
</div>
|
||||
<div class="blahlol">
|
||||
<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
|
||||
@if(halls_enabled && item.primaryHall)
|
||||
<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
|
||||
</span> —
|
||||
@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">
|
||||
@if(session)
|
||||
@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
|
||||
<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
|
||||
<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>
|
||||
@@ -157,7 +162,7 @@
|
||||
</div>
|
||||
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge)
|
||||
@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>
|
||||
@@ -176,79 +181,45 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div id="info-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-body" style="padding: 20px 0; text-align: left;">
|
||||
<table class="info-table">
|
||||
@if(enable_item_title && can_manage_item)
|
||||
<tr class="info-title-row">
|
||||
<th>Title</th>
|
||||
<td>
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
</td>
|
||||
</tr>
|
||||
@elseif(enable_item_title && item.title)
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{!! item.title !!}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
</tr>
|
||||
@if(item_has_dimensions)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
|
||||
<td>{{ item.width }} × {{ item.height }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
|
||||
<td><code>{{ item.mime }}</code></td>
|
||||
</tr>
|
||||
@if(item.checksum)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
|
||||
<tr class="info-repost-row">
|
||||
<th>Repost</th>
|
||||
<td>
|
||||
@each(item.reposts as rp)
|
||||
@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>
|
||||
@else
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
|
||||
<td>
|
||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
|
||||
{{ t('info_modal.view_file') || 'View File' }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@if(item.src.short)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
@include(snippets/info-modal)
|
||||
|
||||
|
||||
<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-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
<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>
|
||||
@@ -94,7 +94,7 @@
|
||||
<tbody>
|
||||
@each(favotop as favo)
|
||||
<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>
|
||||
</tr>
|
||||
@endeach
|
||||
@@ -109,7 +109,7 @@
|
||||
<tbody>
|
||||
@each(xdtop as item)
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
<div class="settings">
|
||||
<h1>{{ t('settings.title') }}</h1>
|
||||
<h1><i class="fa-solid fa-sliders"></i> {{ t('settings.title') }}</h1>
|
||||
|
||||
<!-- Quick navigation -->
|
||||
<nav id="settings-quicknav" aria-label="Settings sections">
|
||||
<a href="#profile">{{ t('settings.profile') }}</a>
|
||||
<a href="#preferences">{{ t('settings.preferences') }}</a>
|
||||
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
|
||||
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
|
||||
@if(enable_data_export)
|
||||
<a href="#export">{{ t('settings.export_data_title') }}</a>
|
||||
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
|
||||
@endif
|
||||
<a href="#account">{{ t('settings.account') }}</a>
|
||||
<a href="#account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</a>
|
||||
@if(matrix_enabled || telegram_enabled)
|
||||
<a href="#linked">{{ t('settings.linked_accounts') }}</a>
|
||||
<a href="#linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</a>
|
||||
@endif
|
||||
@if(enable_user_api_keys)
|
||||
<a href="#apikey">API Key</a>
|
||||
<a href="#apikey"><i class="fa-solid fa-key"></i> API Key</a>
|
||||
@endif
|
||||
@if(enable_user_invites)
|
||||
<a href="#invites">{{ t('invites.section_title') }}</a>
|
||||
<a href="#invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</a>
|
||||
@endif
|
||||
</nav>
|
||||
<style>
|
||||
@@ -75,404 +75,141 @@
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
|
||||
<h2 id="profile">{{ t('settings.profile') }}</h2>
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.avatar') }}</legend>
|
||||
<div class="avatar-settings-wrapper">
|
||||
<div class="avatar-preview-container">
|
||||
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
|
||||
@if(avatar_file)
|
||||
<a href="/user/{!! session.user !!}" target="_blank">
|
||||
<img id="avatar-preview" class="avatar-preview-img" src="/a/{{ avatar_file }}">
|
||||
</a>
|
||||
@elseif(session.avatar && session.avatar > 0)
|
||||
<a href="/user/{!! session.user !!}" target="_blank">
|
||||
<img id="avatar-preview" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp">
|
||||
</a>
|
||||
@else
|
||||
<div id="avatar-preview" class="avatar-preview-img avatar-placeholder">?</div>
|
||||
@endif
|
||||
</div>
|
||||
<h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
|
||||
|
||||
<p class="text-muted" style="margin-bottom: 16px; font-size: 0.9em; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--accent);"></i> Click directly on your profile card below to edit your avatar, banner, name, color, or bio.
|
||||
</p>
|
||||
|
||||
<div class="avatar-upload-section">
|
||||
<p class="avatar-hint">{{ t('settings.avatar_hint') }}</p>
|
||||
|
||||
<div class="avatar-upload-wrapper">
|
||||
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
|
||||
<button type="button" id="avatar-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
|
||||
<span id="avatar-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-progress-wrapper" id="avatar-progress-wrapper" style="display: none;">
|
||||
<div class="avatar-progress-bar">
|
||||
<div class="avatar-progress-fill" id="avatar-progress-fill"></div>
|
||||
</div>
|
||||
<span class="avatar-progress-text" id="avatar-progress-text">0%</span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-upload-actions">
|
||||
<button type="button" id="avatar-upload-btn" class="button" disabled>{{ t('settings.upload_btn') }}</button>
|
||||
<div class="direct-edit-profile-card-wrapper" style="position: relative; max-width: 750px; margin-bottom: 30px;">
|
||||
|
||||
<!-- Interactive User Card -->
|
||||
<div class="user-infobox-block editable-profile-card" id="live-profile-preview-box"
|
||||
style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
|
||||
|
||||
<!-- Avatar with Hover Overlay -->
|
||||
<div class="user-infobox-avatar editable-avatar-container" id="avatar-choose-btn" title="Click to change avatar">
|
||||
<a href="#" onclick="event.preventDefault(); document.getElementById('avatar-file-input').click();" style="display:block; position:relative; width:100%; height:100%;">
|
||||
@if(avatar_file)
|
||||
<button type="button" id="avatar-remove-btn" class="button button-danger">{{ t('settings.remove_custom') }}</button>
|
||||
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/{{ avatar_file }}" />
|
||||
@elseif(session.avatar && session.avatar > 0)
|
||||
<img id="live-preview-avatar" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp" />
|
||||
@else
|
||||
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/default.png" style="border-color: #444;" />
|
||||
@endif
|
||||
</div>
|
||||
<div id="avatar-upload-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@if(enable_profile_description)
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.custom_description') }}</legend>
|
||||
<div class="setting-item">
|
||||
<textarea id="profile_description" class="input" placeholder="{{ t('settings.description_placeholder') }}"
|
||||
maxlength="2000">{!! session.description || '' !!}</textarea>
|
||||
<div class="profile-settings-actions">
|
||||
<button type="button" id="btn-save-description" class="button">{{ t('settings.save_description') }}</button>
|
||||
<button type="button" id="btn-clear-description" class="button button-danger">{{ t('settings.clear') }}</button>
|
||||
</div>
|
||||
<div id="description-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@endif
|
||||
|
||||
@if(user_banner_enabled)
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Profile Banner</legend>
|
||||
<div class="avatar-settings-wrapper">
|
||||
<div class="avatar-preview-container" style="width: 360px; max-width: 100%;">
|
||||
<div class="avatar-preview-label">{{ t('settings.current_banner') }}</div>
|
||||
@if(banner_file)
|
||||
<a href="/user/{!! session.user !!}" target="_blank" style="display:block; width: 100%;">
|
||||
<div id="banner-preview" class="banner-preview-img" style="width:100%;aspect-ratio:400/140;border-radius:4px;border:1px solid var(--nav-border-color);background-image:url('/a/{{ banner_file }}');background-size:{{ banner_size || 'cover' }};background-position:{{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }};background-repeat:no-repeat;"></div>
|
||||
<div class="avatar-hover-overlay">
|
||||
<i class="fa-solid fa-camera"></i>
|
||||
</div>
|
||||
</a>
|
||||
@else
|
||||
<div id="banner-preview" class="banner-preview-img banner-placeholder" style="width:100%;aspect-ratio:400/140;display:flex;align-items:center;justify-content:center;border:1px dashed var(--nav-border-color);border-radius:4px;color:var(--text-muted);font-size:0.85em;">{{ t('settings.no_banner_set') }}</div>
|
||||
@if(avatar_file)
|
||||
<button type="button" class="avatar-quick-remove" id="avatar-remove-btn" title="Remove custom avatar">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="avatar-upload-section">
|
||||
<p class="avatar-hint">{{ t('settings.banner_hint') }}</p>
|
||||
<div class="avatar-upload-wrapper">
|
||||
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
|
||||
<button type="button" id="banner-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
|
||||
<span id="banner-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
|
||||
<div class="user-infobox-info">
|
||||
<div class="user-infobox-header">
|
||||
<div class="user-infobox-username-container" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
|
||||
<!-- Editable Display Name -->
|
||||
<div class="inline-edit-username-wrapper">
|
||||
<span id="live-preview-username" class="user-infobox-username" contenteditable="true" spellcheck="false" title="Click to edit display name">{!! session.display_name || session.user !!}</span>
|
||||
<i class="fa-solid fa-pen" style="font-size: 0.65em; opacity: 0.5; margin-left: 2px;" title="Editable"></i>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker Badge -->
|
||||
<div class="inline-color-picker-wrapper" style="display: inline-flex; align-items: center;">
|
||||
<label for="username_color_picker" class="color-picker-badge" id="color-picker-badge" style="background: {{ session.username_color || 'var(--accent)' }};" title="Click to change username color">
|
||||
<i class="fa-solid fa-palette"></i>
|
||||
</label>
|
||||
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}" style="position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;">
|
||||
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" style="display: none;">
|
||||
</div>
|
||||
|
||||
<!-- Hidden Backing Save Buttons for JS event trigger -->
|
||||
<button type="button" id="btn-update-display-name" class="inline-save-btn" style="display: none;"><i class="fa-solid fa-check"></i> Save Name</button>
|
||||
<button type="button" id="btn-save-username-color" style="display: none;">Save Color</button>
|
||||
<button type="button" id="btn-reset-username-color" style="display: none;">Reset</button>
|
||||
|
||||
</div>
|
||||
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-progress-wrapper" id="banner-progress-wrapper" style="display: none;">
|
||||
<div class="avatar-progress-bar">
|
||||
<div class="avatar-progress-fill" id="banner-progress-fill"></div>
|
||||
<div class="user-infobox-body">
|
||||
|
||||
<!-- Editable Description -->
|
||||
<div class="inline-edit-description-wrapper">
|
||||
<div class="user-infobox-description" id="live-preview-description" contenteditable="true" spellcheck="false" title="Click to edit description" data-placeholder="Click to write a bio...">
|
||||
{!! session.description || '' !!}
|
||||
</div>
|
||||
<div class="description-edit-controls" id="desc-edit-controls" style="display: flex; margin-top: 10px; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<button type="button" id="btn-save-description" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-floppy-disk"></i> {{ t('settings.save_description') }}</button>
|
||||
<button type="button" id="btn-clear-description" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> {{ t('settings.clear') }}</button>
|
||||
@if(user_banner_enabled)
|
||||
<button type="button" id="banner-choose-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-camera"></i> Change Banner</button>
|
||||
@if(banner_file)
|
||||
<button type="button" id="banner-edit-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-arrows-up-down-left-right"></i> Reposition</button>
|
||||
<button type="button" id="banner-remove-btn" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> Remove Banner</button>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-infobox-actions">
|
||||
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
||||
</div>
|
||||
<span class="avatar-progress-text" id="banner-progress-text">0%</span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-upload-actions">
|
||||
<button type="button" id="banner-upload-btn" class="button" disabled>Upload Banner</button>
|
||||
@if(banner_file)
|
||||
<button type="button" id="banner-edit-btn" class="button">Edit Position</button>
|
||||
<button type="button" id="banner-remove-btn" class="button button-danger">Remove Banner</button>
|
||||
@endif
|
||||
</div>
|
||||
<div id="banner-upload-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@endif
|
||||
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.username_color') }}</legend>
|
||||
<div class="setting-item">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}"
|
||||
style="width: 50px; height: 30px; padding: 0; border: 1px solid var(--nav-border-color); cursor: pointer; background: none;">
|
||||
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" maxlength="7"
|
||||
placeholder="#ffffff" class="input"
|
||||
style="width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 8px; height: 30px;">
|
||||
<button type="button" id="btn-save-username-color" class="button"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save_color') }}</button>
|
||||
<button type="button" id="btn-reset-username-color" class="button button-secondary"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
|
||||
</div>
|
||||
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.display_name') }}</legend>
|
||||
<div class="setting-item">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="text" id="display_name_input" class="input"
|
||||
placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}"
|
||||
maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
|
||||
<button type="button" id="btn-update-display-name" class="button"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save') }}</button>
|
||||
</div>
|
||||
<div id="display-name-status" class="avatar-status"></div>
|
||||
<!-- Hidden Backing Elements for JavaScript compatibility -->
|
||||
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||
<span id="avatar-filename" style="display: none;"></span>
|
||||
<button type="button" id="avatar-upload-btn" style="display: none;"></button>
|
||||
<div id="avatar-progress-wrapper" style="display: none;">
|
||||
<div id="avatar-progress-fill"></div>
|
||||
<span id="avatar-progress-text"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Live Profile Preview</legend>
|
||||
<div class="setting-item" style="max-width: 750px;">
|
||||
<div class="user-infobox-block" id="live-profile-preview-box" style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
|
||||
<div class="user-infobox-avatar">
|
||||
<a href="#">
|
||||
@if(avatar_file)
|
||||
<img id="live-preview-avatar" src="/a/{{ avatar_file }}" />
|
||||
@else
|
||||
<img id="live-preview-avatar" src="/a/default.png" style="border-color: #444;" />
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-infobox-info">
|
||||
<div class="user-infobox-header">
|
||||
<div class="user-infobox-username-container">
|
||||
<a id="live-preview-username" href="#" class="user-infobox-username">{!! session.display_name || session.user !!}</a>
|
||||
</div>
|
||||
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
||||
</div>
|
||||
<div class="user-infobox-body">
|
||||
<div class="user-infobox-description">
|
||||
{!! session.description || 'Welcome to my profile! This is a live preview.' !!}
|
||||
</div>
|
||||
<div class="user-infobox-actions">
|
||||
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||
<span id="banner-filename" style="display: none;"></span>
|
||||
<button type="button" id="banner-upload-btn" style="display: none;"></button>
|
||||
<div id="banner-progress-wrapper" style="display: none;">
|
||||
<div id="banner-progress-fill"></div>
|
||||
<span id="banner-progress-text"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="text" id="display_name_input" value="{!! session.display_name || '' !!}" style="display: none;">
|
||||
@if(enable_profile_description)
|
||||
<textarea id="profile_description" style="display: none;">{!! session.description || '' !!}</textarea>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ -->
|
||||
<h2 id="preferences">{{ t('settings.preferences') }}</h2>
|
||||
<h2 id="preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</h2>
|
||||
<div class="preferences-settings-wrapper">
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.ui_section') }}</legend>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
|
||||
<span>{{ t('settings.show_motd') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout===true) checked @endif>
|
||||
<span>{{ t('settings.modern_layout') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
|
||||
</div>
|
||||
@if(!session.use_new_layout)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_infobox_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="alternative_infobox_toggle" @if(session.use_alternative_infobox===true) checked @endif>
|
||||
<span>{{ t('settings.alternative_infobox') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
@if(!session.use_new_layout)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_steuerung_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="alternative_steuerung_toggle" @if(session.use_alternative_steuerung===true) checked @endif>
|
||||
<span>{{ t('settings.alternative_steuerung') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_steuerung_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="wheel_nav_toggle">
|
||||
<span>{{ t('settings.scroll_nav') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="image_expand_toggle">
|
||||
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="f_for_fullscreen_toggle">
|
||||
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post.' }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_autoplay_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
|
||||
<span>{{ t('settings.disable_autoplay') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
|
||||
<span>{{ t('settings.disable_swiping') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
||||
</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>
|
||||
<!-- 1. Appearance & Theme -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-palette"></i> {{ t('settings.appearance_section') }}</legend>
|
||||
|
||||
<div class="setting-item" style="margin-top: 10px;">
|
||||
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
@each(themes as t)
|
||||
<option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option>
|
||||
@endeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<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>
|
||||
<span>{{ t('settings.enable_bg_blur') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
|
||||
<span>{{ t('settings.render_emojis') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="embed_youtube_in_comments_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
|
||||
<span>{{ t('settings.embed_yt') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
|
||||
</div>
|
||||
@if(show_koepfe)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="hide_koepfe_toggle" @if(session.hide_koepfe===true) checked @endif>
|
||||
<span>{{ t('settings.hide_koepfe') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
@if(allow_language_change)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
|
||||
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 220px;">
|
||||
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
|
||||
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
|
||||
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
|
||||
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
|
||||
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}
|
||||
</option>
|
||||
</select>
|
||||
<br><small class="text-muted">{{ t('settings.language_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
|
||||
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 220px;" @if(session.force_comment_display_mode) disabled @endif>
|
||||
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
|
||||
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
|
||||
</select>
|
||||
<br><small class="text-muted">
|
||||
@if(session.force_comment_display_mode)
|
||||
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
|
||||
@else
|
||||
{{ t('settings.comment_display_mode_hint') }}
|
||||
@endif
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="hide_item_ratings_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="hide_item_ratings_toggle">
|
||||
<span>{{ t('settings.hide_item_ratings') || 'Hide item ratings' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_item_ratings_hint') || 'Don\'t show SFW/NSFW rating indicators on main page items' }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.content_preferences_section') }}</legend>
|
||||
<div class="setting-item">
|
||||
<label for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfw_toggle">
|
||||
<span>{{ t('settings.blur_nsfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfl_toggle">
|
||||
<span>{{ t('settings.blur_nsfl') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_sfw_toggle">
|
||||
<span>{{ t('settings.blur_sfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_untagged_toggle">
|
||||
<span>{{ t('settings.blur_untagged') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px; border-top: 1px dashed var(--nav-border-color); padding-top: 15px;">
|
||||
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_detail_toggle">
|
||||
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.notifications_section') }}</legend>
|
||||
<div class="setting-item">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
|
||||
<span>{{ t('settings.receive_system_notifications') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
|
||||
<span>{{ t('settings.receive_user_notifications') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
|
||||
<span>{{ t('settings.do_not_disturb') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.do_not_disturb_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.appearance_section') }}</legend>
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_font_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
@@ -483,22 +220,281 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
@each(themes as t)
|
||||
<option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option>
|
||||
@endeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if(allow_language_change)
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
|
||||
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 300px;">
|
||||
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
|
||||
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
|
||||
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
|
||||
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
|
||||
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}</option>
|
||||
</select>
|
||||
<small class="text-muted" style="display: block; margin-top: 4px;">{{ t('settings.language_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<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>
|
||||
<span>{{ t('settings.enable_bg_blur') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
|
||||
<span>{{ t('settings.show_motd') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 2. Interface & Layout -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-desktop"></i> {{ t('settings.ui_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout===true) checked @endif>
|
||||
<span>{{ t('settings.modern_layout') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@if(!session.use_new_layout)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_infobox_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="alternative_infobox_toggle" @if(session.use_alternative_infobox===true) checked @endif>
|
||||
<span>{{ t('settings.alternative_infobox') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_steuerung_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="alternative_steuerung_toggle" @if(session.use_alternative_steuerung===true) checked @endif>
|
||||
<span>{{ t('settings.alternative_steuerung') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_steuerung_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(show_koepfe)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="hide_koepfe_toggle" @if(session.hide_koepfe===true) checked @endif>
|
||||
<span>{{ t('settings.hide_koepfe') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
<!-- 3. Playback & Navigation Controls -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-play"></i> Playback & Navigation</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="disable_autoplay_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
|
||||
<span>{{ t('settings.disable_autoplay') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
|
||||
<span>{{ t('settings.disable_swiping') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="wheel_nav_toggle">
|
||||
<span>{{ t('settings.scroll_nav') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="image_expand_toggle">
|
||||
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="f_for_fullscreen_toggle">
|
||||
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post.' }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 4. Comments & Discussion -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-comments"></i> Comments & Discussion</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
|
||||
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 300px;" @if(session.force_comment_display_mode) disabled @endif>
|
||||
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
|
||||
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
|
||||
</select>
|
||||
<small class="text-muted" style="display: block; margin-top: 4px;">
|
||||
@if(session.force_comment_display_mode)
|
||||
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
|
||||
@else
|
||||
{{ t('settings.comment_display_mode_hint') }}
|
||||
@endif
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
|
||||
<span>{{ t('settings.render_emojis') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="embed_youtube_in_comments_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
|
||||
<span>{{ t('settings.embed_yt') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 5. Content Preferences & Blurring -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-eye-slash"></i> {{ t('settings.content_preferences_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_sfw_toggle">
|
||||
<span>{{ t('settings.blur_sfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfw_toggle">
|
||||
<span>{{ t('settings.blur_nsfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfl_toggle">
|
||||
<span>{{ t('settings.blur_nsfl') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_untagged_toggle">
|
||||
<span>{{ t('settings.blur_untagged') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_detail_toggle">
|
||||
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="hide_item_ratings_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="hide_item_ratings_toggle">
|
||||
<span>{{ t('settings.hide_item_ratings') || 'Hide item ratings' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_item_ratings_hint') || 'Don\'t show SFW/NSFW rating indicators on main page items' }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 6. Privacy & Upload Defaults -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-user-shield"></i> Privacy & Defaults</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<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: 18px;">
|
||||
<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: 220px; padding: 6px 10px; 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
|
||||
</fieldset>
|
||||
|
||||
<!-- 7. Notifications -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-bell"></i> {{ t('settings.notifications_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
|
||||
<span>{{ t('settings.receive_system_notifications') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
|
||||
<span>{{ t('settings.receive_user_notifications') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
|
||||
<span>{{ t('settings.do_not_disturb') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.do_not_disturb_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 8. Flash / SWF Settings (if enabled) -->
|
||||
@if(enable_swf)
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.flash_section') }}</legend>
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-bolt"></i> {{ t('settings.flash_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="ruffle_background_toggle"
|
||||
@@ -513,10 +509,11 @@
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@if(enable_data_export)
|
||||
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
|
||||
<h2 id="export">{{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p>{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}</p>
|
||||
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
@@ -555,10 +552,11 @@
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
|
||||
<h2 id="account">{{ t('settings.account') }}</h2>
|
||||
<h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</h2>
|
||||
<div class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<table class="table account-info-table"
|
||||
style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
|
||||
<tbody>
|
||||
@@ -584,7 +582,7 @@
|
||||
<div class="account-actions-grid"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
|
||||
<div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.change_password') }}</h4>
|
||||
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-key"></i> {{ t('settings.change_password') }}</h4>
|
||||
<form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="password" id="current_password" class="input"
|
||||
placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
|
||||
@@ -599,7 +597,7 @@
|
||||
</div>
|
||||
|
||||
<div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.update_email') }}</h4>
|
||||
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-envelope"></i> {{ t('settings.update_email') }}</h4>
|
||||
<form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}"
|
||||
value="{{ email }}" required>
|
||||
@@ -614,10 +612,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(matrix_enabled || telegram_enabled)
|
||||
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
|
||||
<h2 id="linked">{{ t('settings.linked_accounts') }}</h2>
|
||||
<div class="linked-accounts-wrapper">
|
||||
<h2 id="linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</h2>
|
||||
<div class="linked-accounts-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p>{{ t('settings.matrix_link_desc') }}</p>
|
||||
|
||||
<div id="linked-accounts-container" style="margin-bottom: 1rem;">
|
||||
@@ -653,9 +652,9 @@
|
||||
|
||||
@if(enable_user_api_keys)
|
||||
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
|
||||
<h2 id="apikey">Upload API Key</h2>
|
||||
<h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
|
||||
<div id="api-key-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;">
|
||||
Use this key to upload files via external tools or scripts like ShareX.
|
||||
The key grants <strong>upload access only</strong> to your account.
|
||||
@@ -689,9 +688,9 @@
|
||||
|
||||
@if(enable_user_invites)
|
||||
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
|
||||
<h2 id="invites">{{ t('invites.section_title') }}</h2>
|
||||
<h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
|
||||
<div id="invite-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
|
||||
<p id="invite-section-desc" style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
|
||||
|
||||
@@ -803,8 +802,6 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Read all translated strings from server-rendered data attributes.
|
||||
// Attributes must be lowercase kebab-case: data-foo-bar → dataset.fooBar
|
||||
const ds = document.getElementById('invite-i18n')?.dataset || {};
|
||||
const T = (key, vars) => {
|
||||
let str = ds[key] || key;
|
||||
@@ -839,7 +836,6 @@
|
||||
}
|
||||
|
||||
if (data.is_admin) {
|
||||
// Swap description and hide criteria/slots UI for admins
|
||||
const desc = document.getElementById('invite-section-desc');
|
||||
if (desc) desc.textContent = T('adminDesc');
|
||||
const grid = document.getElementById('invite-criteria-grid');
|
||||
@@ -847,7 +843,6 @@
|
||||
if (grid) grid.style.display = 'none';
|
||||
if (statusLine) statusLine.style.display = 'none';
|
||||
} else {
|
||||
// Update criteria grid
|
||||
const criteriaMap = {
|
||||
'ic-uploads': data.criteria.uploads,
|
||||
'ic-age': data.criteria.age_days,
|
||||
@@ -865,19 +860,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Eligibility badge
|
||||
const badge = document.getElementById('invite-eligible-badge');
|
||||
badge.textContent = data.eligible ? T('eligible') : T('notEligible');
|
||||
badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b';
|
||||
|
||||
// Slots info
|
||||
document.getElementById('invite-slots-info').textContent =
|
||||
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
|
||||
|
||||
// Generate button state
|
||||
genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0);
|
||||
|
||||
// Token list
|
||||
if (!data.tokens || data.tokens.length === 0) {
|
||||
tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>';
|
||||
} else {
|
||||
|
||||
@@ -379,6 +379,9 @@
|
||||
enable_nsfl: @if(enable_nsfl) true @else false @endif,
|
||||
user: @if(session) "{{ session.user }}" @else null @endif,
|
||||
display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif,
|
||||
username_color: @if(session && session.username_color) "{!! session.username_color !!}" @else null @endif,
|
||||
id: @if(session && session.id) {{ session.id }} @else null @endif,
|
||||
user_id: @if(session && session.id) {{ session.id }} @else null @endif,
|
||||
is_admin: @if(session && session.admin) true @else false @endif,
|
||||
is_moderator: @if(session && session.is_moderator) true @else false @endif,
|
||||
enable_swf: @if(enable_swf) true @else false @endif,
|
||||
@@ -391,9 +394,14 @@
|
||||
embed_youtube_in_comments: @if(session && session.embed_youtube_in_comments !== false) true @else false @endif,
|
||||
avatar: @if(session && session.avatar) {{ session.avatar }} @else null @endif,
|
||||
avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif,
|
||||
banner_file: @if(session && session.banner_file) "{{ session.banner_file }}" @else null @endif,
|
||||
banner_position: @if(session && session.banner_position) "{{ session.banner_position }}" @else null @endif,
|
||||
banner_size: @if(session && session.banner_size) "{{ session.banner_size }}" @else null @endif,
|
||||
banner_repeat: @if(session && session.banner_repeat) "{{ session.banner_repeat }}" @else null @endif,
|
||||
receive_system_notifications: @if(session)@if(session.receive_system_notifications !== false) true @else false @endif@else true @endif,
|
||||
receive_user_notifications: @if(session)@if(session.receive_user_notifications !== false) true @else false @endif@else true @endif,
|
||||
do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif,
|
||||
comment_banner_enabled: @if(comment_banner_enabled) true @else false @endif,
|
||||
comment_display_mode: {{ comment_display_mode }},
|
||||
comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }},
|
||||
development: @if(development) true @else false @endif,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
|
||||
|
||||
<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">
|
||||
<meta name="theme-color" content="#0096ff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -23,6 +23,8 @@
|
||||
}
|
||||
@endif
|
||||
:root {
|
||||
--comment-banner-display: @if(comment_banner_enabled) block @else none @endif;
|
||||
--comment-banner-max-height: @if(typeof comment_banner_max_height !== 'undefined'){{ comment_banner_max_height }}px@else 300px @endif;
|
||||
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
|
||||
@if(session && session.font)
|
||||
--font: 'CustomUserFont', monospace !important;
|
||||
@@ -64,7 +66,7 @@
|
||||
@endif
|
||||
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
|
||||
@endif
|
||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }};</script>
|
||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif;</script>
|
||||
@if(!private_society || session)
|
||||
<script src="/s/js/marked.min.js" defer></script>
|
||||
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
|
||||
@@ -72,18 +74,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
@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:title" content="{{ item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||
<meta property="og:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : 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 name="description" content="{{ site_description }}" />
|
||||
<meta property="og:description" content="{{ site_description }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<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:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
@else
|
||||
<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" />
|
||||
|
||||
184
views/snippets/info-modal.html
Normal file
184
views/snippets/info-modal.html
Normal file
@@ -0,0 +1,184 @@
|
||||
<div id="info-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content infobox-modal-content">
|
||||
<!-- Header -->
|
||||
<div class="infobox-modal-header">
|
||||
<div class="infobox-header-title">
|
||||
<div class="infobox-header-text">
|
||||
<h3>{{ t('info_modal.title') || 'Post & File Details' }}</h3>
|
||||
<span class="infobox-sub-heading">ID: <code>{{ item.id }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="infobox-close-icon-btn" id="info-modal-close-icon" title="{{ t('common.close') || 'Close' }}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="infobox-modal-body">
|
||||
|
||||
<!-- Section 1: Post Details -->
|
||||
<div class="infobox-section">
|
||||
<div class="infobox-section-header">
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
<span>Post Configuration</span>
|
||||
</div>
|
||||
|
||||
<div class="infobox-cards-grid">
|
||||
@if(enable_item_title)
|
||||
<div class="infobox-card full-width">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-heading"></i> Title</label>
|
||||
@if(can_manage_item)
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button type="button" id="info-title-save" class="info-title-save-btn" title="Save Title"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
@else
|
||||
<div class="infobox-card-value">{!! item.title || '<span class="text-muted">No title set</span>' !!}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-eye"></i> Visibility</label>
|
||||
<div class="infobox-card-value-row">
|
||||
<span id="info-visibility-label" class="visibility-badge vis-{{ item.visibility || 0 }}">
|
||||
@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 type="button" id="info-visibility-edit-btn" class="infobox-action-btn" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}">
|
||||
<i class="fa-solid fa-pen"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-hourglass-half"></i> Expiration</label>
|
||||
<div class="infobox-expiry-content">
|
||||
<span id="info-expiry-label" class="expiry-status-label" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||
@if(item.expires_at)
|
||||
<i class="fa-solid fa-clock" style="color: #ffaa00;"></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> Permanent
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<div class="info-expiry-edit-controls">
|
||||
<select id="info-expiry-select" class="infobox-select" data-item-id="{{ item.id }}">
|
||||
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent</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 type="button" id="info-set-expiry-btn" class="infobox-action-btn" data-item-id="{{ item.id }}" title="Save Expiration">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Technical File Specifications -->
|
||||
<div class="infobox-section">
|
||||
<div class="infobox-section-header">
|
||||
<i class="fa-solid fa-file-code"></i>
|
||||
<span>Technical Specifications</span>
|
||||
</div>
|
||||
|
||||
<div class="infobox-cards-grid">
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-hard-drive"></i> File Size</label>
|
||||
<div class="infobox-card-value font-mono">{{ item.size }}</div>
|
||||
</div>
|
||||
|
||||
@if(item_has_dimensions)
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-ruler-combined"></i> Dimensions</label>
|
||||
<div class="infobox-card-value font-mono">{{ item.width }} × {{ item.height }} px</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-file-lines"></i> MIME Type</label>
|
||||
<div class="infobox-card-value"><code class="mime-pill">{{ item.mime }}</code></div>
|
||||
</div>
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
|
||||
<div class="infobox-card-value">
|
||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill">
|
||||
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(item.src && item.src.short)
|
||||
<div class="infobox-card full-width">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-link"></i> Source URL</label>
|
||||
<div class="infobox-card-value">
|
||||
<a href="{{ item.src.long }}" target="_blank" class="infobox-source-link">
|
||||
<i class="fa-solid fa-globe"></i> {{ item.src.short }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(item.checksum)
|
||||
<div class="infobox-card full-width">
|
||||
<div class="infobox-card-label-row">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-fingerprint"></i> SHA-256 Hash</label>
|
||||
<button type="button" class="copy-hash-btn" id="info-copy-hash-btn" data-hash="{{ item.checksum.split('_bypass_')[0] }}" title="Copy SHA-256 Hash">
|
||||
<i class="fa-solid fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-hash-codeblock font-mono"><code>{{ item.checksum.split('_bypass_')[0] }}</code></div>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Reposts & Duplicates (if present) -->
|
||||
@if(item.show_repost_row)
|
||||
<div class="infobox-section">
|
||||
<div class="infobox-section-header">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
<span>Similar & Duplicate Uploads</span>
|
||||
</div>
|
||||
<div class="infobox-reposts-list">
|
||||
@each(item.reposts as rp)
|
||||
<a href="/{{ rp.slug || rp.id }}" class="repost-pill {{ rp.match_type === 'phash' ? 'phash' : 'exact' }}" tooltip="{{ rp.match_type === 'phash' ? 'Visually similar (perceptual hash)' : 'Exact duplicate (checksum)' }}" flow="up">
|
||||
<i class="fa-solid {{ rp.match_type === 'phash' ? 'fa-clone' : 'fa-copy' }}"></i>
|
||||
<span>{{ rp.match_type === 'phash' ? '~#' : '#' }}{{ rp.slug || rp.id }}</span>
|
||||
</a>
|
||||
@endeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="infobox-modal-footer">
|
||||
@if(can_manage_item)
|
||||
<button type="button" class="btn-infobox-action" id="info-rethumb-btn" data-item-id="{{ item.id }}">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="btn-infobox-close" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
@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">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
<div class="nav-user-dropdown">
|
||||
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
|
||||
@if(session.avatar_file)
|
||||
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'">
|
||||
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
|
||||
@elseif(session.avatar && session.avatar > 0)
|
||||
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'">
|
||||
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
|
||||
@else
|
||||
<img class="nav-avatar-img" src="/a/default.png">
|
||||
<img class="nav-avatar-img" src="/a/default.png" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
|
||||
@endif
|
||||
<span class="nav-avatar-name" id="nav-display-name" @if(session.username_color)style="color: {{ session.username_color }}"@endif>{!! session.display_name || session.user !!}</span>
|
||||
<span class="nav-avatar-caret">▾</span>
|
||||
@@ -166,7 +166,7 @@
|
||||
@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;">
|
||||
@else
|
||||
<span class="f0ck">{{ custom_navbar_brand_text || domain.split('.')[0] }}</span>
|
||||
<span class="f0ck">{{ custom_navbar_brand_text || domain }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@each(notifications as n)
|
||||
@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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<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>
|
||||
</a>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<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>
|
||||
</a>
|
||||
@@ -33,46 +33,46 @@
|
||||
</div>
|
||||
</a>
|
||||
@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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||
<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>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
|
||||
<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>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<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>
|
||||
</a>
|
||||
@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 || '' }}">
|
||||
<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>
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</a>
|
||||
@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)
|
||||
<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'};" />
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@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)
|
||||
<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'" />
|
||||
@@ -125,9 +125,9 @@
|
||||
<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-msg">
|
||||
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }}
|
||||
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }}
|
||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').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_slug || n.item_id) }}
|
||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_slug || n.item_id) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@each(items as item)
|
||||
<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">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<img src="{{ item.thumb }}" loading="lazy" />
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -79,6 +79,41 @@
|
||||
</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">
|
||||
<label>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
@if(count.f0cks)
|
||||
<div class="posts no-infinite-scroll">
|
||||
@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">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -156,7 +156,7 @@
|
||||
@if(count.favs)
|
||||
<div class="posts no-infinite-scroll">
|
||||
@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">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
Reference in New Issue
Block a user