Compare commits
1 Commits
svelte
...
difflayout
| Author | SHA1 | Date | |
|---|---|---|---|
| dbb8861aed |
13
.env.example
13
.env.example
@@ -1,10 +1,3 @@
|
|||||||
POSTGRES_USER=f0ckm
|
POSTGRES_USER=
|
||||||
POSTGRES_DB=f0ckm
|
POSTGRES_DB=
|
||||||
POSTGRES_PASSWORD=f0ckm
|
POSTGRES_PASSWORD=
|
||||||
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
|
||||||
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
|
||||||
# COMPOSE_PROFILES=f0ckm-nginx
|
|
||||||
#
|
|
||||||
# VIRTUAL_HOST=yourdomain.com
|
|
||||||
# LETSENCRYPT_HOST=yourdomain.com
|
|
||||||
# LETSENCRYPT_EMAIL=your-email@example.com
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,4 +17,3 @@ public/tag_cache
|
|||||||
.env
|
.env
|
||||||
config.json
|
config.json
|
||||||
bundle.css
|
bundle.css
|
||||||
public/s/fonts/impact.woff
|
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -1,30 +1,18 @@
|
|||||||
# f0ckm
|
# f0ckm
|
||||||
|
|
||||||
Happy to finally bring you f0ckm! The long awaited imageboard solution that you can host yourself. It is designed to be a real alternative to big tech platforms that you can modify to suit your communities needs.
|
|
||||||
|
|
||||||
It features extensive tagging, searching, filtering and a variety of options.
|
|
||||||
|
|
||||||
The software is mostly generic, it can be modified easily via config.json to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
|
|
||||||
|
|
||||||
The software comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software.
|
|
||||||
|
|
||||||
## Software Requirements
|
|
||||||
|
|
||||||
- Docker (https://docs.docker.com/engine/install/debian/)
|
|
||||||
|
|
||||||
## prod
|
## prod
|
||||||
|
|
||||||
first things
|
first things
|
||||||
|
|
||||||
`cp .env.example .env`
|
`cp .env.example .env`
|
||||||
|
|
||||||
fill with for example: f0ckm (prefilled)
|
fill with for example: f0ckm
|
||||||
|
|
||||||
`cp config_example.json config.json`
|
`cp config_example.json config.json`
|
||||||
|
|
||||||
Edit to needs, for sql you can do this:
|
Edit to needs, for sql you can do this:
|
||||||
|
|
||||||
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname, if you choose localhost you wont need to add the ENV `DB_HOST=localhost` and if you also change the port to be `DB_PORT=5454` you wont need this ENV either, but then Docker wont find the DB.
|
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname
|
||||||
|
|
||||||
```
|
```
|
||||||
"sql": {
|
"sql": {
|
||||||
@@ -54,8 +42,6 @@ now vist http://localhost:1337 in your browser
|
|||||||
|
|
||||||
## dev
|
## dev
|
||||||
|
|
||||||
NOTE: when developing locally it might be necessary to run commands with `DB_HOST=localhost DB_PORT=5454`
|
|
||||||
|
|
||||||
`docker compose up -d f0ckm-db`
|
`docker compose up -d f0ckm-db`
|
||||||
|
|
||||||
on dev machine:
|
on dev machine:
|
||||||
@@ -63,18 +49,8 @@ on dev machine:
|
|||||||
`npm i`
|
`npm i`
|
||||||
`npm run dev`
|
`npm run dev`
|
||||||
|
|
||||||
Fill Database
|
|
||||||
|
|
||||||
`docker exec -i f0ckm-db psql -U f0ckm -d f0ckm < migrations/f0ckm_schema.sql`
|
|
||||||
|
|
||||||
`DB_HOST=localhost DB_PORT=5454 node scripts/seed.mjs`
|
|
||||||
|
|
||||||
Create admin user in dev env
|
Create admin user in dev env
|
||||||
|
|
||||||
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
|
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
|
||||||
|
|
||||||
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
|
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
|
||||||
|
|
||||||
## NGINX
|
|
||||||
|
|
||||||
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
client_max_body_size 10000M;
|
|
||||||
client_body_timeout 120s;
|
|
||||||
client_header_timeout 120s;
|
|
||||||
@@ -53,20 +53,14 @@
|
|||||||
"amoled"
|
"amoled"
|
||||||
],
|
],
|
||||||
"theme": "amoled",
|
"theme": "amoled",
|
||||||
"default_font": "",
|
|
||||||
"default_layout": "legacy",
|
"default_layout": "legacy",
|
||||||
"custom_favicon": "/s/img/favicon.gif",
|
"custom_favicon": "/s/img/favicon.gif",
|
||||||
"custom_brand_image": [],
|
"custom_brand_image": [],
|
||||||
"custom_navbar_brand_text": "",
|
|
||||||
"show_koepfe": false,
|
"show_koepfe": false,
|
||||||
"hide_sidebar_default": true,
|
|
||||||
"koepfe": [],
|
"koepfe": [],
|
||||||
"enable_global_chat": true,
|
"enable_global_chat": true,
|
||||||
"enable_danmaku": true,
|
"enable_danmaku": true,
|
||||||
"private_messages": true,
|
"private_messages": true,
|
||||||
"dm_attachments": true,
|
|
||||||
"dm_unencrypted": false,
|
|
||||||
"dm_attachment_expiry_days": 90,
|
|
||||||
"halls_enabled": true,
|
"halls_enabled": true,
|
||||||
"userhalls_enabled": true,
|
"userhalls_enabled": true,
|
||||||
"enable_userhall_image_upload": true,
|
"enable_userhall_image_upload": true,
|
||||||
@@ -74,24 +68,12 @@
|
|||||||
"meme_creator": true,
|
"meme_creator": true,
|
||||||
"enable_cleanup": false,
|
"enable_cleanup": false,
|
||||||
"enable_data_export": true,
|
"enable_data_export": true,
|
||||||
"inactivity_ban_days": 60,
|
|
||||||
"enable_user_api_keys": true,
|
|
||||||
"enable_user_invites": true,
|
|
||||||
"user_invite_slots": 2,
|
|
||||||
"invite_criteria": {
|
|
||||||
"uploads": 10,
|
|
||||||
"age_days": 10,
|
|
||||||
"comments": 10,
|
|
||||||
"tags": 10
|
|
||||||
},
|
|
||||||
"cleanup_timeframe_days": 30,
|
"cleanup_timeframe_days": 30,
|
||||||
"web_url_upload": true,
|
"web_url_upload": true,
|
||||||
"enable_youtube_upload": true,
|
"enable_youtube_upload": true,
|
||||||
"web_meta_extraction": true,
|
"web_meta_extraction": true,
|
||||||
"bypass_duplicate_check": true,
|
"bypass_duplicate_check": true,
|
||||||
"shitpost_mode": false,
|
"shitpost_mode": false,
|
||||||
"shitpost_require_rating": false,
|
|
||||||
"shitpost_min_tags": 0,
|
|
||||||
"protect_files": false,
|
"protect_files": false,
|
||||||
"enable_dynamic_thumbs": false,
|
"enable_dynamic_thumbs": false,
|
||||||
"allowed_comment_images": [
|
"allowed_comment_images": [
|
||||||
@@ -101,14 +83,6 @@
|
|||||||
],
|
],
|
||||||
"show_mime_picker": true,
|
"show_mime_picker": true,
|
||||||
"embed_youtube_in_comments": true,
|
"embed_youtube_in_comments": true,
|
||||||
"allow_fileupload_comments": true,
|
|
||||||
"allow_comment_deletion": false,
|
|
||||||
"enable_comment_polls": false,
|
|
||||||
"fileupload_comments_multifile": true,
|
|
||||||
"fileupload_comments_size": 104857600,
|
|
||||||
"fileupload_comments_max": 5,
|
|
||||||
"fileupload_comments_mode": "attachment",
|
|
||||||
"fileupload_comments_mimes": ["image", "video", "audio"],
|
|
||||||
"show_content_warning": true,
|
"show_content_warning": true,
|
||||||
"default_comment_display_mode": 1,
|
"default_comment_display_mode": 1,
|
||||||
"phrases": [
|
"phrases": [
|
||||||
@@ -120,19 +94,12 @@
|
|||||||
"enable_swiping": true,
|
"enable_swiping": true,
|
||||||
"enable_profile_description": true,
|
"enable_profile_description": true,
|
||||||
"user_alternative_infobox": false,
|
"user_alternative_infobox": false,
|
||||||
"user_banner_enabled": true,
|
|
||||||
"user_alternative_steuerung": false,
|
|
||||||
"enable_swf": false,
|
"enable_swf": false,
|
||||||
"swf_thumb": "/s/img/swf.png",
|
"swf_thumb": "/s/img/swf.png",
|
||||||
"enable_archive": true,
|
|
||||||
"archive_thumb": "/s/img/archive.webp",
|
|
||||||
"enable_item_title": true,
|
|
||||||
"open_registration": true,
|
"open_registration": true,
|
||||||
"open_registration_web_toggle": false,
|
"open_registration_web_toggle": false,
|
||||||
"open_registration_require_mail_andor_token": false,
|
|
||||||
"private_society": false,
|
"private_society": false,
|
||||||
"private_society_gate": "cloudflare",
|
"private_society_gate": "cloudflare",
|
||||||
"public_nsfw": false,
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"images": "/b",
|
"images": "/b",
|
||||||
"thumbnails": "/t",
|
"thumbnails": "/t",
|
||||||
@@ -210,16 +177,7 @@
|
|||||||
"video/x-matroska": "mkv",
|
"video/x-matroska": "mkv",
|
||||||
"application/x-shockwave-flash": "swf",
|
"application/x-shockwave-flash": "swf",
|
||||||
"application/vnd.adobe.flash.movie": "swf",
|
"application/vnd.adobe.flash.movie": "swf",
|
||||||
"application/pdf": "pdf",
|
"application/pdf": "pdf"
|
||||||
"application/zip": "zip",
|
|
||||||
"application/x-zip-compressed": "zip",
|
|
||||||
"application/x-rar-compressed": "rar",
|
|
||||||
"application/vnd.rar": "rar",
|
|
||||||
"application/x-7z-compressed": "7z",
|
|
||||||
"application/x-tar": "tar",
|
|
||||||
"application/gzip": "gz",
|
|
||||||
"application/x-bzip2": "bz2",
|
|
||||||
"application/x-xz": "xz"
|
|
||||||
},
|
},
|
||||||
"apis": {},
|
"apis": {},
|
||||||
"smtp": {
|
"smtp": {
|
||||||
@@ -231,10 +189,5 @@
|
|||||||
"password": "smtp_password",
|
"password": "smtp_password",
|
||||||
"from": "admin@example.com",
|
"from": "admin@example.com",
|
||||||
"mail_reset_password": false
|
"mail_reset_password": false
|
||||||
},
|
|
||||||
"recaptcha": {
|
|
||||||
"enabled": false,
|
|
||||||
"site_key": "YOUR_RECAPTCHA_V2_SITE_KEY",
|
|
||||||
"secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,14 +11,12 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- f0ckm-net
|
- f0ckm-net
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/opt/f0ckm/config.json:ro,Z
|
- ./config.json:/opt/f0ckm/config.json:Z
|
||||||
- ./src/:/opt/f0ckm/src/:Z
|
- ./src/:/opt/f0ckm/src/:Z
|
||||||
- ./views/:/opt/f0ckm/views/:Z
|
- ./views/:/opt/f0ckm/views/:Z
|
||||||
- ./scripts/:/opt/f0ckm/scripts/:Z
|
- ./scripts/:/opt/f0ckm/scripts/:Z
|
||||||
- ./f0ckm-data/a/:/opt/f0ckm/public/a/:Z
|
- ./f0ckm-data/a/:/opt/f0ckm/public/a/:Z
|
||||||
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
|
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
|
||||||
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
|
|
||||||
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
|
|
||||||
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
|
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
|
||||||
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
|
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
|
||||||
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
|
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
|
||||||
@@ -35,10 +33,6 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
- GIT_HASH=${f0ckm_TAG:-unknown}
|
- GIT_HASH=${f0ckm_TAG:-unknown}
|
||||||
- VIRTUAL_HOST=${VIRTUAL_HOST:-localhost}
|
|
||||||
- VIRTUAL_PORT=1337
|
|
||||||
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
|
|
||||||
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
|
|
||||||
ports:
|
ports:
|
||||||
- "1337:1337"
|
- "1337:1337"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -46,16 +40,6 @@ services:
|
|||||||
f0ckm-db:
|
f0ckm-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
tor:
|
|
||||||
image: dockurr/tor
|
|
||||||
container_name: tor
|
|
||||||
ports:
|
|
||||||
- "9050:9050"
|
|
||||||
- "8118:8118"
|
|
||||||
networks:
|
|
||||||
- f0ckm-net
|
|
||||||
restart: always
|
|
||||||
|
|
||||||
f0ckm-db:
|
f0ckm-db:
|
||||||
container_name: f0ckm-db
|
container_name: f0ckm-db
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -94,49 +78,6 @@ services:
|
|||||||
# - f0ckm-net
|
# - f0ckm-net
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
f0ckm-nginx:
|
|
||||||
image: nginxproxy/nginx-proxy:latest
|
|
||||||
container_name: f0ckm-nginx
|
|
||||||
profiles:
|
|
||||||
- f0ckm-nginx
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
|
||||||
- certs:/etc/nginx/certs:rw
|
|
||||||
- vhost:/etc/nginx/vhost.d:rw
|
|
||||||
- html:/usr/share/nginx/html:rw
|
|
||||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
|
||||||
networks:
|
|
||||||
- f0ckm-net
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
acme-companion:
|
|
||||||
image: nginxproxy/acme-companion:latest
|
|
||||||
container_name: f0ckm-acme-companion
|
|
||||||
profiles:
|
|
||||||
- f0ckm-nginx
|
|
||||||
environment:
|
|
||||||
- NGINX_PROXY_CONTAINER=f0ckm-nginx
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- certs:/etc/nginx/certs:rw
|
|
||||||
- vhost:/etc/nginx/vhost.d:rw
|
|
||||||
- html:/usr/share/nginx/html:rw
|
|
||||||
- acme:/etc/acme.sh:rw
|
|
||||||
depends_on:
|
|
||||||
- f0ckm-nginx
|
|
||||||
networks:
|
|
||||||
- f0ckm-net
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
f0ckm-net:
|
f0ckm-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
|
||||||
certs:
|
|
||||||
vhost:
|
|
||||||
html:
|
|
||||||
acme:
|
|
||||||
|
|||||||
23
f0ck-svelte/.gitignore
vendored
23
f0ck-svelte/.gitignore
vendored
@@ -1,23 +0,0 @@
|
|||||||
node_modules
|
|
||||||
|
|
||||||
# Output
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
3
f0ck-svelte/.vscode/extensions.json
vendored
3
f0ck-svelte/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# recreate this project
|
|
||||||
npx sv@0.16.3 create --template minimal --types ts --install npm .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
2486
f0ck-svelte/package-lock.json
generated
2486
f0ck-svelte/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "f0ck-svelte",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
|
||||||
"@sveltejs/kit": "^2.63.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"@types/node": "^26.1.1",
|
|
||||||
"daisyui": "^5.6.18",
|
|
||||||
"svelte": "^5.56.1",
|
|
||||||
"svelte-check": "^4.6.0",
|
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.16"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "daisyui" {
|
|
||||||
themes: f0ck --default, light;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
/* f0ck dark theme — AMOLED-inspired */
|
|
||||||
--color-base-100: oklch(10% 0.02 265);
|
|
||||||
--color-base-200: oklch(13% 0.02 265);
|
|
||||||
--color-base-300: oklch(16% 0.02 265);
|
|
||||||
--color-base-content: oklch(92% 0.01 265);
|
|
||||||
|
|
||||||
--color-primary: oklch(55% 0.22 265);
|
|
||||||
--color-primary-content: oklch(98% 0.01 265);
|
|
||||||
|
|
||||||
--color-secondary: oklch(60% 0.18 300);
|
|
||||||
--color-secondary-content: oklch(98% 0.01 265);
|
|
||||||
|
|
||||||
--color-accent: oklch(72% 0.15 290);
|
|
||||||
--color-accent-content: oklch(10% 0.02 265);
|
|
||||||
|
|
||||||
--color-neutral: oklch(20% 0.02 265);
|
|
||||||
--color-neutral-content: oklch(85% 0.01 265);
|
|
||||||
|
|
||||||
--color-info: oklch(70% 0.15 220);
|
|
||||||
--color-success: oklch(70% 0.15 160);
|
|
||||||
--color-warning: oklch(78% 0.18 80);
|
|
||||||
--color-error: oklch(63% 0.22 25);
|
|
||||||
|
|
||||||
/* Rating colors */
|
|
||||||
--color-sfw: oklch(70% 0.15 160);
|
|
||||||
--color-nsfw: oklch(65% 0.20 25);
|
|
||||||
--color-nsfl: oklch(60% 0.22 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Global resets & base styles ───────────────────────────────────────── */
|
|
||||||
*, *::before, *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
||||||
background-color: var(--color-base-100);
|
|
||||||
color: var(--color-base-content);
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100dvh;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
|
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: oklch(30% 0.02 265); border-radius: 3px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: oklch(40% 0.02 265); }
|
|
||||||
|
|
||||||
/* ── Rating badge utility classes ─────────────────────────────────────── */
|
|
||||||
.badge-sfw { background: oklch(70% 0.15 160 / 20%); color: oklch(70% 0.15 160); border-color: oklch(70% 0.15 160 / 40%); }
|
|
||||||
.badge-nsfw { background: oklch(65% 0.20 25 / 20%); color: oklch(65% 0.20 25); border-color: oklch(65% 0.20 25 / 40%); }
|
|
||||||
.badge-nsfl { background: oklch(55% 0.22 0 / 20%); color: oklch(55% 0.22 0); border-color: oklch(55% 0.22 0 / 40%); }
|
|
||||||
.badge-unt { background: oklch(55% 0.02 265 / 20%); color: oklch(65% 0.02 265); border-color: oklch(55% 0.02 265 / 40%); }
|
|
||||||
|
|
||||||
/* ── Grid thumb skeleton pulse ─────────────────────────────────────────── */
|
|
||||||
@keyframes thumb-pulse {
|
|
||||||
0%, 100% { opacity: 0.4; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
}
|
|
||||||
.thumb-skeleton { animation: thumb-pulse 1.5s ease-in-out infinite; }
|
|
||||||
|
|
||||||
/* ── Fade-in for navigations ───────────────────────────────────────────── */
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.page-enter { animation: fade-in 0.15s ease-out; }
|
|
||||||
21
f0ck-svelte/src/app.d.ts
vendored
21
f0ck-svelte/src/app.d.ts
vendored
@@ -1,21 +0,0 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
import type { SessionUser } from '$lib/types/session';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
interface Error {
|
|
||||||
message: string;
|
|
||||||
code?: number;
|
|
||||||
}
|
|
||||||
interface Locals {
|
|
||||||
session: SessionUser | null;
|
|
||||||
}
|
|
||||||
interface PageData {
|
|
||||||
session: SessionUser | null;
|
|
||||||
}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="text-scale" content="scale" />
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side hook: read session from the existing f0ckm backend
|
|
||||||
* by forwarding the session cookie on every SSR request.
|
|
||||||
*
|
|
||||||
* This keeps auth as a single source of truth in the backend.
|
|
||||||
*/
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
|
||||||
// Forward cookies to the backend session endpoint
|
|
||||||
const cookieHeader = event.request.headers.get('cookie') ?? '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${getApiBase(event)}/api/v2/session`, {
|
|
||||||
headers: { cookie: cookieHeader }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
event.locals.session = data.loggedIn ? data.user : null;
|
|
||||||
} else {
|
|
||||||
event.locals.session = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Backend not reachable (dev without backend running)
|
|
||||||
event.locals.session = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolve(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the API base URL for server-side fetches.
|
|
||||||
* In production the SvelteKit server and the API backend run on separate ports.
|
|
||||||
* The VITE_API_URL env var (or API_URL for SSR) sets the backend address.
|
|
||||||
*/
|
|
||||||
function getApiBase(event: Parameters<Handle>[0]['event']): string {
|
|
||||||
// SSR: use internal backend address (avoids going through Nginx proxy)
|
|
||||||
const internal = process.env.API_URL ?? 'http://localhost:1337';
|
|
||||||
return internal;
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import type { Comment } from '$lib/types/comment';
|
|
||||||
import { csrfFetch } from '$lib/utils/csrfFetch';
|
|
||||||
|
|
||||||
// ── Comments ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface CommentsResponse {
|
|
||||||
success: boolean;
|
|
||||||
comments: Comment[];
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getComments(
|
|
||||||
itemId: number,
|
|
||||||
fetchFn = fetch
|
|
||||||
): Promise<CommentsResponse> {
|
|
||||||
const res = await fetchFn(`/api/comments/${itemId}`);
|
|
||||||
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function postComment(payload: {
|
|
||||||
itemId: number;
|
|
||||||
content: string;
|
|
||||||
replyTo?: number | null;
|
|
||||||
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
item_id: String(payload.itemId),
|
|
||||||
content: payload.content,
|
|
||||||
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
|
|
||||||
});
|
|
||||||
const res = await csrfFetch('/api/comments', { method: 'POST', body });
|
|
||||||
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteComment(
|
|
||||||
commentId: number
|
|
||||||
): Promise<{ success: boolean; msg?: string }> {
|
|
||||||
// Backend uses POST /api/comments/:id/delete (not DELETE method)
|
|
||||||
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
|
|
||||||
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Favorites ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface FavResponse {
|
|
||||||
success: boolean;
|
|
||||||
itemid: number;
|
|
||||||
favs: Array<{
|
|
||||||
user: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
avatar_file: string | null;
|
|
||||||
username_color: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleFav(itemId: number): Promise<FavResponse> {
|
|
||||||
const body = new URLSearchParams({ postid: String(itemId) });
|
|
||||||
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
|
|
||||||
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tags ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface TagsResponse {
|
|
||||||
success: boolean;
|
|
||||||
tags: import('$lib/types/item').Tag[];
|
|
||||||
msg?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
|
||||||
const body = new URLSearchParams({ tagname });
|
|
||||||
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
|
|
||||||
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
|
||||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CycleRatingResponse {
|
|
||||||
success: boolean;
|
|
||||||
rating_tag_id: number;
|
|
||||||
rating_label: 'SFW' | 'NSFW' | 'NSFL';
|
|
||||||
rating_class: 'sfw' | 'nsfw' | 'nsfl';
|
|
||||||
msg?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
|
|
||||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
|
|
||||||
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
|
|
||||||
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
|
|
||||||
if (!res.ok) throw new Error('suggestTags failed');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Item management (mod/admin) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function deleteItem(
|
|
||||||
itemId: number,
|
|
||||||
reason = ''
|
|
||||||
): Promise<{ success: boolean; msg?: string }> {
|
|
||||||
const body = new URLSearchParams({ postid: String(itemId), reason });
|
|
||||||
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
|
|
||||||
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
|
||||||
// Backend reads id from query string: req.url.qs.id
|
|
||||||
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
|
|
||||||
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
|
||||||
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
|
|
||||||
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { SessionResponse } from '$lib/types/session';
|
|
||||||
|
|
||||||
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
|
|
||||||
const res = await fetchFn('/api/v2/session');
|
|
||||||
if (!res.ok) return { loggedIn: false, user: null };
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
kmsi = false
|
|
||||||
): Promise<{ success: boolean; msg?: string }> {
|
|
||||||
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
|
|
||||||
const res = await fetch('/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Accept: 'application/json'
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
await fetch('/logout', { method: 'POST', credentials: 'include' });
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
|
|
||||||
|
|
||||||
export interface GetItemsParams {
|
|
||||||
page?: number;
|
|
||||||
mode?: number;
|
|
||||||
ratings?: string;
|
|
||||||
mime?: string;
|
|
||||||
tag?: string;
|
|
||||||
hall?: string;
|
|
||||||
user?: string;
|
|
||||||
fav?: boolean;
|
|
||||||
random?: boolean;
|
|
||||||
strict?: boolean;
|
|
||||||
min_xd?: number;
|
|
||||||
newer?: number;
|
|
||||||
tagger?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a page of grid items.
|
|
||||||
* Called from +page.ts (server-side) and from the client for infinite-scroll.
|
|
||||||
*/
|
|
||||||
export async function getItems(
|
|
||||||
params: GetItemsParams,
|
|
||||||
fetchFn: typeof fetch = fetch
|
|
||||||
): Promise<ItemsResponse> {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.page) qs.set('page', String(params.page));
|
|
||||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
|
||||||
if (params.ratings) qs.set('ratings', params.ratings);
|
|
||||||
if (params.mime) qs.set('mime', params.mime);
|
|
||||||
if (params.tag) qs.set('tag', params.tag);
|
|
||||||
if (params.hall) qs.set('hall', params.hall);
|
|
||||||
if (params.user) qs.set('user', params.user);
|
|
||||||
if (params.fav) qs.set('fav', 'true');
|
|
||||||
if (params.random) qs.set('random', '1');
|
|
||||||
if (params.strict) qs.set('strict', '1');
|
|
||||||
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
|
|
||||||
if (params.newer) qs.set('newer', String(params.newer));
|
|
||||||
if (params.tagger) qs.set('tagger', params.tagger);
|
|
||||||
|
|
||||||
const res = await fetchFn(`/api/v2/items?${qs}`);
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetItemParams {
|
|
||||||
mode?: number;
|
|
||||||
ratings?: string;
|
|
||||||
tag?: string;
|
|
||||||
hall?: string;
|
|
||||||
user?: string;
|
|
||||||
fav?: boolean;
|
|
||||||
random?: boolean;
|
|
||||||
strict?: boolean;
|
|
||||||
mime?: string;
|
|
||||||
userHall?: string;
|
|
||||||
userHallOwner?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single item with its neighbours and metadata.
|
|
||||||
*/
|
|
||||||
export async function getItem(
|
|
||||||
id: number | string,
|
|
||||||
params: GetItemParams = {},
|
|
||||||
fetchFn: typeof fetch = fetch
|
|
||||||
): Promise<ItemResponse> {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
|
||||||
if (params.ratings) qs.set('ratings', params.ratings);
|
|
||||||
if (params.tag) qs.set('tag', params.tag);
|
|
||||||
if (params.hall) qs.set('hall', params.hall);
|
|
||||||
if (params.user) qs.set('user', params.user);
|
|
||||||
if (params.fav) qs.set('fav', 'true');
|
|
||||||
if (params.random) qs.set('random', '1');
|
|
||||||
if (params.strict) qs.set('strict', '1');
|
|
||||||
if (params.mime) qs.set('mime', params.mime);
|
|
||||||
if (params.userHall) qs.set('userHall', params.userHall);
|
|
||||||
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
|
|
||||||
|
|
||||||
const suffix = qs.toString() ? `?${qs}` : '';
|
|
||||||
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a random item ID (optionally within a context).
|
|
||||||
* Returns the JSON response; caller should navigate to response.url.
|
|
||||||
*/
|
|
||||||
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
|
|
||||||
const res = await fetchFn('/api/v2/random');
|
|
||||||
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,232 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getComments, postComment, deleteComment } from '$lib/api/actions';
|
|
||||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
|
||||||
import { flash } from '$lib/stores/flash';
|
|
||||||
import { timeAgo, formatDate } from '$lib/utils/timeago';
|
|
||||||
import { onSSE } from '$lib/stores/sse';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import type { Comment } from '$lib/types/comment';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
itemId: number;
|
|
||||||
}
|
|
||||||
let { itemId }: Props = $props();
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────
|
|
||||||
let comments = $state<Comment[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let text = $state('');
|
|
||||||
let replyTo = $state<Comment | null>(null);
|
|
||||||
let submitting = $state(false);
|
|
||||||
|
|
||||||
// ── Load ──────────────────────────────────────────────────────────────────
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const data = await getComments(itemId);
|
|
||||||
comments = data.comments ?? [];
|
|
||||||
} catch {
|
|
||||||
error = 'Failed to load comments.';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run on mount and when itemId changes
|
|
||||||
$effect(() => { load(); });
|
|
||||||
|
|
||||||
// ── Live updates via SSE ──────────────────────────────────────────────────
|
|
||||||
const unsubSSE = onSSE((event) => {
|
|
||||||
if (event.type !== 'comments') return;
|
|
||||||
const d = event.data as { item_id: number; type?: string };
|
|
||||||
if (d.item_id !== itemId) return;
|
|
||||||
// Reload the full comment list on any comment activity for this item
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
onDestroy(unsubSSE);
|
|
||||||
|
|
||||||
// ── Post comment ──────────────────────────────────────────────────────────
|
|
||||||
async function submit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!text.trim()) return;
|
|
||||||
submitting = true;
|
|
||||||
try {
|
|
||||||
const res = await postComment({
|
|
||||||
itemId,
|
|
||||||
content: text.trim(),
|
|
||||||
replyTo: replyTo?.id ?? null
|
|
||||||
});
|
|
||||||
if (res.success) {
|
|
||||||
text = '';
|
|
||||||
replyTo = null;
|
|
||||||
await load();
|
|
||||||
flash('Comment posted', 'success');
|
|
||||||
} else {
|
|
||||||
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete comment ────────────────────────────────────────────────────────
|
|
||||||
async function handleDelete(comment: Comment) {
|
|
||||||
if (!confirm('Delete this comment?')) return;
|
|
||||||
try {
|
|
||||||
const res = await deleteComment(comment.id);
|
|
||||||
if (res.success) {
|
|
||||||
await load();
|
|
||||||
flash('Deleted', 'success');
|
|
||||||
} else {
|
|
||||||
flash(res.msg ?? 'Failed to delete', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canDelete(comment: Comment) {
|
|
||||||
return $session && ($isModOrAdmin || $session.user === comment.username);
|
|
||||||
}
|
|
||||||
|
|
||||||
function avatarSrc(comment: Comment): string {
|
|
||||||
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
|
|
||||||
if (comment.avatar) return `/t/${comment.avatar}.webp`;
|
|
||||||
return '/a/default.png';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="flex flex-col gap-3">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
|
|
||||||
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
|
|
||||||
</span>
|
|
||||||
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
|
|
||||||
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comment form -->
|
|
||||||
{#if $session}
|
|
||||||
<form onsubmit={submit} class="flex flex-col gap-2">
|
|
||||||
{#if replyTo}
|
|
||||||
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
|
|
||||||
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
|
|
||||||
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
|
|
||||||
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100">✕</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<textarea
|
|
||||||
bind:value={text}
|
|
||||||
placeholder="Write a comment…"
|
|
||||||
rows="2"
|
|
||||||
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
|
|
||||||
disabled={submitting}
|
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
|
|
||||||
></textarea>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary btn-sm self-end"
|
|
||||||
disabled={submitting || !text.trim()}
|
|
||||||
>
|
|
||||||
{#if submitting}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs opacity-40">
|
|
||||||
<a href="/login" class="link link-primary">Log in</a> to comment.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Comments list -->
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex justify-center py-4">
|
|
||||||
<span class="loading loading-dots loading-sm opacity-40"></span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<p class="text-xs text-error opacity-70">{error}</p>
|
|
||||||
{:else if comments.length === 0}
|
|
||||||
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="flex flex-col gap-2">
|
|
||||||
{#each comments as comment (comment.id)}
|
|
||||||
<li
|
|
||||||
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
|
|
||||||
id="comment-{comment.id}"
|
|
||||||
>
|
|
||||||
<!-- Avatar -->
|
|
||||||
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
|
|
||||||
<div class="avatar">
|
|
||||||
<div class="w-7 rounded-full border border-base-300">
|
|
||||||
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-baseline gap-1.5 flex-wrap">
|
|
||||||
<a
|
|
||||||
href="/user/{comment.username?.toLowerCase()}"
|
|
||||||
class="font-semibold text-xs hover:text-primary transition-colors"
|
|
||||||
style={comment.username_color ? `color: ${comment.username_color}` : ''}
|
|
||||||
>
|
|
||||||
{comment.display_name || comment.username}
|
|
||||||
</a>
|
|
||||||
<time
|
|
||||||
datetime={comment.stamp_full}
|
|
||||||
title={formatDate(comment.stamp_full)}
|
|
||||||
class="text-xs opacity-40"
|
|
||||||
>
|
|
||||||
{comment.stamp}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if comment.is_deleted}
|
|
||||||
<p class="text-xs italic opacity-50">— deleted —</p>
|
|
||||||
{:else}
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
|
|
||||||
{@html comment.content}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
{#if !comment.is_deleted}
|
|
||||||
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{#if $session}
|
|
||||||
<button
|
|
||||||
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
|
|
||||||
class="btn btn-ghost btn-xs gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if canDelete(comment)}
|
|
||||||
<button
|
|
||||||
onclick={() => handleDelete(comment)}
|
|
||||||
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { GridItem } from '$lib/types/item';
|
|
||||||
import type { ItemLink } from '$lib/types/item';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: GridItem;
|
|
||||||
link: ItemLink;
|
|
||||||
}
|
|
||||||
let { item, link }: Props = $props();
|
|
||||||
|
|
||||||
// Build the item URL respecting the current filter context
|
|
||||||
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
|
|
||||||
const thumbUrl = $derived(`/t/${item.id}.webp`);
|
|
||||||
|
|
||||||
// Rating badge
|
|
||||||
const ratingClass = $derived(
|
|
||||||
item.tag === 'NSFL' ? 'badge-nsfl' :
|
|
||||||
item.tag === 'NSFW' ? 'badge-nsfw' :
|
|
||||||
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={itemUrl}
|
|
||||||
data-sveltekit-preload-data="hover"
|
|
||||||
class="
|
|
||||||
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
|
|
||||||
hover:border-primary/50 transition-all duration-200
|
|
||||||
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
|
|
||||||
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<!-- Thumbnail -->
|
|
||||||
<div class="aspect-video w-full overflow-hidden bg-base-300">
|
|
||||||
<img
|
|
||||||
src={thumbUrl}
|
|
||||||
alt="Item {item.id}"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overlay on hover -->
|
|
||||||
<div class="
|
|
||||||
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
|
||||||
flex flex-col justify-end p-2 gap-1
|
|
||||||
">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{#if item.is_oc}
|
|
||||||
<span class="badge badge-xs badge-accent">OC</span>
|
|
||||||
{/if}
|
|
||||||
{#if item.is_pinned}
|
|
||||||
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
|
|
||||||
{/if}
|
|
||||||
{#if item.xd_tier > 0}
|
|
||||||
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if item.display_name || item.username}
|
|
||||||
<span class="text-xs text-base-content/60 truncate">
|
|
||||||
{item.display_name || item.username}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rating badge (always visible) -->
|
|
||||||
{#if item.tag}
|
|
||||||
<div class="absolute top-1.5 right-1.5">
|
|
||||||
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Notification dot -->
|
|
||||||
{#if item.has_notification}
|
|
||||||
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
|
|
||||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
|
||||||
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
|
|
||||||
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
|
|
||||||
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
|
|
||||||
import TagList from '$lib/components/item/TagList.svelte';
|
|
||||||
import Comments from '$lib/components/item/Comments.svelte';
|
|
||||||
import { mimeCategory } from '$lib/utils/mimeHelpers';
|
|
||||||
import { formatDate } from '$lib/utils/timeago';
|
|
||||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
|
||||||
import { goto, preloadData } from '$app/navigation';
|
|
||||||
import { flash } from '$lib/stores/flash';
|
|
||||||
import { onSSE } from '$lib/stores/sse';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { broadcastPin } from '$lib/stores/pin';
|
|
||||||
import {
|
|
||||||
toggleFav, addTag, deleteTag,
|
|
||||||
deleteItem, pinItem, unpinItem, cycleRating
|
|
||||||
} from '$lib/api/actions';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: FullItem;
|
|
||||||
pagination: Pagination;
|
|
||||||
link: ItemLink;
|
|
||||||
isSubscribed?: boolean;
|
|
||||||
}
|
|
||||||
let { item, pagination, link, isSubscribed = false }: Props = $props();
|
|
||||||
|
|
||||||
// ── Reactive local state (mutable copies updated after actions) ───────────
|
|
||||||
let favorites = $state(item.favorites);
|
|
||||||
let tags = $state(item.tags);
|
|
||||||
let isPinned = $state(item.is_pinned);
|
|
||||||
let isOc = $state(item.is_oc);
|
|
||||||
let isSfw = $state(item.is_sfw);
|
|
||||||
let isNsfw = $state(item.is_nsfw);
|
|
||||||
let isNsfl = $state(item.is_nsfl);
|
|
||||||
|
|
||||||
// Sync when item prop changes (navigation)
|
|
||||||
$effect(() => {
|
|
||||||
favorites = item.favorites;
|
|
||||||
tags = item.tags;
|
|
||||||
isPinned = item.is_pinned;
|
|
||||||
isOc = item.is_oc;
|
|
||||||
isSfw = item.is_sfw;
|
|
||||||
isNsfw = item.is_nsfw;
|
|
||||||
isNsfl = item.is_nsfl;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Live SSE updates ──────────────────────────────────────────────────────
|
|
||||||
const unsubSSE = onSSE((event) => {
|
|
||||||
if (event.type === 'favorites') {
|
|
||||||
const d = event.data as { item_id: number; favs: FavoriteUser[] };
|
|
||||||
if (d.item_id === item.id) favorites = d.favs;
|
|
||||||
} else if (event.type === 'tags') {
|
|
||||||
const d = event.data as { item_id: number; tags: Tag[] };
|
|
||||||
if (d.item_id === item.id) {
|
|
||||||
tags = d.tags;
|
|
||||||
// Re-derive rating state from the fresh tag list
|
|
||||||
const SFW_ID = 1, NSFW_ID = 2;
|
|
||||||
const ids = d.tags.map(t => t.id);
|
|
||||||
isSfw = ids.includes(SFW_ID);
|
|
||||||
isNsfw = ids.includes(NSFW_ID);
|
|
||||||
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
|
|
||||||
}
|
|
||||||
} else if (event.type === 'delete_item') {
|
|
||||||
const d = event.data as { id: number };
|
|
||||||
if (d.id === item.id) goto('/');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
onDestroy(unsubSSE);
|
|
||||||
|
|
||||||
const cat = $derived(mimeCategory(item.mime));
|
|
||||||
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
|
|
||||||
|
|
||||||
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
|
|
||||||
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
|
|
||||||
|
|
||||||
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
|
|
||||||
|
|
||||||
// Rating derived from local mutable state (so cycle updates instantly)
|
|
||||||
const ratingClass = $derived(
|
|
||||||
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
|
|
||||||
);
|
|
||||||
const ratingLabel = $derived(
|
|
||||||
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
|
|
||||||
);
|
|
||||||
|
|
||||||
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
|
|
||||||
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
|
|
||||||
|
|
||||||
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
|
|
||||||
let ratingCycling = $state(false);
|
|
||||||
async function handleCycleRating() {
|
|
||||||
if (!isMod) return;
|
|
||||||
ratingCycling = true;
|
|
||||||
try {
|
|
||||||
const res = await cycleRating(item.id);
|
|
||||||
if (res.success) {
|
|
||||||
isSfw = res.rating_class === 'sfw';
|
|
||||||
isNsfw = res.rating_class === 'nsfw';
|
|
||||||
isNsfl = res.rating_class === 'nsfl';
|
|
||||||
flash(`Rating → ${res.rating_label}`, 'success');
|
|
||||||
} else {
|
|
||||||
flash(res.msg ?? 'Failed to cycle rating', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
} finally {
|
|
||||||
ratingCycling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Keyboard navigation ───────────────────────────────────────────────────
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
||||||
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
|
|
||||||
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tag input state ───────────────────────────────────────────────────────
|
|
||||||
let tagInput = $state('');
|
|
||||||
let tagAdding = $state(false);
|
|
||||||
let showTagInput = $state(false);
|
|
||||||
let suggestions = $state<string[]>([]);
|
|
||||||
|
|
||||||
async function submitTag(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = tagInput.trim();
|
|
||||||
if (!name) return;
|
|
||||||
tagAdding = true;
|
|
||||||
try {
|
|
||||||
const res = await addTag(item.id, name);
|
|
||||||
if (res.success) {
|
|
||||||
tags = res.tags;
|
|
||||||
tagInput = '';
|
|
||||||
showTagInput = false;
|
|
||||||
flash(`Tag "${name}" added`, 'success');
|
|
||||||
} else {
|
|
||||||
flash(res.msg ?? 'Failed to add tag', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
} finally {
|
|
||||||
tagAdding = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveTag(tag: Tag) {
|
|
||||||
try {
|
|
||||||
const res = await deleteTag(item.id, tag.tag);
|
|
||||||
if (res.success) {
|
|
||||||
tags = res.tags;
|
|
||||||
flash(`Tag "${tag.tag}" removed`, 'success');
|
|
||||||
} else {
|
|
||||||
flash(res.msg ?? 'Failed to remove tag', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Favorite ──────────────────────────────────────────────────────────────
|
|
||||||
let favLoading = $state(false);
|
|
||||||
async function handleFav() {
|
|
||||||
if (!$session) { flash('Log in to favorite', 'warning'); return; }
|
|
||||||
favLoading = true;
|
|
||||||
try {
|
|
||||||
const res = await toggleFav(item.id);
|
|
||||||
if (res.success) favorites = res.favs;
|
|
||||||
} catch {
|
|
||||||
flash('Failed to update favorite', 'error');
|
|
||||||
} finally {
|
|
||||||
favLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete ────────────────────────────────────────────────────────────────
|
|
||||||
async function handleDelete() {
|
|
||||||
const reason = prompt('Reason for deletion (optional):') ?? '';
|
|
||||||
if (reason === null) return; // cancelled
|
|
||||||
try {
|
|
||||||
const res = await deleteItem(item.id, reason);
|
|
||||||
if (res.success) {
|
|
||||||
flash('Item deleted', 'success');
|
|
||||||
goto('/');
|
|
||||||
} else {
|
|
||||||
flash(res.msg ?? 'Delete failed', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pin ───────────────────────────────────────────────────────────────────
|
|
||||||
async function handlePin() {
|
|
||||||
try {
|
|
||||||
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
|
|
||||||
if (res.success) {
|
|
||||||
isPinned = res.pinned;
|
|
||||||
broadcastPin(item.id, res.pinned); // patch grid card live
|
|
||||||
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
|
|
||||||
} else {
|
|
||||||
flash('Pin failed', 'error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
flash('Network error', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
|
|
||||||
|
|
||||||
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
|
|
||||||
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
|
|
||||||
|
|
||||||
<!-- Tags card -->
|
|
||||||
<div class="card bg-base-200 border border-base-300">
|
|
||||||
<div class="card-body p-3 gap-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
|
|
||||||
{#if $session}
|
|
||||||
<button
|
|
||||||
class="btn btn-xs btn-ghost"
|
|
||||||
onclick={() => showTagInput = !showTagInput}
|
|
||||||
aria-label="Add tag"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tag input -->
|
|
||||||
{#if showTagInput}
|
|
||||||
<form onsubmit={submitTag} class="flex gap-1">
|
|
||||||
<input
|
|
||||||
bind:value={tagInput}
|
|
||||||
type="text"
|
|
||||||
placeholder="tag name…"
|
|
||||||
class="input input-xs input-bordered flex-1"
|
|
||||||
disabled={tagAdding}
|
|
||||||
autocomplete="off"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-xs btn-primary"
|
|
||||||
disabled={tagAdding || !tagInput.trim()}
|
|
||||||
>
|
|
||||||
{#if tagAdding}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
OK
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if tags.length > 0}
|
|
||||||
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs opacity-40">No tags yet.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- xD Score -->
|
|
||||||
{#if item.xd_tier > 0}
|
|
||||||
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
|
|
||||||
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
|
|
||||||
{item.xd_label}
|
|
||||||
<span class="opacity-70 text-xs">{item.xd_score}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="card bg-base-200 border border-base-300">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<Comments itemId={item.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
|
|
||||||
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
|
|
||||||
|
|
||||||
{#if item.title}
|
|
||||||
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Media + prev/next arrows -->
|
|
||||||
<div class="relative group">
|
|
||||||
<!-- Prev -->
|
|
||||||
<a
|
|
||||||
href={prevUrl ?? '#'}
|
|
||||||
aria-disabled={!prevUrl}
|
|
||||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
|
||||||
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity
|
|
||||||
{!prevUrl ? 'pointer-events-none' : ''}"
|
|
||||||
>
|
|
||||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
|
||||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="rounded-lg overflow-hidden bg-base-300">
|
|
||||||
{#if cat === 'youtube'}
|
|
||||||
<YouTubeEmbed videoId={ytId} />
|
|
||||||
{:else if cat === 'video'}
|
|
||||||
<VideoPlayer src={item.dest} mime={item.mime} />
|
|
||||||
{:else if cat === 'audio'}
|
|
||||||
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
|
|
||||||
{:else if cat === 'image'}
|
|
||||||
<ImageViewer src={item.dest} alt="Item {item.id}" />
|
|
||||||
{:else if cat === 'pdf'}
|
|
||||||
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center justify-center p-12 gap-4">
|
|
||||||
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
|
|
||||||
<a href={item.dest} download class="btn btn-primary gap-2">
|
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Next -->
|
|
||||||
<a
|
|
||||||
href={nextUrl ?? '#'}
|
|
||||||
aria-disabled={!nextUrl}
|
|
||||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
|
||||||
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity
|
|
||||||
{!nextUrl ? 'pointer-events-none' : ''}"
|
|
||||||
>
|
|
||||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
|
||||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata / action bar -->
|
|
||||||
<div class="card bg-base-200 border border-base-300">
|
|
||||||
<div class="card-body p-3 gap-2">
|
|
||||||
|
|
||||||
<!-- Prev / Random / Next -->
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
|
|
||||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
|
||||||
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
|
|
||||||
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
|
|
||||||
</a>
|
|
||||||
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
|
|
||||||
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
|
|
||||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
|
||||||
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
|
|
||||||
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
|
||||||
|
|
||||||
<!-- Item metadata row -->
|
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
||||||
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
|
|
||||||
#{item.id}
|
|
||||||
</a>
|
|
||||||
{#if isMod}
|
|
||||||
<button
|
|
||||||
onclick={handleCycleRating}
|
|
||||||
disabled={ratingCycling}
|
|
||||||
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
|
|
||||||
title="Cycle rating: {ratingLabel} → {isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
|
|
||||||
aria-label="Cycle rating"
|
|
||||||
>
|
|
||||||
{#if ratingCycling}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
|
|
||||||
{/if}
|
|
||||||
{ratingLabel}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
|
|
||||||
{/if}
|
|
||||||
<a href="/user/{item.username.toLowerCase()}"
|
|
||||||
class="font-medium hover:text-primary transition-colors"
|
|
||||||
style={item.author_color ? `color: ${item.author_color}` : ''}>
|
|
||||||
{item.author_display_name || item.username}
|
|
||||||
</a>
|
|
||||||
{#if isOc}
|
|
||||||
<span class="badge badge-sm badge-accent">OC</span>
|
|
||||||
{/if}
|
|
||||||
{#if isPinned}
|
|
||||||
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
|
|
||||||
{/if}
|
|
||||||
{#if item.primaryHall}
|
|
||||||
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
|
|
||||||
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
|
|
||||||
{item.primaryHall.name}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
|
|
||||||
{item.timestamp.timeago}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex items-center flex-wrap gap-1 pt-1">
|
|
||||||
<!-- Favorite -->
|
|
||||||
<button
|
|
||||||
onclick={handleFav}
|
|
||||||
disabled={favLoading || !$session}
|
|
||||||
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
|
|
||||||
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
|
|
||||||
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
|
|
||||||
>
|
|
||||||
{#if favLoading}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
|
|
||||||
{/if}
|
|
||||||
{#if favorites.length > 0}
|
|
||||||
<span class="text-xs">{favorites.length}</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Subscribe (stub) -->
|
|
||||||
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
|
|
||||||
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Link copy -->
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
title="Copy link"
|
|
||||||
aria-label="Copy link"
|
|
||||||
onclick={() => {
|
|
||||||
navigator.clipboard.writeText(location.href);
|
|
||||||
flash('Link copied!', 'success');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Mod / owner actions -->
|
|
||||||
{#if canManage}
|
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
|
||||||
<!-- Pin/Unpin -->
|
|
||||||
<button
|
|
||||||
onclick={handlePin}
|
|
||||||
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
|
|
||||||
title="{isPinned ? 'Unpin' : 'Pin'}"
|
|
||||||
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<!-- Delete -->
|
|
||||||
<button
|
|
||||||
onclick={handleDelete}
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
title="Delete"
|
|
||||||
aria-label="Delete item"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Favorites avatars -->
|
|
||||||
{#if favorites.length > 0}
|
|
||||||
<div class="flex flex-wrap gap-1 pt-1">
|
|
||||||
{#each favorites as fav (fav.user)}
|
|
||||||
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
|
|
||||||
<div class="avatar">
|
|
||||||
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
|
|
||||||
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
|
|
||||||
{#if fav.avatar_file}
|
|
||||||
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
|
|
||||||
{:else if fav.avatar}
|
|
||||||
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<img src="/a/default.png" alt={fav.user} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Tag } from '$lib/types/item';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tags: Tag[];
|
|
||||||
canRemove?: boolean;
|
|
||||||
onRemove?: (tag: Tag) => void;
|
|
||||||
}
|
|
||||||
let { tags, canRemove = false, onRemove }: Props = $props();
|
|
||||||
|
|
||||||
// Map badge class names from the existing tag badge values
|
|
||||||
const badgeClass = (badge: string) => {
|
|
||||||
if (badge === 'badge-sfw') return 'badge-sfw';
|
|
||||||
if (badge === 'badge-nsfw') return 'badge-nsfw';
|
|
||||||
if (badge === 'badge-nsfl') return 'badge-nsfl';
|
|
||||||
return 'badge-neutral';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
{#each tags as tag (tag.id)}
|
|
||||||
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
|
|
||||||
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
|
|
||||||
{tag.tag}
|
|
||||||
</a>
|
|
||||||
{#if canRemove && onRemove}
|
|
||||||
<button
|
|
||||||
class="hover:text-error ml-0.5 transition-colors"
|
|
||||||
onclick={() => onRemove?.(tag)}
|
|
||||||
aria-label="Remove tag {tag.tag}"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-xmark text-[10px]"></i>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { session, loggedIn } from '$lib/stores/session';
|
|
||||||
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { getRandom } from '$lib/api/items';
|
|
||||||
import { flash } from '$lib/stores/flash';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
async function goRandom() {
|
|
||||||
try {
|
|
||||||
const res = await getRandom();
|
|
||||||
if (res.success) goto(res.url);
|
|
||||||
} catch {
|
|
||||||
flash('Could not fetch a random item', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
|
|
||||||
// OR just use them in the template with $ prefix — both work, but $ in templates
|
|
||||||
// is fine for Svelte writable stores even in runes mode.
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
|
|
||||||
<!-- Logo / Home -->
|
|
||||||
<div class="navbar-start gap-2">
|
|
||||||
<a href="/" class="flex items-center gap-2 group">
|
|
||||||
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
|
|
||||||
f0ckm
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: actions -->
|
|
||||||
<div class="navbar-center gap-1 hidden sm:flex">
|
|
||||||
<!-- Random button -->
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-sm gap-1"
|
|
||||||
onclick={goRandom}
|
|
||||||
title="Random item"
|
|
||||||
aria-label="Random item"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
|
|
||||||
<span class="hidden md:inline">Random</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Rating filter badge -->
|
|
||||||
<div class="dropdown dropdown-bottom">
|
|
||||||
<button
|
|
||||||
tabindex="0"
|
|
||||||
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
|
|
||||||
aria-label="Content filter"
|
|
||||||
>
|
|
||||||
{$filterLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: user area -->
|
|
||||||
<div class="navbar-end gap-2">
|
|
||||||
{#if $loggedIn}
|
|
||||||
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
|
|
||||||
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
|
|
||||||
<span class="hidden sm:inline">Upload</span>
|
|
||||||
</a>
|
|
||||||
<!-- Avatar / user menu -->
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
|
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
|
|
||||||
{#if $session?.avatar_file}
|
|
||||||
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
|
|
||||||
{:else if $session?.avatar}
|
|
||||||
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
|
|
||||||
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
|
|
||||||
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
|
|
||||||
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
|
|
||||||
{#if $session?.admin || $session?.is_moderator}
|
|
||||||
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
|
|
||||||
{/if}
|
|
||||||
<li class="divider my-1"></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/logout">
|
|
||||||
<button type="submit" class="text-error w-full text-left">
|
|
||||||
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
|
|
||||||
<a href="/register" class="btn btn-primary btn-sm">Register</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
mime: string;
|
|
||||||
coverart?: string;
|
|
||||||
}
|
|
||||||
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
|
|
||||||
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
|
|
||||||
>
|
|
||||||
<!-- Dark overlay over coverart -->
|
|
||||||
<div class="absolute inset-0 bg-black/60"></div>
|
|
||||||
<!-- svelte-ignore a11y-media-has-caption -->
|
|
||||||
<audio
|
|
||||||
{src}
|
|
||||||
loop
|
|
||||||
controls
|
|
||||||
preload="auto"
|
|
||||||
class="relative z-10 w-full max-w-md"
|
|
||||||
></audio>
|
|
||||||
</div>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
}
|
|
||||||
let { src, alt = 'Image' }: Props = $props();
|
|
||||||
|
|
||||||
let expanded = $state(false);
|
|
||||||
let imgEl = $state<HTMLImageElement | undefined>();
|
|
||||||
|
|
||||||
function toggleExpand() {
|
|
||||||
expanded = !expanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC to close modal
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape' && expanded) expanded = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
|
||||||
|
|
||||||
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
|
|
||||||
<img
|
|
||||||
bind:this={imgEl}
|
|
||||||
{src}
|
|
||||||
{alt}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onclick={toggleExpand}
|
|
||||||
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
|
|
||||||
onclick={toggleExpand}
|
|
||||||
aria-label="Expand"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-expand"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fullscreen modal -->
|
|
||||||
{#if expanded}
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
|
|
||||||
onclick={toggleExpand}
|
|
||||||
>
|
|
||||||
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
|
|
||||||
<button
|
|
||||||
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
|
|
||||||
onclick={toggleExpand}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-xmark text-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
mime: string;
|
|
||||||
autoplay?: boolean;
|
|
||||||
}
|
|
||||||
let { src, mime, autoplay = false }: Props = $props();
|
|
||||||
|
|
||||||
let videoEl = $state<HTMLVideoElement | undefined>();
|
|
||||||
|
|
||||||
// Keyboard shortcut: space to toggle play/pause
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.target !== document.body) return;
|
|
||||||
if (e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (videoEl?.paused) videoEl.play();
|
|
||||||
else videoEl?.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
|
||||||
|
|
||||||
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
|
|
||||||
<!-- svelte-ignore a11y-media-has-caption -->
|
|
||||||
<video
|
|
||||||
bind:this={videoEl}
|
|
||||||
{src}
|
|
||||||
{autoplay}
|
|
||||||
loop
|
|
||||||
playsinline
|
|
||||||
controls
|
|
||||||
class="max-h-[80dvh] w-full object-contain"
|
|
||||||
preload="auto"
|
|
||||||
></video>
|
|
||||||
</div>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
videoId: string; // e.g. "dQw4w9WgXcQ"
|
|
||||||
}
|
|
||||||
let { videoId }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
|
|
||||||
<iframe
|
|
||||||
class="absolute inset-0 w-full h-full"
|
|
||||||
src="https://www.youtube.com/embed/{videoId}"
|
|
||||||
title="YouTube video"
|
|
||||||
frameborder="0"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
||||||
referrerpolicy="strict-origin-when-cross-origin"
|
|
||||||
allowfullscreen
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
|
|
||||||
|
|
||||||
const iconMap: Record<FlashType, string> = {
|
|
||||||
info: 'fa-solid fa-circle-info',
|
|
||||||
success: 'fa-solid fa-circle-check',
|
|
||||||
warning: 'fa-solid fa-triangle-exclamation',
|
|
||||||
error: 'fa-solid fa-circle-xmark'
|
|
||||||
};
|
|
||||||
|
|
||||||
const alertMap: Record<FlashType, string> = {
|
|
||||||
info: 'alert-info',
|
|
||||||
success: 'alert-success',
|
|
||||||
warning: 'alert-warning',
|
|
||||||
error: 'alert-error'
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
|
||||||
{#each $flashes as msg (msg.id)}
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
|
|
||||||
animate-[slide-up_0.2s_ease-out]"
|
|
||||||
>
|
|
||||||
<i class="{iconMap[msg.type]}"></i>
|
|
||||||
<span class="flex-1 text-sm">{msg.text}</span>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
onclick={() => dismiss(msg.id)}
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Pagination, ItemLink } from '$lib/types/item';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pagination: Pagination;
|
|
||||||
link: ItemLink;
|
|
||||||
}
|
|
||||||
let { pagination, link }: Props = $props();
|
|
||||||
|
|
||||||
const base = $derived(link.main + link.path);
|
|
||||||
const suffix = $derived(link.suffix ?? '');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if pagination.end > 1}
|
|
||||||
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
|
|
||||||
<!-- Prev -->
|
|
||||||
<a
|
|
||||||
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
|
|
||||||
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
|
|
||||||
aria-disabled={!pagination.prev}
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- First page -->
|
|
||||||
{#if pagination.cheat[0] > 1}
|
|
||||||
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
|
|
||||||
{#if pagination.cheat[0] > 2}
|
|
||||||
<span class="px-1 opacity-40">…</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Page range -->
|
|
||||||
{#each pagination.cheat as p}
|
|
||||||
<a
|
|
||||||
href="{base}{p}{suffix}"
|
|
||||||
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Last page -->
|
|
||||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
|
|
||||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
|
|
||||||
<span class="px-1 opacity-40">…</span>
|
|
||||||
{/if}
|
|
||||||
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Next -->
|
|
||||||
<a
|
|
||||||
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
|
|
||||||
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
|
|
||||||
aria-disabled={!pagination.next}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
// ── Flash message type ────────────────────────────────────────────────────────
|
|
||||||
export type FlashType = 'info' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
export interface FlashMessage {
|
|
||||||
id: number;
|
|
||||||
text: string;
|
|
||||||
type: FlashType;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _nextId = 0;
|
|
||||||
export const flashes = writable<FlashMessage[]>([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a flash message. Auto-dismisses after `duration` ms.
|
|
||||||
* Returns the message ID so callers can dismiss early if needed.
|
|
||||||
*/
|
|
||||||
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
|
|
||||||
const id = ++_nextId;
|
|
||||||
flashes.update((q) => [...q, { id, text, type }]);
|
|
||||||
setTimeout(() => dismiss(id), duration);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dismiss(id: number): void {
|
|
||||||
flashes.update((q) => q.filter((m) => m.id !== id));
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Lightweight broadcast store for pin/unpin events.
|
|
||||||
* ItemView fires it after a successful pin action;
|
|
||||||
* the main grid subscribes to patch the item card immediately.
|
|
||||||
*/
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export interface PinEvent {
|
|
||||||
itemId: number;
|
|
||||||
pinned: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Null when idle, set to an event when pin/unpin succeeds
|
|
||||||
export const pinEvent = writable<PinEvent | null>(null);
|
|
||||||
|
|
||||||
export function broadcastPin(itemId: number, pinned: boolean) {
|
|
||||||
pinEvent.set({ itemId, pinned });
|
|
||||||
// Reset so subsequent identical events still trigger subscribers
|
|
||||||
setTimeout(() => pinEvent.set(null), 0);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { writable, derived, get } from 'svelte/store';
|
|
||||||
|
|
||||||
// ── Content mode ─────────────────────────────────────────────────────────────
|
|
||||||
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
|
|
||||||
export const activeMode = writable<number>(3);
|
|
||||||
|
|
||||||
// ── Multi-rating filter ───────────────────────────────────────────────────────
|
|
||||||
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
|
|
||||||
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
|
|
||||||
export const activeRatings = writable<Rating[]>([]);
|
|
||||||
|
|
||||||
// ── MIME filter ───────────────────────────────────────────────────────────────
|
|
||||||
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
|
|
||||||
export const activeMimes = writable<MimeFilter[]>([]);
|
|
||||||
|
|
||||||
// ── Derived: ratings query param string for API calls ─────────────────────────
|
|
||||||
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
|
|
||||||
|
|
||||||
// ── Derived: readable label for the filter badge ─────────────────────────────
|
|
||||||
export const filterLabel = derived(
|
|
||||||
[activeMode, activeRatings],
|
|
||||||
([$mode, $ratings]) => {
|
|
||||||
if ($ratings.length > 0) {
|
|
||||||
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
|
|
||||||
return $ratings.map((r) => abbr[r]).join('+');
|
|
||||||
}
|
|
||||||
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
|
|
||||||
return labels[$mode] ?? 'ALL';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Persistence helpers ───────────────────────────────────────────────────────
|
|
||||||
// Call these after receiving the session from the server to hydrate from cookie/session.
|
|
||||||
export function initPreferences(opts: { mode: number; ratings?: string }) {
|
|
||||||
activeMode.set(opts.mode ?? 3);
|
|
||||||
if (opts.ratings) {
|
|
||||||
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
|
|
||||||
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
|
|
||||||
);
|
|
||||||
activeRatings.set(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a mode number → server mode number (for API calls when using multi-rating). */
|
|
||||||
export function getServerMode(): number {
|
|
||||||
const ratings = get(activeRatings);
|
|
||||||
if (ratings.length === 0) return get(activeMode);
|
|
||||||
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
|
|
||||||
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
|
||||||
import type { SessionUser } from '$lib/types/session';
|
|
||||||
|
|
||||||
/** The current authenticated user, or null if not logged in. */
|
|
||||||
export const session = writable<SessionUser | null>(null);
|
|
||||||
|
|
||||||
/** Convenience derived — true when logged in. */
|
|
||||||
export const loggedIn = derived(session, ($s) => $s !== null);
|
|
||||||
|
|
||||||
/** True when the user has admin or moderator role. */
|
|
||||||
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* SSE store — connects to the backend's /api/notifications/stream endpoint.
|
|
||||||
*
|
|
||||||
* Emits typed events that components subscribe to with `onSSE()`.
|
|
||||||
* Auto-reconnects with exponential backoff on disconnect.
|
|
||||||
* Client-only: never runs during SSR.
|
|
||||||
*/
|
|
||||||
import { writable, get } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
// ── Event types broadcast by the backend ─────────────────────────────────────
|
|
||||||
export interface SSEComment {
|
|
||||||
type: 'comment' | 'delete';
|
|
||||||
id?: number;
|
|
||||||
item_id: number;
|
|
||||||
comment_id?: number;
|
|
||||||
body?: string;
|
|
||||||
username?: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
avatar?: string | null;
|
|
||||||
avatar_file?: string | null;
|
|
||||||
username_color?: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
parent_id?: number | null;
|
|
||||||
xd_score?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSEFavorites {
|
|
||||||
item_id: number;
|
|
||||||
favs: Array<{
|
|
||||||
user: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
avatar_file: string | null;
|
|
||||||
username_color: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSETags {
|
|
||||||
item_id: number;
|
|
||||||
fresh: boolean;
|
|
||||||
tags: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSENewItem {
|
|
||||||
id: number;
|
|
||||||
mime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSEDeleteItem {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSEEvent {
|
|
||||||
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
|
|
||||||
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connection state ──────────────────────────────────────────────────────────
|
|
||||||
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
||||||
export const sseState = writable<SSEState>('disconnected');
|
|
||||||
|
|
||||||
// ── Listener registry ─────────────────────────────────────────────────────────
|
|
||||||
type SSEListener = (event: SSEEvent) => void;
|
|
||||||
const listeners = new Set<SSEListener>();
|
|
||||||
|
|
||||||
/** Subscribe to SSE events. Returns an unsubscribe function. */
|
|
||||||
export function onSSE(fn: SSEListener): () => void {
|
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal connection state ─────────────────────────────────────────────────
|
|
||||||
let es: EventSource | null = null;
|
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let retryDelay = 2000;
|
|
||||||
const MAX_RETRY = 30_000;
|
|
||||||
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
|
|
||||||
|
|
||||||
function dispatch(raw: SSEEvent) {
|
|
||||||
for (const fn of listeners) {
|
|
||||||
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRetry() {
|
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
|
||||||
retryTimer = setTimeout(connect, retryDelay);
|
|
||||||
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function connect() {
|
|
||||||
if (!browser) return;
|
|
||||||
if (es) { es.close(); es = null; }
|
|
||||||
|
|
||||||
sseState.set('connecting');
|
|
||||||
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
|
|
||||||
|
|
||||||
es.onopen = () => {
|
|
||||||
sseState.set('connected');
|
|
||||||
retryDelay = 2000; // reset backoff on successful connect
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data) as SSEEvent;
|
|
||||||
dispatch(data);
|
|
||||||
} catch {
|
|
||||||
// ignore malformed frames
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
sseState.set('error');
|
|
||||||
es?.close();
|
|
||||||
es = null;
|
|
||||||
scheduleRetry();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disconnect() {
|
|
||||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
||||||
es?.close();
|
|
||||||
es = null;
|
|
||||||
sseState.set('disconnected');
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
// ── WebSocket message types ───────────────────────────────────────────────────
|
|
||||||
export type WSMessage =
|
|
||||||
| { type: 'pong' }
|
|
||||||
| { type: 'notification'; count: number; item?: unknown }
|
|
||||||
| { type: 'comment:new'; itemId: number; comment: unknown }
|
|
||||||
| { type: 'comment:delete'; itemId: number; commentId: number }
|
|
||||||
| { type: 'item:tag_added'; itemId: number; tag: unknown }
|
|
||||||
| { type: 'item:favorited'; itemId: number; user: string }
|
|
||||||
| { type: 'global:activity'; payload: unknown };
|
|
||||||
|
|
||||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
|
||||||
export const wsConnected = writable(false);
|
|
||||||
|
|
||||||
let _socket: WebSocket | null = null;
|
|
||||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let _reconnectDelay = 1000;
|
|
||||||
|
|
||||||
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
|
|
||||||
|
|
||||||
/** Connect to the WebSocket server. Call once from the root layout. */
|
|
||||||
export function wsConnect(): void {
|
|
||||||
if (typeof window === 'undefined') return; // SSR guard
|
|
||||||
if (_socket?.readyState === WebSocket.OPEN) return;
|
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
_socket = new WebSocket(`${proto}://${location.host}/ws`);
|
|
||||||
|
|
||||||
_socket.onopen = () => {
|
|
||||||
wsConnected.set(true);
|
|
||||||
_reconnectDelay = 1000; // reset backoff on successful connect
|
|
||||||
_ping();
|
|
||||||
};
|
|
||||||
|
|
||||||
_socket.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.data) as WSMessage;
|
|
||||||
// Dispatch to typed listeners
|
|
||||||
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
|
|
||||||
_listeners.get('*')?.forEach((fn) => fn(msg));
|
|
||||||
} catch {
|
|
||||||
// ignore malformed messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_socket.onclose = () => {
|
|
||||||
wsConnected.set(false);
|
|
||||||
_socket = null;
|
|
||||||
// Exponential backoff reconnect
|
|
||||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
|
||||||
_reconnectTimer = setTimeout(() => {
|
|
||||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
|
||||||
wsConnect();
|
|
||||||
}, _reconnectDelay);
|
|
||||||
};
|
|
||||||
|
|
||||||
_socket.onerror = () => {
|
|
||||||
_socket?.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Send a typed message to the server. */
|
|
||||||
export function wsSend(msg: object): void {
|
|
||||||
if (_socket?.readyState === WebSocket.OPEN) {
|
|
||||||
_socket.send(JSON.stringify(msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subscribe to a specific message type. Returns an unsubscribe function. */
|
|
||||||
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
|
|
||||||
if (!_listeners.has(type)) _listeners.set(type, new Set());
|
|
||||||
_listeners.get(type)!.add(fn);
|
|
||||||
return () => _listeners.get(type)?.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
function _ping() {
|
|
||||||
if (_pingTimer) clearInterval(_pingTimer);
|
|
||||||
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export interface Comment {
|
|
||||||
id: number;
|
|
||||||
item_id: number;
|
|
||||||
username: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
avatar_file: string | null;
|
|
||||||
username_color: string | null;
|
|
||||||
content: string; // may be HTML (server-rendered)
|
|
||||||
stamp: string; // human-readable relative time
|
|
||||||
stamp_full: string; // ISO timestamp
|
|
||||||
is_deleted: boolean;
|
|
||||||
reply_to: number | null;
|
|
||||||
poll?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommentsResponse {
|
|
||||||
success: boolean;
|
|
||||||
comments: Comment[];
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
|
|
||||||
export interface GridItem {
|
|
||||||
id: number;
|
|
||||||
mime: string;
|
|
||||||
dest: string;
|
|
||||||
username: string;
|
|
||||||
display_name: string | null;
|
|
||||||
is_pinned: boolean;
|
|
||||||
is_oc: boolean;
|
|
||||||
xd_score: number;
|
|
||||||
xd_tier: number;
|
|
||||||
xd_label: string;
|
|
||||||
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
|
|
||||||
tag_id: number | null;
|
|
||||||
thumb_size: 1 | 2;
|
|
||||||
has_notification: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
|
|
||||||
export interface FullItem {
|
|
||||||
id: number;
|
|
||||||
mime: string;
|
|
||||||
dest: string;
|
|
||||||
username: string;
|
|
||||||
author_id: number | null;
|
|
||||||
author_color: string | null;
|
|
||||||
author_display_name: string | null;
|
|
||||||
author_avatar: string | null;
|
|
||||||
author_avatar_file: string | null;
|
|
||||||
title: string | null;
|
|
||||||
src: { long: string; short: string };
|
|
||||||
thumbnail: string;
|
|
||||||
og_thumbnail: string;
|
|
||||||
og_description: string;
|
|
||||||
coverart: string;
|
|
||||||
size: string;
|
|
||||||
checksum: string | null;
|
|
||||||
timestamp: { timeago: string; timefull: string };
|
|
||||||
favorites: FavoriteUser[];
|
|
||||||
tags: Tag[];
|
|
||||||
halls: Hall[];
|
|
||||||
user_halls: Hall[];
|
|
||||||
is_nsfw: boolean;
|
|
||||||
is_nsfl: boolean;
|
|
||||||
is_sfw: boolean;
|
|
||||||
is_pinned: boolean;
|
|
||||||
is_comments_locked: boolean;
|
|
||||||
is_oc: boolean;
|
|
||||||
is_repost: boolean;
|
|
||||||
reposts: RepostItem[];
|
|
||||||
width: number | null;
|
|
||||||
height: number | null;
|
|
||||||
original_filename: string | null;
|
|
||||||
xd_score: number;
|
|
||||||
xd_tier: number;
|
|
||||||
xd_label: string;
|
|
||||||
primaryHall: Hall | null;
|
|
||||||
otherHalls: Hall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pagination ───────────────────────────────────────────────────────────────
|
|
||||||
export interface Pagination {
|
|
||||||
prev: number | null;
|
|
||||||
next: number | null;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
current: number;
|
|
||||||
page: number;
|
|
||||||
cheat: number[];
|
|
||||||
location?: string;
|
|
||||||
suffix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Link context ─────────────────────────────────────────────────────────────
|
|
||||||
export interface ItemLink {
|
|
||||||
main: string;
|
|
||||||
mainDisplay?: string;
|
|
||||||
path: string;
|
|
||||||
suffix: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tags ─────────────────────────────────────────────────────────────────────
|
|
||||||
export interface Tag {
|
|
||||||
id: number;
|
|
||||||
tag: string;
|
|
||||||
normalized: string;
|
|
||||||
badge: string;
|
|
||||||
display_name: string | null;
|
|
||||||
user: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Halls ────────────────────────────────────────────────────────────────────
|
|
||||||
export interface Hall {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Favorites ────────────────────────────────────────────────────────────────
|
|
||||||
export interface FavoriteUser {
|
|
||||||
user: string;
|
|
||||||
avatar: string | null;
|
|
||||||
avatar_file: string | null;
|
|
||||||
username_color: string | null;
|
|
||||||
display_name: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reposts ──────────────────────────────────────────────────────────────────
|
|
||||||
export interface RepostItem {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
stamp: number;
|
|
||||||
match_type: 'checksum' | 'phash';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API response wrappers ─────────────────────────────────────────────────────
|
|
||||||
export interface ItemsResponse {
|
|
||||||
success: boolean;
|
|
||||||
items: GridItem[];
|
|
||||||
pagination: Pagination;
|
|
||||||
total: number;
|
|
||||||
link: ItemLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ItemResponse {
|
|
||||||
success: boolean;
|
|
||||||
item: FullItem | null;
|
|
||||||
pagination: Pagination;
|
|
||||||
link: ItemLink;
|
|
||||||
isSubscribed: boolean;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RandomResponse {
|
|
||||||
success: boolean;
|
|
||||||
id: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export interface SessionUser {
|
|
||||||
id: number;
|
|
||||||
user: string;
|
|
||||||
login: string;
|
|
||||||
admin: boolean;
|
|
||||||
is_moderator: boolean;
|
|
||||||
mode: number;
|
|
||||||
theme: string;
|
|
||||||
avatar: string | null;
|
|
||||||
avatar_file: string | null;
|
|
||||||
username_color: string | null;
|
|
||||||
display_name: string | null;
|
|
||||||
use_new_layout: boolean;
|
|
||||||
excluded_tags: number[];
|
|
||||||
min_xd_score: number;
|
|
||||||
uploads_remaining: number | undefined;
|
|
||||||
pending_count?: number;
|
|
||||||
language: string | null;
|
|
||||||
disable_autoplay: boolean;
|
|
||||||
disable_swiping: boolean;
|
|
||||||
csrf_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionResponse {
|
|
||||||
loggedIn: boolean;
|
|
||||||
user: SessionUser | null;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wrapper around fetch that automatically injects the CSRF token as a header
|
|
||||||
* on all state-changing requests (POST, PUT, PATCH, DELETE).
|
|
||||||
*
|
|
||||||
* The token is read from the session store at call time so it's always fresh.
|
|
||||||
*/
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { session } from '$lib/stores/session';
|
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
||||||
|
|
||||||
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
|
||||||
const method = (init.method ?? 'GET').toUpperCase();
|
|
||||||
if (SAFE_METHODS.has(method)) return fetch(input, init);
|
|
||||||
|
|
||||||
const token = get(session)?.csrf_token;
|
|
||||||
if (!token) return fetch(input, init); // not logged in — let backend return 401
|
|
||||||
|
|
||||||
const headers = new Headers(init.headers ?? {});
|
|
||||||
headers.set('X-CSRF-Token', token);
|
|
||||||
|
|
||||||
return fetch(input, { ...init, headers });
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/** Map a MIME type to a simple category string. */
|
|
||||||
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
|
|
||||||
if (mime === 'video/youtube') return 'youtube';
|
|
||||||
if (mime.startsWith('video/')) return 'video';
|
|
||||||
if (mime.startsWith('audio/')) return 'audio';
|
|
||||||
if (mime.startsWith('image/')) return 'image';
|
|
||||||
if (mime === 'application/pdf') return 'pdf';
|
|
||||||
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
|
|
||||||
if (mime.startsWith('application/')) return 'archive';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return a Font Awesome icon class for a MIME category. */
|
|
||||||
export function mimeIcon(mime: string): string {
|
|
||||||
const cat = mimeCategory(mime);
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
youtube: 'fa-brands fa-youtube',
|
|
||||||
video: 'fa-solid fa-film',
|
|
||||||
audio: 'fa-solid fa-music',
|
|
||||||
image: 'fa-solid fa-image',
|
|
||||||
flash: 'fa-solid fa-bolt',
|
|
||||||
pdf: 'fa-solid fa-file-pdf',
|
|
||||||
archive: 'fa-solid fa-file-zipper',
|
|
||||||
unknown: 'fa-solid fa-file'
|
|
||||||
};
|
|
||||||
return map[cat] ?? map.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if the MIME type represents inline-playable media. */
|
|
||||||
export function isPlayable(mime: string): boolean {
|
|
||||||
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
|
|
||||||
* relative time string, e.g. "3 hours ago".
|
|
||||||
*/
|
|
||||||
export function timeAgo(input: number | string | Date): string {
|
|
||||||
const date =
|
|
||||||
input instanceof Date
|
|
||||||
? input
|
|
||||||
: typeof input === 'number'
|
|
||||||
? new Date(input * 1000)
|
|
||||||
: new Date(input);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const diff = now - date.getTime();
|
|
||||||
|
|
||||||
const epochs: [string, number][] = [
|
|
||||||
['year', 365 * 24 * 3600 * 1000],
|
|
||||||
['month', 30 * 24 * 3600 * 1000],
|
|
||||||
['week', 7 * 24 * 3600 * 1000],
|
|
||||||
['day', 24 * 3600 * 1000],
|
|
||||||
['hour', 3600 * 1000],
|
|
||||||
['minute', 60 * 1000],
|
|
||||||
['second', 1000]
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [label, ms] of epochs) {
|
|
||||||
const n = Math.floor(diff / ms);
|
|
||||||
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format an ISO timestamp to a readable full date string.
|
|
||||||
*/
|
|
||||||
export function formatDate(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
|
||||||
|
|
||||||
/** Pass session to all pages via PageData. */
|
|
||||||
export const load: LayoutServerLoad = ({ locals }) => {
|
|
||||||
return {
|
|
||||||
session: locals.session
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import '../app.css';
|
|
||||||
import Navbar from '$lib/components/layout/Navbar.svelte';
|
|
||||||
import FlashMessages from '$lib/components/ui/FlashMessages.svelte';
|
|
||||||
import { session as sessionStore } from '$lib/stores/session';
|
|
||||||
import { initPreferences } from '$lib/stores/preferences';
|
|
||||||
import { connect as sseConnect, disconnect as sseDisconnect } from '$lib/stores/sse';
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
data: {
|
|
||||||
session: import('$lib/types/session').SessionUser | null;
|
|
||||||
};
|
|
||||||
children: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
let { data, children }: Props = $props();
|
|
||||||
|
|
||||||
// Hydrate session store from server-loaded data
|
|
||||||
$effect(() => {
|
|
||||||
sessionStore.set(data.session);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Init user preferences from session
|
|
||||||
$effect(() => {
|
|
||||||
if (data.session) {
|
|
||||||
initPreferences({ mode: data.session.mode ?? 3 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
sseConnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
sseDisconnect();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>f0ckm</title>
|
|
||||||
<meta name="description" content="f0ckm — media sharing" />
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="min-h-dvh flex flex-col" data-theme="f0ck">
|
|
||||||
<Navbar />
|
|
||||||
<div class="flex-1">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Global flash messages -->
|
|
||||||
<FlashMessages />
|
|
||||||
</div>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { getItems } from '$lib/api/items';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
|
||||||
const page = Number(url.searchParams.get('page') ?? 1);
|
|
||||||
const mode = locals.session?.mode ?? 3;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getItems({ page, mode }, fetch);
|
|
||||||
if (data.success) {
|
|
||||||
return {
|
|
||||||
items: data.items,
|
|
||||||
pagination: data.pagination,
|
|
||||||
total: data.total,
|
|
||||||
link: data.link
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Backend not yet running or API route not registered — return empty state
|
|
||||||
// so the page renders rather than hard-erroring.
|
|
||||||
console.warn('[/] Failed to load items:', (e as Error).message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
pagination: null,
|
|
||||||
total: 0,
|
|
||||||
link: { main: '/', path: 'p/', suffix: '' }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import type { GridItem } from '$lib/types/item';
|
|
||||||
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
|
||||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
|
||||||
import { onSSE } from '$lib/stores/sse';
|
|
||||||
import { pinEvent } from '$lib/stores/pin';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
// Local mutable copy of the items list — SSE patches it live
|
|
||||||
let items = $state<GridItem[]>(data.items);
|
|
||||||
let total = $state(data.total);
|
|
||||||
|
|
||||||
// Re-sync when server data changes (e.g. navigation / invalidation)
|
|
||||||
$effect(() => {
|
|
||||||
items = data.items;
|
|
||||||
total = data.total;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Live grid updates via SSE ─────────────────────────────────────────────
|
|
||||||
const unsubSSE = onSSE((event) => {
|
|
||||||
if (event.type === 'new_item') {
|
|
||||||
const d = event.data as GridItem & { dest: string; username: string };
|
|
||||||
if (!items.some(i => i.id === d.id)) {
|
|
||||||
items = [d, ...items];
|
|
||||||
total += 1;
|
|
||||||
}
|
|
||||||
} else if (event.type === 'delete_item') {
|
|
||||||
const d = event.data as { id: number };
|
|
||||||
const before = items.length;
|
|
||||||
items = items.filter(i => i.id !== d.id);
|
|
||||||
if (items.length < before) total = Math.max(0, total - 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
onDestroy(unsubSSE);
|
|
||||||
|
|
||||||
// ── Pin store: patch the grid card is_pinned live ─────────────────────────
|
|
||||||
const unsubPin = pinEvent.subscribe((evt) => {
|
|
||||||
if (!evt) return;
|
|
||||||
items = items.map(i => i.id === evt.itemId ? { ...i, is_pinned: evt.pinned } : i);
|
|
||||||
});
|
|
||||||
onDestroy(unsubPin);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>f0ckm — {total} items</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
|
||||||
<!-- Grid header -->
|
|
||||||
{#if total > 0}
|
|
||||||
<p class="text-xs opacity-40 mb-3 font-mono">{total.toLocaleString()} items</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Thumbnail grid -->
|
|
||||||
{#if items.length > 0}
|
|
||||||
<div class="
|
|
||||||
grid gap-1.5
|
|
||||||
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
|
||||||
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
|
||||||
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
|
||||||
">
|
|
||||||
{#each items as item (item.id)}
|
|
||||||
<ItemCard {item} link={data.link} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if data.pagination}
|
|
||||||
<Pagination pagination={data.pagination} link={data.link} />
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
|
||||||
<i class="fa-solid fa-box-open text-5xl"></i>
|
|
||||||
<p class="text-sm">No items found.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { getItem } from '$lib/api/items';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, locals, url }) => {
|
|
||||||
const id = params.itemid;
|
|
||||||
if (!id || !/^\d+$/.test(id)) throw error(404, 'Not found');
|
|
||||||
|
|
||||||
const mode = locals.session?.mode ?? 3;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getItem(id, { mode }, fetch);
|
|
||||||
|
|
||||||
if (!data.success || !data.item) {
|
|
||||||
throw error(404, 'Item not found or not visible in current mode');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
item: data.item,
|
|
||||||
pagination: data.pagination,
|
|
||||||
link: data.link,
|
|
||||||
isSubscribed: data.isSubscribed
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Response) throw e;
|
|
||||||
throw error(500, 'Failed to load item');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import ItemView from '$lib/components/item/ItemView.svelte';
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
const ogTitle = $derived(data.item.title ?? `#${data.item.id} — f0ckm`);
|
|
||||||
const ogImage = $derived(data.item.og_thumbnail);
|
|
||||||
const ogDesc = $derived(data.item.og_description ?? '');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{ogTitle}</title>
|
|
||||||
<meta name="description" content={ogDesc} />
|
|
||||||
<!-- OG tags for embeds -->
|
|
||||||
<meta property="og:title" content={ogTitle} />
|
|
||||||
<meta property="og:description" content={ogDesc} />
|
|
||||||
<meta property="og:image" content={ogImage} />
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<!-- Twitter card -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content={ogTitle} />
|
|
||||||
<meta name="twitter:description" content={ogDesc} />
|
|
||||||
<meta name="twitter:image" content={ogImage} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<ItemView
|
|
||||||
item={data.item}
|
|
||||||
pagination={data.pagination}
|
|
||||||
link={data.link}
|
|
||||||
isSubscribed={data.isSubscribed}
|
|
||||||
/>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// Stub page for /login — will be built in Sprint 2.
|
|
||||||
// For now redirect to the legacy login page so auth still works.
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
// Temporary: redirect to legacy backend login until we build the Svelte login form
|
|
||||||
throw redirect(307, 'http://localhost:1337/login');
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { getRandom } from '$lib/api/items';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
|
||||||
try {
|
|
||||||
const data = await getRandom(fetch);
|
|
||||||
if (data.success && data.url) {
|
|
||||||
throw redirect(307, data.url);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Response) throw e;
|
|
||||||
}
|
|
||||||
// Fallback: go home if no random item found
|
|
||||||
throw redirect(307, '/');
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
throw redirect(307, 'http://localhost:1337/register');
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { getItems } from '$lib/api/items';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, params, locals }) => {
|
|
||||||
const tag = decodeURIComponent(params.tag);
|
|
||||||
const page = Number(url.searchParams.get('page') ?? 1);
|
|
||||||
const mode = locals.session?.mode ?? 3;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getItems({ page, mode, tag }, fetch);
|
|
||||||
if (data.success) {
|
|
||||||
return {
|
|
||||||
tag,
|
|
||||||
items: data.items,
|
|
||||||
pagination: data.pagination,
|
|
||||||
total: data.total,
|
|
||||||
link: data.link
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[/tag/${tag}] Failed to load items:`, (e as Error).message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tag,
|
|
||||||
items: [],
|
|
||||||
pagination: null,
|
|
||||||
total: 0,
|
|
||||||
link: { main: `/tag/${encodeURIComponent(tag)}/`, path: 'p/', suffix: '' }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import type { GridItem } from '$lib/types/item';
|
|
||||||
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
|
||||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
|
||||||
import { onSSE } from '$lib/stores/sse';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
let items = $state<GridItem[]>(data.items);
|
|
||||||
let total = $state(data.total);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
items = data.items;
|
|
||||||
total = data.total;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Live: remove items that get deleted
|
|
||||||
// (we don't prepend new_item because it may not match this tag filter)
|
|
||||||
const unsubSSE = onSSE((event) => {
|
|
||||||
if (event.type === 'delete_item') {
|
|
||||||
const d = event.data as { id: number };
|
|
||||||
const before = items.length;
|
|
||||||
items = items.filter(i => i.id !== d.id);
|
|
||||||
if (items.length < before) total = Math.max(0, total - 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
onDestroy(unsubSSE);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>#{data.tag} — f0ckm</title>
|
|
||||||
<meta name="description" content="Items tagged #{data.tag} on f0ckm" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
|
||||||
|
|
||||||
<!-- Tag header -->
|
|
||||||
<div class="flex items-center gap-3 mb-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-tag text-primary opacity-70" aria-hidden="true"></i>
|
|
||||||
<h1 class="text-xl font-bold">{data.tag}</h1>
|
|
||||||
</div>
|
|
||||||
{#if total > 0}
|
|
||||||
<span class="badge badge-neutral font-mono text-xs">{total.toLocaleString()}</span>
|
|
||||||
{/if}
|
|
||||||
<a href="/" class="btn btn-ghost btn-xs ml-auto opacity-60 hover:opacity-100 gap-1">
|
|
||||||
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i> Clear
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grid -->
|
|
||||||
{#if items.length > 0}
|
|
||||||
<div class="
|
|
||||||
grid gap-1.5
|
|
||||||
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
|
||||||
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
|
||||||
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
|
||||||
">
|
|
||||||
{#each items as item (item.id)}
|
|
||||||
<ItemCard {item} link={data.link} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if data.pagination}
|
|
||||||
<Pagination pagination={data.pagination} link={data.link} />
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
|
||||||
<i class="fa-solid fa-tag text-5xl" aria-hidden="true"></i>
|
|
||||||
<p class="text-sm">No items found for <strong>#{data.tag}</strong>.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { getItem } from '$lib/api/items';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, params, locals }) => {
|
|
||||||
const tag = decodeURIComponent(params.tag);
|
|
||||||
const itemid = Number(params.itemid);
|
|
||||||
const mode = locals.session?.mode ?? 3;
|
|
||||||
|
|
||||||
if (!itemid) throw error(404, 'Not found');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getItem(itemid, { mode, tag }, fetch);
|
|
||||||
if (!data.success || !data.item) throw error(404, data.message ?? 'Item not found');
|
|
||||||
|
|
||||||
return {
|
|
||||||
tag,
|
|
||||||
item: data.item,
|
|
||||||
pagination: data.pagination,
|
|
||||||
link: data.link,
|
|
||||||
isSubscribed: data.isSubscribed ?? false
|
|
||||||
};
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if ((e as { status?: number })?.status) throw e;
|
|
||||||
throw error(500, 'Failed to load item');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import ItemView from '$lib/components/item/ItemView.svelte';
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>#{data.tag} — item {data.item.id} — f0ckm</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<!-- Tag breadcrumb -->
|
|
||||||
<div class="max-w-[1600px] mx-auto px-4 pt-2">
|
|
||||||
<div class="text-xs opacity-50 flex items-center gap-1.5">
|
|
||||||
<a href="/" class="hover:opacity-100 transition-opacity">f0ckm</a>
|
|
||||||
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
|
||||||
<a href="/tag/{encodeURIComponent(data.tag)}" class="hover:opacity-100 transition-opacity flex items-center gap-1">
|
|
||||||
<i class="fa-solid fa-tag text-[10px]" aria-hidden="true"></i>
|
|
||||||
{data.tag}
|
|
||||||
</a>
|
|
||||||
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
|
||||||
<span>#{data.item.id}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ItemView
|
|
||||||
item={data.item}
|
|
||||||
pagination={data.pagination}
|
|
||||||
link={data.link}
|
|
||||||
isSubscribed={data.isSubscribed}
|
|
||||||
/>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# allow crawling everything by default
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rewriteRelativeImportExtensions": true,
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"types": ["node"]
|
|
||||||
}
|
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
|
||||||
//
|
|
||||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
|
||||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
tailwindcss(),
|
|
||||||
sveltekit({
|
|
||||||
compilerOptions: {
|
|
||||||
runes: ({ filename }) =>
|
|
||||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
|
||||||
},
|
|
||||||
adapter: adapter()
|
|
||||||
})
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
// Proxy API + WebSocket to the existing f0ckm backend
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:1337',
|
|
||||||
changeOrigin: true
|
|
||||||
},
|
|
||||||
'/ajax': {
|
|
||||||
target: 'http://localhost:1337',
|
|
||||||
changeOrigin: true
|
|
||||||
},
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://localhost:1337',
|
|
||||||
ws: true
|
|
||||||
},
|
|
||||||
// Static media files — use exact prefix matching to avoid
|
|
||||||
// colliding with Vite's own /src/ module paths.
|
|
||||||
// Each rule anchored to exactly /X/ (with trailing slash).
|
|
||||||
'^/b/': { target: 'http://localhost:1337', changeOrigin: true },
|
|
||||||
'^/t/': { target: 'http://localhost:1337', changeOrigin: true },
|
|
||||||
'^/ca/': { target: 'http://localhost:1337', changeOrigin: true },
|
|
||||||
'^/a/': { target: 'http://localhost:1337', changeOrigin: true },
|
|
||||||
// /s/ — NOTE: must NOT match /src/ — regex anchors it
|
|
||||||
'^/s/': { target: 'http://localhost:1337', changeOrigin: true },
|
|
||||||
'^/memes/': { target: 'http://localhost:1337', changeOrigin: true },
|
|
||||||
// mod actions (pin, unpin, delete)
|
|
||||||
'^/mod/': { target: 'http://localhost:1337', changeOrigin: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- User emoji favorites — persisted per-user, cross-device
|
|
||||||
CREATE TABLE IF NOT EXISTS user_emoji_favorites (
|
|
||||||
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
emoji_name text NOT NULL,
|
|
||||||
emoji_url text NOT NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (user_id, emoji_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON user_emoji_favorites (user_id);
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
-- Migration: Add sticker_packs table and link custom_emojis to packs
|
|
||||||
-- Run this against the f0ckm database (idempotent — safe to run multiple times)
|
|
||||||
|
|
||||||
-- Create sticker_packs table
|
|
||||||
CREATE TABLE IF NOT EXISTS public.sticker_packs (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
tg_name text UNIQUE,
|
|
||||||
tg_title text,
|
|
||||||
thumb_url text,
|
|
||||||
sticker_count integer DEFAULT 0,
|
|
||||||
created_at timestamp with time zone DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
|
|
||||||
|
|
||||||
-- Add pack_id column to custom_emojis (nullable: NULL = standalone emoji, no pack)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'custom_emojis'
|
|
||||||
AND column_name = 'pack_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.custom_emojis
|
|
||||||
ADD COLUMN pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
|
|
||||||
RAISE NOTICE 'pack_id column added to custom_emojis';
|
|
||||||
ELSE
|
|
||||||
RAISE NOTICE 'pack_id column already exists on custom_emojis — skipping';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Add thumb_url column to sticker_packs if missing
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'sticker_packs'
|
|
||||||
AND column_name = 'thumb_url'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.sticker_packs ADD COLUMN thumb_url text;
|
|
||||||
RAISE NOTICE 'thumb_url column added to sticker_packs';
|
|
||||||
ELSE
|
|
||||||
RAISE NOTICE 'thumb_url already exists on sticker_packs — skipping';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Index for fast pack lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- Add banner_file column to user_options for custom profile banners
|
|
||||||
-- Banners are displayed in the alternative infobox (user-infobox-block)
|
|
||||||
|
|
||||||
ALTER TABLE user_options
|
|
||||||
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE user_options
|
|
||||||
ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
|
||||||
|
|
||||||
ALTER TABLE user_options
|
|
||||||
ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
|
|
||||||
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
|
|
||||||
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner (e.g. cover, contain, or a percentage)';
|
|
||||||
@@ -20,9 +20,6 @@ SET client_min_messages = warning;
|
|||||||
SET row_security = off;
|
SET row_security = off;
|
||||||
|
|
||||||
DROP PUBLICATION IF EXISTS alltables;
|
DROP PUBLICATION IF EXISTS alltables;
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_user_id_fkey;
|
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_api_key_key;
|
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_pkey;
|
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey;
|
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey;
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey;
|
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey;
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey;
|
ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey;
|
||||||
@@ -79,7 +76,7 @@ CREATE OR REPLACE VIEW public.items_li AS
|
|||||||
SELECT
|
SELECT
|
||||||
NULL::integer AS id,
|
NULL::integer AS id,
|
||||||
NULL::character varying(255) AS src,
|
NULL::character varying(255) AS src,
|
||||||
NULL::character varying(60) AS dest,
|
NULL::character varying(40) AS dest,
|
||||||
NULL::character varying(100) AS mime,
|
NULL::character varying(100) AS mime,
|
||||||
NULL::integer AS size,
|
NULL::integer AS size,
|
||||||
NULL::character varying(255) AS checksum,
|
NULL::character varying(255) AS checksum,
|
||||||
@@ -94,7 +91,6 @@ DROP INDEX IF EXISTS public.idx_user_last_seen;
|
|||||||
DROP INDEX IF EXISTS public.idx_user_halls_user_id;
|
DROP INDEX IF EXISTS public.idx_user_halls_user_id;
|
||||||
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
|
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
|
||||||
DROP INDEX IF EXISTS public.idx_user_halls_assign_hall;
|
DROP INDEX IF EXISTS public.idx_user_halls_assign_hall;
|
||||||
DROP INDEX IF EXISTS public.idx_user_api_keys_api_key;
|
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_userid;
|
DROP INDEX IF EXISTS public.idx_user_alias_userid;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_type;
|
DROP INDEX IF EXISTS public.idx_user_alias_type;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_alias;
|
DROP INDEX IF EXISTS public.idx_user_alias_alias;
|
||||||
@@ -195,7 +191,6 @@ DROP TABLE IF EXISTS public.user_halls_assign;
|
|||||||
DROP TABLE IF EXISTS public.user_halls;
|
DROP TABLE IF EXISTS public.user_halls;
|
||||||
DROP TABLE IF EXISTS public.user_dm_keyvault;
|
DROP TABLE IF EXISTS public.user_dm_keyvault;
|
||||||
DROP TABLE IF EXISTS public.user_conversation_states;
|
DROP TABLE IF EXISTS public.user_conversation_states;
|
||||||
DROP TABLE IF EXISTS public.user_api_keys;
|
|
||||||
DROP TABLE IF EXISTS public.user_alias;
|
DROP TABLE IF EXISTS public.user_alias;
|
||||||
DROP TABLE IF EXISTS public."user";
|
DROP TABLE IF EXISTS public."user";
|
||||||
DROP SEQUENCE IF EXISTS public.user_id_seq;
|
DROP SEQUENCE IF EXISTS public.user_id_seq;
|
||||||
@@ -593,7 +588,7 @@ CREATE TABLE public.comment_files (
|
|||||||
id integer NOT NULL,
|
id integer NOT NULL,
|
||||||
comment_id integer,
|
comment_id integer,
|
||||||
user_id integer NOT NULL,
|
user_id integer NOT NULL,
|
||||||
dest character varying(60) NOT NULL,
|
dest character varying(40) NOT NULL,
|
||||||
mime character varying(100) NOT NULL,
|
mime character varying(100) NOT NULL,
|
||||||
size integer NOT NULL,
|
size integer NOT NULL,
|
||||||
checksum character varying(255) NOT NULL,
|
checksum character varying(255) NOT NULL,
|
||||||
@@ -605,8 +600,6 @@ CREATE TABLE public.comment_files (
|
|||||||
|
|
||||||
ALTER TABLE public.comment_files OWNER TO f0ckm;
|
ALTER TABLE public.comment_files OWNER TO f0ckm;
|
||||||
|
|
||||||
ALTER TABLE ONLY public.comment_files REPLICA IDENTITY DEFAULT;
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -827,25 +820,12 @@ CREATE TABLE public.invite_tokens (
|
|||||||
used_by integer,
|
used_by integer,
|
||||||
is_used boolean DEFAULT false,
|
is_used boolean DEFAULT false,
|
||||||
created_by_discord character varying(255) DEFAULT NULL::character varying,
|
created_by_discord character varying(255) DEFAULT NULL::character varying,
|
||||||
created_by_matrix character varying(255) DEFAULT NULL::character varying,
|
created_by_matrix character varying(255) DEFAULT NULL::character varying
|
||||||
used_at timestamp with time zone DEFAULT NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
|
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by);
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true;
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -889,7 +869,7 @@ ALTER SEQUENCE public.items_id_seq OWNER TO f0ckm;
|
|||||||
CREATE TABLE public.items (
|
CREATE TABLE public.items (
|
||||||
id integer DEFAULT nextval('public.items_id_seq'::regclass) NOT NULL,
|
id integer DEFAULT nextval('public.items_id_seq'::regclass) NOT NULL,
|
||||||
src character varying(255) NOT NULL,
|
src character varying(255) NOT NULL,
|
||||||
dest character varying(60) NOT NULL,
|
dest character varying(40) NOT NULL,
|
||||||
mime character varying(100) NOT NULL,
|
mime character varying(100) NOT NULL,
|
||||||
size integer NOT NULL,
|
size integer NOT NULL,
|
||||||
checksum character varying(255) NOT NULL,
|
checksum character varying(255) NOT NULL,
|
||||||
@@ -907,10 +887,7 @@ CREATE TABLE public.items (
|
|||||||
is_pinned boolean DEFAULT false,
|
is_pinned boolean DEFAULT false,
|
||||||
is_oc boolean DEFAULT false,
|
is_oc boolean DEFAULT false,
|
||||||
xd_score integer DEFAULT 0 NOT NULL,
|
xd_score integer DEFAULT 0 NOT NULL,
|
||||||
original_filename text,
|
original_filename text
|
||||||
title text,
|
|
||||||
width integer,
|
|
||||||
height integer
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -1349,21 +1326,6 @@ CREATE TABLE public."user" (
|
|||||||
|
|
||||||
ALTER TABLE public."user" OWNER TO f0ckm;
|
ALTER TABLE public."user" OWNER TO f0ckm;
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: user_api_keys; Type: TABLE; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.user_api_keys (
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
api_key text NOT NULL,
|
|
||||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT user_api_keys_pkey PRIMARY KEY (user_id),
|
|
||||||
CONSTRAINT user_api_keys_api_key_key UNIQUE (api_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE public.user_api_keys OWNER TO f0ckm;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm
|
-- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -1493,12 +1455,12 @@ CREATE TABLE public.user_options (
|
|||||||
hide_koepfe boolean DEFAULT false NOT NULL,
|
hide_koepfe boolean DEFAULT false NOT NULL,
|
||||||
language text,
|
language text,
|
||||||
use_alternative_infobox boolean DEFAULT false,
|
use_alternative_infobox boolean DEFAULT false,
|
||||||
use_alternative_steuerung boolean DEFAULT NULL,
|
|
||||||
receive_system_notifications boolean DEFAULT true,
|
receive_system_notifications boolean DEFAULT true,
|
||||||
receive_user_notifications boolean DEFAULT true,
|
receive_user_notifications boolean DEFAULT true,
|
||||||
do_not_disturb boolean DEFAULT false,
|
do_not_disturb boolean DEFAULT false,
|
||||||
comment_display_mode integer DEFAULT 1,
|
comment_display_mode integer DEFAULT 1,
|
||||||
force_comment_display_mode integer DEFAULT 0
|
force_comment_display_mode integer DEFAULT 0,
|
||||||
|
feed_layout smallint DEFAULT 0 NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -1625,13 +1587,6 @@ ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.au
|
|||||||
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
|
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: comment_files id; Type: DEFAULT; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.comment_files ALTER COLUMN id SET DEFAULT nextval('public.comment_files_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: f0ckm
|
-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -1739,14 +1694,6 @@ ALTER TABLE ONLY public.comment_subscriptions
|
|||||||
ADD CONSTRAINT comment_subscriptions_pkey PRIMARY KEY (user_id, item_id);
|
ADD CONSTRAINT comment_subscriptions_pkey PRIMARY KEY (user_id, item_id);
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: comment_files comment_files_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.comment_files
|
|
||||||
ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
|
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2295,13 +2242,6 @@ CREATE INDEX idx_user_alias_type ON public.user_alias USING btree (type);
|
|||||||
CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid);
|
CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid);
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_user_api_keys_api_key; Type: INDEX; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_user_api_keys_api_key ON public.user_api_keys USING btree (api_key);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm
|
-- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2446,22 +2386,6 @@ ALTER TABLE ONLY public.comment_subscriptions
|
|||||||
ADD CONSTRAINT comment_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
ADD CONSTRAINT comment_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: comment_files comment_files_comment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.comment_files
|
|
||||||
ADD CONSTRAINT comment_files_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: comment_files comment_files_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.comment_files
|
|
||||||
ADD CONSTRAINT comment_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: comments comments_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
-- Name: comments comments_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2798,14 +2722,6 @@ ALTER TABLE ONLY public.user_warnings
|
|||||||
ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: user_api_keys user_api_keys_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.user_api_keys
|
|
||||||
ADD CONSTRAINT user_api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
|
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2844,104 +2760,4 @@ CREATE INDEX IF NOT EXISTS idx_user_ips_user_id ON user_ips(user_id);
|
|||||||
-- Add IP tracking to user_sessions for "current" IP view
|
-- Add IP tracking to user_sessions for "current" IP view
|
||||||
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT;
|
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT;
|
||||||
|
|
||||||
-- DM encrypted attachments (Migration 005)
|
|
||||||
CREATE TABLE IF NOT EXISTS public.dm_attachments (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
sender_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
|
||||||
recipient_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
|
||||||
iv text NOT NULL,
|
|
||||||
file_path text NOT NULL,
|
|
||||||
original_name text NOT NULL DEFAULT '',
|
|
||||||
mime_hint text NOT NULL DEFAULT '',
|
|
||||||
size_bytes integer NOT NULL DEFAULT 0,
|
|
||||||
created_at timestamp with time zone DEFAULT now(),
|
|
||||||
expires_at timestamp with time zone DEFAULT (now() + interval '90 days')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dm_att_sender ON public.dm_attachments(sender_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dm_att_recipient ON public.dm_attachments(recipient_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dm_att_expires ON public.dm_attachments(expires_at);
|
|
||||||
|
|
||||||
-- DM message edit/delete support (Migration 006)
|
|
||||||
ALTER TABLE private_messages
|
|
||||||
ADD COLUMN IF NOT EXISTS edited_at timestamp with time zone DEFAULT NULL;
|
|
||||||
|
|
||||||
-- Wordfilter Table (Migration 009)
|
|
||||||
CREATE TABLE IF NOT EXISTS public.wordfilter (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
word VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
replacement VARCHAR(255) NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Comment Polls
|
|
||||||
CREATE TABLE IF NOT EXISTS public.comment_polls (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE,
|
|
||||||
question TEXT NOT NULL,
|
|
||||||
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
|
||||||
UNIQUE(comment_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.comment_poll_options (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
sort_order SMALLINT NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.comment_poll_votes (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
|
|
||||||
option_id INTEGER NOT NULL REFERENCES public.comment_poll_options(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
UNIQUE(poll_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_polls_comment_id ON public.comment_polls(comment_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_poll_options_poll ON public.comment_poll_options(poll_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_poll ON public.comment_poll_votes(poll_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_user ON public.comment_poll_votes(user_id);
|
|
||||||
|
|
||||||
-- Sticker Packs (Migration 010)
|
|
||||||
-- sticker_packs table: holds imported Telegram (or custom) sticker pack metadata
|
|
||||||
CREATE TABLE IF NOT EXISTS public.sticker_packs (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
tg_name text UNIQUE,
|
|
||||||
tg_title text,
|
|
||||||
thumb_url text,
|
|
||||||
sticker_count integer DEFAULT 0,
|
|
||||||
created_at timestamp with time zone DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
|
|
||||||
|
|
||||||
-- Link custom_emojis to a sticker pack (NULL = standalone emoji)
|
|
||||||
ALTER TABLE public.custom_emojis
|
|
||||||
ADD COLUMN IF NOT EXISTS pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
-- Ensure thumb_url exists even on databases created before this column was added
|
|
||||||
ALTER TABLE public.sticker_packs
|
|
||||||
ADD COLUMN IF NOT EXISTS thumb_url text;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
|
|
||||||
|
|
||||||
-- User Emoji Favorites (Migration 011)
|
|
||||||
-- Persists per-user sticker/emoji favorites for cross-device sync
|
|
||||||
CREATE TABLE IF NOT EXISTS public.user_emoji_favorites (
|
|
||||||
user_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
|
||||||
emoji_name text NOT NULL,
|
|
||||||
emoji_url text NOT NULL,
|
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (user_id, emoji_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
|
|
||||||
|
|
||||||
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,6 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Impact';
|
font-family: 'Impact';
|
||||||
src: url('/s/fonts/impact.woff') format('woff');
|
src: url('/s/fonts/impact.woff') format('woff');
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Impact';
|
|
||||||
src: url('/s/fonts/impact.woff') format('woff');
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meme Creator Styles */
|
/* Meme Creator Styles */
|
||||||
@@ -192,8 +185,6 @@ canvas#memeCanvas {
|
|||||||
.meme-layout-wrapper input[type="range"] {
|
.meme-layout-wrapper input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: var(--accent, #9f0);
|
accent-color: var(--accent, #9f0);
|
||||||
touch-action: pan-x;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meme-layout-wrapper .layer-input-group {
|
.meme-layout-wrapper .layer-input-group {
|
||||||
@@ -243,7 +234,7 @@ canvas#memeCanvas {
|
|||||||
.meme-editor-layout {
|
.meme-editor-layout {
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
grid-template-rows: 0.6fr auto !important;
|
grid-template-rows: 0.6fr 1fr !important;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.meme-controls {
|
.meme-controls {
|
||||||
@@ -269,45 +260,3 @@ canvas#memeCanvas {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Template Card & Drag Hover Styles */
|
|
||||||
.custom-template-card .template-image-wrapper {
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-template-card:hover .template-image-wrapper {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-template-card i {
|
|
||||||
transition: transform 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-template-card:hover i {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wrapper.drag-hover {
|
|
||||||
border-color: var(--accent, #9f0) !important;
|
|
||||||
box-shadow: 0 0 25px rgba(159, 255, 0, 0.4) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar space reservation for meme pages */
|
|
||||||
.meme-layout-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
transition: padding-right 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
|
||||||
.meme-layout-wrapper {
|
|
||||||
padding-right: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapse reserved space when sidebar is hidden */
|
|
||||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* Stacked */
|
/* Stacked */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +219,19 @@
|
|||||||
|
|
||||||
.upload-form:not(.shitpost-mode-active) .preview-media-small {
|
.upload-form:not(.shitpost-mode-active) .preview-media-small {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-form.shitpost-mode-active .preview-media-small {
|
||||||
|
width: 120px;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-media-small {
|
.preview-media-small {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -241,7 +247,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .file-name-small {
|
.upload-form.shitpost-mode-active .file-meta-row-small {
|
||||||
padding-right: 30px; /* Space for X button */
|
padding-right: 30px; /* Space for X button */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +255,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-info-small {
|
.file-info-small {
|
||||||
@@ -398,71 +403,56 @@
|
|||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-rating-option input:checked + .item-rating-label.sfw {
|
.item-rating-option input:checked + .item-rating-label.sfw {
|
||||||
background: var(--badge-sfw);
|
background: #40c057;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--badge-sfw);
|
border-color: #40c057;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-rating-option input:checked + .item-rating-label.nsfw {
|
.item-rating-option input:checked + .item-rating-label.nsfw {
|
||||||
background: var(--badge-nsfw);
|
background: #fd7e14;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--badge-nsfw);
|
border-color: #fd7e14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-rating-option input:checked + .item-rating-label.nsfl {
|
.item-rating-option input:checked + .item-rating-label.nsfl {
|
||||||
background: var(--badge-nsfl);
|
background: #fa5252;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--badge-nsfl);
|
border-color: #fa5252;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.item-rating-label:hover {
|
.item-rating-label:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bigger Previews in Shitpost Mode */
|
/* Bigger Previews in Shitpost Mode */
|
||||||
.upload-form.shitpost-mode-active .file-preview-item {
|
.file-preview-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
gap: 16px;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .preview-media-small {
|
.preview-media-small {
|
||||||
flex: 0 0 50%;
|
flex: 0 0 50%;
|
||||||
width: 50% !important;
|
width: 50% !important;
|
||||||
max-width: 50% !important;
|
height: auto !important;
|
||||||
height: 240px !important;
|
min-height: 120px;
|
||||||
max-height: 240px !important;
|
max-height: 350px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
overflow: hidden; /* Clip the video — Chrome cannot expand past this */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inner video inside the wrapper fills the container and cannot resize it */
|
|
||||||
.upload-form.shitpost-mode-active .preview-video-wrapper > video {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-item Tags */
|
/* Per-item Tags */
|
||||||
.item-tags-container {
|
.item-tags-container {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-tags-list {
|
.item-tags-list {
|
||||||
@@ -559,34 +549,6 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title-container {
|
|
||||||
margin-top: 6px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title-input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(0,0,0,0.3);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title-input::placeholder {
|
|
||||||
color: rgba(255,255,255,0.3);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title-input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rating-options {
|
.rating-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -714,29 +676,6 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global title input (normal mode) — matches tag-input-container style */
|
|
||||||
.upload-title-input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 0.5rem;
|
|
||||||
color: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-title-input:focus {
|
|
||||||
border-color: var(--accent, #7c5cbf);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-title-input::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-count {
|
.tag-count {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -796,9 +735,9 @@
|
|||||||
|
|
||||||
.upload-form .tag-suggestions {
|
.upload-form .tag-suggestions {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
min-width: 220px;
|
min-width: 220px !important;
|
||||||
max-width: 320px;
|
max-width: 320px !important;
|
||||||
max-height: 260px;
|
max-height: 260px !important;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
background: var(--dropdown-bg, #1a1a1a) !important;
|
background: var(--dropdown-bg, #1a1a1a) !important;
|
||||||
border: 1px solid var(--black, #000) !important;
|
border: 1px solid var(--black, #000) !important;
|
||||||
@@ -811,7 +750,9 @@
|
|||||||
scrollbar-width: thin !important;
|
scrollbar-width: thin !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-form.shitpost-mode-active .tag-suggestions {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes tagDropIn {
|
@keyframes tagDropIn {
|
||||||
from {
|
from {
|
||||||
@@ -1302,7 +1243,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 600px) {
|
||||||
.upload-form.shitpost-mode-active .file-preview-item {
|
.upload-form.shitpost-mode-active .file-preview-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -1312,7 +1253,6 @@
|
|||||||
.upload-form.shitpost-mode-active .preview-media-small,
|
.upload-form.shitpost-mode-active .preview-media-small,
|
||||||
.upload-form.shitpost-mode-active .item-media-col {
|
.upload-form.shitpost-mode-active .item-media-col {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
@@ -1320,341 +1260,4 @@
|
|||||||
.upload-form.shitpost-mode-active .item-media-col iframe {
|
.upload-form.shitpost-mode-active .item-media-col iframe {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep suggestions visible and position them above the tag input */
|
|
||||||
.upload-form.shitpost-mode-active .file-meta-row-small {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .tag-suggestions {
|
|
||||||
position: absolute !important;
|
|
||||||
bottom: 100% !important;
|
|
||||||
top: auto !important;
|
|
||||||
left: 0 !important;
|
|
||||||
right: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: none !important;
|
|
||||||
margin-top: 0 !important;
|
|
||||||
margin-bottom: 8px !important;
|
|
||||||
z-index: 200000 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Video thumbnail loading animation */
|
|
||||||
.video-thumbnail-loading {
|
|
||||||
animation: thumbPulse 1.5s ease-in-out infinite;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes thumbPulse {
|
|
||||||
0% { opacity: 0.4; }
|
|
||||||
50% { opacity: 0.8; }
|
|
||||||
100% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Ruffle (Flash) Upload Preview ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* The container: CSS Grid with 3 rows — player | snapshot preview | button.
|
|
||||||
All rows are auto so the container grows naturally; no overflow clipping. */
|
|
||||||
.swf-upload-preview {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto auto auto;
|
|
||||||
max-height: none !important;
|
|
||||||
background: #000;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Normal (non-shitpost) mode: full-width, player drives container height via aspect-ratio */
|
|
||||||
.upload-form:not(.shitpost-mode-active) .swf-upload-preview.preview-media-small {
|
|
||||||
width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
max-height: none !important;
|
|
||||||
min-height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-player,
|
|
||||||
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-object {
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shitpost mode: player row is a fixed height so the snapshot row doesn't steal its space */
|
|
||||||
.upload-form.shitpost-mode-active .swf-upload-preview.preview-media-small {
|
|
||||||
width: 45% !important;
|
|
||||||
flex: 0 0 45% !important;
|
|
||||||
height: auto !important;
|
|
||||||
max-height: none !important;
|
|
||||||
min-height: auto !important;
|
|
||||||
aspect-ratio: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-player,
|
|
||||||
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-object {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 220px !important;
|
|
||||||
min-height: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholder shown while Ruffle is loading */
|
|
||||||
.swf-upload-placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 200px;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
animation: thumbPulse 1.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swf-upload-placeholder-icon {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.7));
|
|
||||||
}
|
|
||||||
|
|
||||||
.swf-upload-placeholder-text {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Ruffle Snapshot Button ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* The snapshot button lives inside .swf-upload-preview, pinned to the bottom */
|
|
||||||
.swf-upload-preview {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ruffle-snapshot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: rgba(255, 200, 0, 0.08);
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid rgba(255, 200, 0, 0.2);
|
|
||||||
border-radius: 0 0 6px 6px;
|
|
||||||
color: rgba(255, 200, 0, 0.9);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ruffle-snapshot:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 200, 0, 0.15);
|
|
||||||
border-top-color: rgba(255, 200, 0, 0.4);
|
|
||||||
color: #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ruffle-snapshot:active:not(:disabled) {
|
|
||||||
background: rgba(255, 200, 0, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ruffle-snapshot:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Snapshot preview: sits in its own grid row, below the player */
|
|
||||||
.swf-upload-preview .ruffle-snapshot-preview {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
object-position: center;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
border-top: 1px solid rgba(81, 207, 102, 0.3);
|
|
||||||
animation: snapPreviewIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes snapPreviewIn {
|
|
||||||
from { opacity: 0; transform: scale(0.96) translateY(4px); }
|
|
||||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* =============================================
|
|
||||||
URL Upload Tracker — single persistent panel
|
|
||||||
============================================= */
|
|
||||||
|
|
||||||
#url-upload-tracker {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 20px;
|
|
||||||
z-index: 9998;
|
|
||||||
width: 320px;
|
|
||||||
background: var(--nav-bg, #1c1c1c);
|
|
||||||
border: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.15);
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
transition: opacity 1.5s ease, transform 1.5s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#url-upload-tracker.uut-visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────────────── */
|
|
||||||
.uut-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 8px 10px 8px 12px;
|
|
||||||
background: rgba(var(--accent-rgb, 120,120,120), 0.07);
|
|
||||||
border-bottom: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.1);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-header > .fa-solid {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.75;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--white, #fff);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-badge {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0 4px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-actions { display: flex; gap: 2px; }
|
|
||||||
|
|
||||||
.uut-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--white, #fff);
|
|
||||||
opacity: 0.3;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: opacity 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.uut-btn:hover { opacity: 0.8; background: rgba(var(--accent-rgb,120,120,120),0.12); }
|
|
||||||
|
|
||||||
/* ── Body (scrollable) ───────────────────────────── */
|
|
||||||
.uut-body {
|
|
||||||
max-height: 340px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(var(--accent-rgb,120,120,120), 0.25) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Active job rows ─────────────────────────────── */
|
|
||||||
.uut-job {
|
|
||||||
padding: 8px 12px 6px;
|
|
||||||
border-bottom: 1px solid rgba(var(--accent-rgb,120,120,120), 0.06);
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-job--fading { opacity: 0; }
|
|
||||||
|
|
||||||
.uut-job-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-spinner {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 3px;
|
|
||||||
border: 1.5px solid rgba(var(--accent-rgb,120,120,120), 0.18);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: uutSpin 0.75s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes uutSpin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.uut-job-info { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
.uut-url {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--white, #fff);
|
|
||||||
opacity: 0.65;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-stage {
|
|
||||||
font-size: 0.71rem;
|
|
||||||
color: var(--accent);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-speed {
|
|
||||||
font-size: 0.67rem;
|
|
||||||
color: var(--white, #fff);
|
|
||||||
opacity: 0.38;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress bar */
|
|
||||||
.uut-bar {
|
|
||||||
height: 2px;
|
|
||||||
background: rgba(var(--accent-rgb,120,120,120), 0.1);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uut-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.65s ease;
|
|
||||||
background-image: linear-gradient(90deg, var(--accent) 0%, rgba(255,255,255,0.4) 50%, var(--accent) 100%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: uutShimmer 1.6s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,11 +138,13 @@
|
|||||||
transform: translateY(100%) translateY(-3px);
|
transform: translateY(100%) translateY(-3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v0ck:hover .v0ck_player_controls,
|
||||||
.v0ck.v0ck_hover .v0ck_player_controls,
|
.v0ck.v0ck_hover .v0ck_player_controls,
|
||||||
.v0ck.v0ck_swf_active .v0ck_player_controls {
|
.v0ck.v0ck_swf_active .v0ck_player_controls {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v0ck:hover .v0ck_progress,
|
||||||
.v0ck.v0ck_hover .v0ck_progress,
|
.v0ck.v0ck_hover .v0ck_progress,
|
||||||
.v0ck.v0ck_swf_active .v0ck_progress {
|
.v0ck.v0ck_swf_active .v0ck_progress {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -432,9 +434,8 @@
|
|||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
/* Hide images/videos (emojis, inline imgs, webm stickers) inside unrevealed spoiler */
|
/* Hide images (emojis, inline imgs) inside unrevealed spoiler */
|
||||||
.danmaku-pill .dpill-spoiler img,
|
.danmaku-pill .dpill-spoiler img {
|
||||||
.danmaku-pill .dpill-spoiler video {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -447,9 +448,7 @@
|
|||||||
-1px 1px 0 #000, 1px 1px 0 #000;
|
-1px 1px 0 #000, 1px 1px 0 #000;
|
||||||
}
|
}
|
||||||
.danmaku-pill .dpill-spoiler:hover img,
|
.danmaku-pill .dpill-spoiler:hover img,
|
||||||
.danmaku-pill .dpill-spoiler.revealed img,
|
.danmaku-pill .dpill-spoiler.revealed img {
|
||||||
.danmaku-pill .dpill-spoiler:hover video,
|
|
||||||
.danmaku-pill .dpill-spoiler.revealed video {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,38 +512,3 @@
|
|||||||
from { transform: translateX(calc(100vw + 100%)); }
|
from { transform: translateX(calc(100vw + 100%)); }
|
||||||
to { transform: translateX(calc(-100% - 200px)); }
|
to { transform: translateX(calc(-100% - 200px)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Speedup 2x HUD Pill */
|
|
||||||
.v0ck_speed_indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -10px);
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.v0ck_speed_indicator:not(.v0ck_hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide mouse cursor on inactivity in fullscreen */
|
|
||||||
.v0ck.v0ck_fullscreen:not(.v0ck_hover),
|
|
||||||
.v0ck.v0ck_fullscreen:not(.v0ck_hover) * {
|
|
||||||
cursor: none !important;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
24
public/s/img/iconset.svg
Normal file
24
public/s/img/iconset.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
|
||||||
|
<defs>
|
||||||
|
<symbol id="heart_regular" viewBox="0 0 512 512"><path d="M458.4 64.3C400.6 15.7 311.3 23 256 79.3 200.7 23 111.4 15.6 53.6 64.3-21.6 127.6-10.6 230.8 43 285.5l175.4 178.7c10 10.2 23.4 15.9 37.6 15.9 14.3 0 27.6-5.6 37.6-15.8L469 285.6c53.5-54.7 64.7-157.9-10.6-221.3zm-23.6 187.5L259.4 430.5c-2.4 2.4-4.4 2.4-6.8 0L77.2 251.8c-36.5-37.2-43.9-107.6 7.3-150.7 38.9-32.7 98.9-27.8 136.5 10.5l35 35.7 35-35.7c37.8-38.5 97.8-43.2 136.5-10.6 51.1 43.1 43.5 113.9 7.3 150.8z"/></symbol>
|
||||||
|
<symbol id="heart_solid" viewBox="0 0 512 512"><path d="M462.3 62.6C407.5 15.9 326 24.3 275.7 76.2L256 96.5l-19.7-20.3C186.1 24.3 104.5 15.9 49.7 62.6c-62.8 53.6-66.1 149.8-9.9 207.9l193.5 199.8c12.5 12.9 32.8 12.9 45.3 0l193.5-199.8c56.3-58.1 53-154.3-9.8-207.9z"/></symbol>
|
||||||
|
<symbol id="cross" viewBox="0 0 512 512"><path d="M53.6,62.3 L458.4,458.4 M458.4,62.3 L53.6,458.4" style="stroke-linecap: round;stroke-width: 60;"/></symbol>
|
||||||
|
<symbol id="window-maximize" viewBox="0 0 512 512"><path d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm0 394c0 3.3-2.7 6-6 6H54c-3.3 0-6-2.7-6-6V192h416v234z" style="fill: var(--maximize_button);"/></symbol>
|
||||||
|
<symbol id="window-minimize" viewBox="0 0 512 512"><path d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zm-96 464H48V256h320v208zm96-96h-48V144c0-26.5-21.5-48-48-48H144V48h320v320z" style="fill: var(--maximize_button);"/></symbol>
|
||||||
|
<symbol id="plus" viewBox="0 0 512 512"><path d="M256 80c0-8.84-7.16-16-16-16s-16 7.16-16 16v160H64c-8.84 0-16 7.16-16 16s7.16 16 16 16h160v160c0 8.84 7.16 16 16 16s16-7.16 16-16V272h160c8.84 0 16-7.16 16-16s-7.16-16-16-16H272V80z"/></symbol>
|
||||||
|
<symbol id="tag" viewBox="0 0 512 512"><path d="M0 252.118V48C0 21.49 21.49 0 48 0h204.118a48 48 0 0 1 33.941 14.059l211.882 211.882c18.745 18.745 18.745 49.137 0 67.882L293.823 497.941c-18.745 18.745-49.137 18.745-67.882 0L14.059 286.059A48 48 0 0 1 0 252.118zM112 64a48 48 0 1 0 48 48 48.055 48.055 0 0 0-48-48z"/></symbol>
|
||||||
|
<symbol id="shield" viewBox="0 0 512 512"><path d="M466.5 83.71c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91a27.3 27.3 0 0 1-31.4-18.41V28c0-15.46-12.54-28-28-28h-74.8c-15.46 0-28 12.54-28 28v37.39a27.3 27.3 0 0 1-31.4 18.41c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91A27.3 27.3 0 0 1 12 102.12V256c0 141.38 102.34 230.13 234.33 254.12a27.5 27.5 0 0 0 9.34 0C387.66 486.13 490 397.38 490 256V102.12a27.3 27.3 0 0 1-23.5-18.41z"/></symbol>
|
||||||
|
<symbol id="bell_solid" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z"/></symbol>
|
||||||
|
<symbol id="bell_regular" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM44.75 330.4C63.2 312 96 281.3 96 208c0-61.94 43.15-112 112-112s112 50.06 112 112c0 73.3 32.8 104 51.25 122.4 2.8 2.8 3.5 6.9 1.75 10.4s-5.4 5.6-9.15 5.6H52.1c-3.75 0-7.35-2.1-9.1-5.6s-1.05-7.6 1.75-10.4z"/></symbol>
|
||||||
|
<symbol id="pin_solid" viewBox="0 0 24 24"><path d="M12 17h-7v-1.76a2 2 0 0 1 1.11-1.79l1.78-.9a2 0 0 0 1.11-1.76v-4.79a3 3 0 0 1 3-3 3 3 0 0 1 3 3v4.76a2 2 0 0 0 1.11 1.79l1.78.9a2 0 0 1 1.11 1.79v1.76h-7v5z" fill="currentColor" transform="rotate(45 12 12)"/></symbol>
|
||||||
|
<symbol id="pin_regular" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"></line><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path></g></symbol>
|
||||||
|
<symbol id="building" viewBox="0 0 512 512"><path d="M432 64H80c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16zm-32 352H112V96h288v320zM192 288h128v32H192zm0-64h128v32H192zm0-64h128v32H192z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="arrow-down" viewBox="0 0 448 512"><path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="arrow-up" viewBox="0 0 448 512"><path d="M34.9 289.5l-22.2-22.2c-9.4-9.4-9.4-24.6 0-33.9L207 39c9.4-9.4 24.6-9.4 33.9 0l194.3 194.3c9.4 9.4 9.4 24.6 0 33.9l-22.2 22.2c-9.5 9.5-25 9.3-34.3-.4L264 168.6V456c0 13.3-10.7 24-24 24h-32c-13.3 0-24-10.7-24-24V168.6L69.2 289.1c-9.3 9.8-24.8 10-34.3.4z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="star_regular" viewBox="0 0 576 512"><path d="M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 137.4L288 384.8l-124.3 64.9 23.7-137.4L87.5 214.4l138-20.1L288 69.1l62.5 125.2 138 20.1-99.9 97.9z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="star_solid" viewBox="0 0 576 512"><path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z" fill="currentColor"/></symbol>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -60,7 +60,7 @@
|
|||||||
a.textContent = tag.tag;
|
a.textContent = tag.tag;
|
||||||
|
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.classList.add("badge");
|
span.classList.add("badge", "mr-2");
|
||||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
span.classList.add('new-tag-glow');
|
span.classList.add('new-tag-glow');
|
||||||
}
|
}
|
||||||
@@ -155,13 +155,7 @@
|
|||||||
postid,
|
postid,
|
||||||
existingTags: tags,
|
existingTags: tags,
|
||||||
anchorEl: anchor,
|
anchorEl: anchor,
|
||||||
onSubmit: async (tag) => {
|
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||||
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
|
|
||||||
if (res.success && window.invalidateItemCache) {
|
|
||||||
window.invalidateItemCache(postid);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
renderTags
|
renderTags
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -205,6 +199,7 @@
|
|||||||
|
|
||||||
a.appendChild(img);
|
a.appendChild(img);
|
||||||
favcontainer.appendChild(a);
|
favcontainer.appendChild(a);
|
||||||
|
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||||
});
|
});
|
||||||
favcontainer.hidden = false;
|
favcontainer.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -384,91 +379,40 @@
|
|||||||
window.adminSetPassword = async (btn) => {
|
window.adminSetPassword = async (btn) => {
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
|
const password = prompt(`Enter new password for ${name} (min 20 chars):`);
|
||||||
|
if (!password) return;
|
||||||
|
if (password.length < 20) return alert('Password must be at least 20 characters.');
|
||||||
|
|
||||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return;
|
||||||
|
|
||||||
const hint =
|
try {
|
||||||
'Set a new password for <strong>' + escHTML(name) + '</strong>. Must be at least 20 characters.<br><br>' +
|
|
||||||
'<input type="password" id="admin-pw-new" class="input" placeholder="New password (min 20 chars)" style="width:100%;margin-bottom:8px;" autocomplete="new-password">' +
|
|
||||||
'<input type="password" id="admin-pw-confirm" class="input" placeholder="Confirm new password" style="width:100%;" autocomplete="new-password">';
|
|
||||||
|
|
||||||
ModAction.confirm('Set Password', hint, async () => {
|
|
||||||
const password = document.getElementById('admin-pw-new')?.value || '';
|
|
||||||
const confirm = document.getElementById('admin-pw-confirm')?.value || '';
|
|
||||||
if (password.length < 20) throw new Error('Password must be at least 20 characters.');
|
|
||||||
if (password !== confirm) throw new Error('Passwords do not match.');
|
|
||||||
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showFlash(data.msg, 'success');
|
alert(data.msg);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.msg || 'Failed to set password');
|
alert(data.msg || 'Failed to set password');
|
||||||
}
|
}
|
||||||
}, { hideReason: true, confirmText: 'Set Password', unsafeContent: true });
|
} catch (err) {
|
||||||
};
|
alert('Network error');
|
||||||
|
}
|
||||||
window.adminRenameUser = async (btn) => {
|
|
||||||
const id = btn.dataset.id;
|
|
||||||
const currentName = btn.dataset.name;
|
|
||||||
const currentUsername = btn.dataset.username;
|
|
||||||
|
|
||||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
|
||||||
|
|
||||||
ModAction.confirm(
|
|
||||||
'Rename User',
|
|
||||||
'Enter a new login name for <strong>' + escHTML(currentName) + '</strong>.<br>' +
|
|
||||||
'<small style="color:#888;">Current login: <code>' + escHTML(currentUsername) + '</code> — All uploads will be reassigned. User sessions will be invalidated. And the user has to login with the NEW name from now on.</small>',
|
|
||||||
async (newUsername) => {
|
|
||||||
const data = await post('/api/v2/admin/users/rename', { user_id: id, new_username: newUsername });
|
|
||||||
if (data.success) {
|
|
||||||
showFlash(data.msg, 'success');
|
|
||||||
// Update the row in-place: links, text, and all button data attributes
|
|
||||||
const row = document.getElementById('user-row-' + id);
|
|
||||||
if (row) {
|
|
||||||
// Update the name link
|
|
||||||
const link = row.querySelector('.user-info-cell a');
|
|
||||||
if (link) {
|
|
||||||
link.href = '/user/' + data.new_login;
|
|
||||||
// Only overwrite text if there's no display_name (plain username link)
|
|
||||||
if (!link.querySelector('span[style*="accent"]')) {
|
|
||||||
link.textContent = data.new_user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Update all buttons in the row with the new name/username
|
|
||||||
row.querySelectorAll('[data-username]').forEach(el => { el.dataset.username = data.new_login; });
|
|
||||||
row.querySelectorAll('[data-name]').forEach(el => { el.dataset.name = data.new_user; });
|
|
||||||
// Update activity stat links
|
|
||||||
row.querySelectorAll('a[href^="/user/"]').forEach(a => {
|
|
||||||
a.href = a.href.replace(/\/user\/[^/]+/, '/user/' + data.new_login);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(data.msg || 'Rename failed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ hideReason: false, singleLine: true, confirmText: 'Rename', placeholder: 'new username' }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.adminDeleteUser = async (btn) => {
|
window.adminDeleteUser = async (btn) => {
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
|
if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return;
|
||||||
|
|
||||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
try {
|
||||||
|
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||||
ModAction.confirm(
|
if (data.success) {
|
||||||
'Delete User',
|
alert(data.msg);
|
||||||
'<strong style="color:#d9534f">CRITICAL ACTION</strong>: Permanently delete user <strong>' + escHTML(name) + '</strong>?<br><br>All their uploads and comments will be reassigned to <code>deleted_user</code>. <strong>This cannot be undone.</strong>',
|
document.getElementById(`user-row-${id}`)?.remove();
|
||||||
async () => {
|
} else {
|
||||||
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
alert(data.msg || 'Failed to delete user');
|
||||||
if (data.success) {
|
}
|
||||||
showFlash(data.msg, 'success');
|
} catch (err) {
|
||||||
document.getElementById(`user-row-${id}`)?.remove();
|
alert('Network error');
|
||||||
} else {
|
}
|
||||||
throw new Error(data.msg || 'Failed to delete user');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ hideReason: true, confirmText: 'Delete User' }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.adminResetLoginAttempts = async (btn) => {
|
window.adminResetLoginAttempts = async (btn) => {
|
||||||
@@ -478,13 +422,8 @@
|
|||||||
try {
|
try {
|
||||||
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showFlash(data.msg, 'success');
|
alert(data.msg);
|
||||||
// Remove the failed attempt badges and reset button from the row in-place
|
window.location.reload(); // Quickest way to refresh badges
|
||||||
const row = btn.closest('tr');
|
|
||||||
if (row) {
|
|
||||||
row.querySelectorAll('.status-badge[style*="ffcc00"], .status-badge[style*="ff4d4d"]').forEach(el => el.remove());
|
|
||||||
}
|
|
||||||
btn.remove();
|
|
||||||
} else {
|
} else {
|
||||||
alert(data.msg || 'Failed to reset attempts');
|
alert(data.msg || 'Failed to reset attempts');
|
||||||
}
|
}
|
||||||
@@ -496,22 +435,18 @@
|
|||||||
window.adminBulkDeleteHalls = async (btn) => {
|
window.adminBulkDeleteHalls = async (btn) => {
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
|
if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return;
|
||||||
|
|
||||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
try {
|
||||||
|
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||||
ModAction.confirm(
|
if (data.success) {
|
||||||
'Delete All Halls',
|
alert(data.msg);
|
||||||
'Permanently delete <strong>ALL halls</strong> for <strong>' + escHTML(name) + '</strong>? <strong>This cannot be undone.</strong>',
|
} else {
|
||||||
async () => {
|
alert(data.msg || 'Failed to delete halls');
|
||||||
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
}
|
||||||
if (data.success) {
|
} catch (err) {
|
||||||
showFlash(data.msg, 'success');
|
alert('Network error');
|
||||||
} else {
|
}
|
||||||
throw new Error(data.msg || 'Failed to delete halls');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ hideReason: true, confirmText: 'Delete Everything' }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.adminReassignUploads = async (btn) => {
|
window.adminReassignUploads = async (btn) => {
|
||||||
@@ -538,7 +473,7 @@
|
|||||||
throw new Error(res.msg || 'Reassignment failed');
|
throw new Error(res.msg || 'Reassignment failed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ hideReason: false, singleLine: true, confirmText: 'Reassign', placeholder: 'target username' }
|
{ hideReason: false, confirmText: 'Reassign', placeholder: 'target username' }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -533,7 +533,7 @@ class Danmaku {
|
|||||||
const raw = msg.dataset.rawText;
|
const raw = msg.dataset.rawText;
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
// Only re-render if the content is still plain text (no img children)
|
// Only re-render if the content is still plain text (no img children)
|
||||||
if (!msg.querySelector('.dpill-emoji')) {
|
if (!msg.querySelector('img.dpill-emoji')) {
|
||||||
msg.textContent = '';
|
msg.textContent = '';
|
||||||
msg.appendChild(this._renderContent(raw));
|
msg.appendChild(this._renderContent(raw));
|
||||||
}
|
}
|
||||||
@@ -600,25 +600,11 @@ class Danmaku {
|
|||||||
const code = match[3];
|
const code = match[3];
|
||||||
const url = emojiCache[code];
|
const url = emojiCache[code];
|
||||||
if (url) {
|
if (url) {
|
||||||
if (url.endsWith('.webm')) {
|
const img = document.createElement('img');
|
||||||
const vid = document.createElement('video');
|
img.src = url;
|
||||||
vid.src = url;
|
img.alt = `:${code}:`;
|
||||||
vid.className = 'dpill-emoji';
|
img.className = 'dpill-emoji';
|
||||||
vid.muted = true;
|
parent.appendChild(img);
|
||||||
vid.loop = true;
|
|
||||||
vid.autoplay = true;
|
|
||||||
vid.playsInline = true;
|
|
||||||
vid.play().catch(() => {
|
|
||||||
vid.addEventListener('canplay', () => vid.play().catch(() => {}), { once: true });
|
|
||||||
});
|
|
||||||
parent.appendChild(vid);
|
|
||||||
} else {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = url;
|
|
||||||
img.alt = `:${code}:`;
|
|
||||||
img.className = 'dpill-emoji';
|
|
||||||
parent.appendChild(img);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
parent.appendChild(document.createTextNode(match[0]));
|
parent.appendChild(document.createTextNode(match[0]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,19 +46,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark internal drags to prevent them from triggering the dropzone
|
|
||||||
window.addEventListener('dragstart', (e) => {
|
|
||||||
if (e.dataTransfer) {
|
|
||||||
e.dataTransfer.setData('application/x-f0ck-internal', 'true');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global Drag Events
|
// Global Drag Events
|
||||||
window.addEventListener('dragenter', (e) => {
|
window.addEventListener('dragenter', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.location.pathname === '/upload') return;
|
if (window.location.pathname === '/upload') return;
|
||||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
|
||||||
dragCounter++;
|
dragCounter++;
|
||||||
dropOverlay.classList.add('active');
|
dropOverlay.classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -67,17 +59,10 @@
|
|||||||
window.addEventListener('dragover', (e) => {
|
window.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.location.pathname === '/upload') return;
|
if (window.location.pathname === '/upload') return;
|
||||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) {
|
|
||||||
// Change dropEffect to none to indicate this dropzone doesn't accept internal elements
|
|
||||||
e.dataTransfer.dropEffect = 'none';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('dragleave', (e) => {
|
window.addEventListener('dragleave', (e) => {
|
||||||
if (window.location.pathname === '/upload') return;
|
if (window.location.pathname === '/upload') return;
|
||||||
// dragleave doesn't always have reliable dataTransfer.types, but if it does, check it
|
|
||||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
|
|
||||||
|
|
||||||
dragCounter--;
|
dragCounter--;
|
||||||
if (dragCounter <= 0) {
|
if (dragCounter <= 0) {
|
||||||
dragCounter = 0;
|
dragCounter = 0;
|
||||||
@@ -87,8 +72,6 @@
|
|||||||
|
|
||||||
window.addEventListener('drop', (e) => {
|
window.addEventListener('drop', (e) => {
|
||||||
if (window.location.pathname === '/upload') return;
|
if (window.location.pathname === '/upload') return;
|
||||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter = 0;
|
dragCounter = 0;
|
||||||
dropOverlay.classList.remove('active');
|
dropOverlay.classList.remove('active');
|
||||||
|
|||||||
3696
public/s/js/f0ckm.js
3696
public/s/js/f0ckm.js
File diff suppressed because it is too large
Load Diff
@@ -634,12 +634,8 @@
|
|||||||
// Visual state: strike-through when disabled
|
// Visual state: strike-through when disabled
|
||||||
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
|
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
|
||||||
|
|
||||||
// Handle floating badge visibility — only show as fallback when on an item page
|
// Handle floating badge visibility
|
||||||
// with the primary player present but no in-player SWF button (e.g. v0ck not loaded).
|
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
|
||||||
// Never show it just because sidebar .webm stickers exist.
|
|
||||||
const primaryVideo = document.getElementById('my-video') ||
|
|
||||||
document.querySelector('video.viewer, video.v0ck_video');
|
|
||||||
ui.floatingBadge.style.display = (swfButtons.length === 0 && !!primaryVideo) ? 'block' : 'none';
|
|
||||||
|
|
||||||
// Style both (if they exist)
|
// Style both (if they exist)
|
||||||
[ui.floatingBadge, ...swfButtons].forEach(b => {
|
[ui.floatingBadge, ...swfButtons].forEach(b => {
|
||||||
|
|||||||
@@ -23,22 +23,6 @@
|
|||||||
let chatFocused = document.hasFocus();
|
let chatFocused = document.hasFocus();
|
||||||
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
||||||
|
|
||||||
// Shared IntersectionObserver for lazy-loading embedded images.
|
|
||||||
// Images are rendered with data-lazy-src; this observer sets the real src
|
|
||||||
// when the image is within 200px of the visible scroll area.
|
|
||||||
const lazyImgObserver = new IntersectionObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isIntersecting) continue;
|
|
||||||
const img = entry.target;
|
|
||||||
const src = img.dataset.lazySrc;
|
|
||||||
if (src) { img.src = src; delete img.dataset.lazySrc; }
|
|
||||||
lazyImgObserver.unobserve(img);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
rootMargin: '200px', // start loading 200px before entering viewport
|
|
||||||
threshold: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateBadge() {
|
function updateBadge() {
|
||||||
const badge = document.getElementById('gchat-badge');
|
const badge = document.getElementById('gchat-badge');
|
||||||
const bubble = document.getElementById('gchat-reopen-bubble');
|
const bubble = document.getElementById('gchat-reopen-bubble');
|
||||||
@@ -203,7 +187,7 @@
|
|||||||
'gi'
|
'gi'
|
||||||
);
|
);
|
||||||
html = html.replace(imageRegex, url =>
|
html = html.replace(imageRegex, url =>
|
||||||
`<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
`<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6b. Raw video URLs from allowed hosts → <video>
|
// 6b. Raw video URLs from allowed hosts → <video>
|
||||||
@@ -286,7 +270,7 @@
|
|||||||
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
||||||
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
||||||
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
||||||
}
|
}
|
||||||
@@ -326,73 +310,11 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(force = false, smooth = false) {
|
function scrollToBottom(force = false) {
|
||||||
const el = document.getElementById('gchat-messages');
|
const el = document.getElementById('gchat-messages');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
if (!force && !nearBottom) return;
|
if (force || nearBottom) el.scrollTop = el.scrollHeight;
|
||||||
if (smooth) {
|
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
// Double rAF ensures layout is committed before reading scrollHeight
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch the message container and keep snapping to bottom for durationMs.
|
|
||||||
* Only stops if the user actively scrolls up via wheel / touch / keyboard.
|
|
||||||
* Same logic as the DM snapToBottomSticky.
|
|
||||||
*/
|
|
||||||
function startStickyScroll(durationMs = 8000) {
|
|
||||||
const el = document.getElementById('gchat-messages');
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
let userScrolledUp = false;
|
|
||||||
|
|
||||||
const onWheel = (e) => { if (e.deltaY < 0) userScrolledUp = true; };
|
|
||||||
const onKey = (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) userScrolledUp = true; };
|
|
||||||
let touchStartY = 0;
|
|
||||||
const onTouchStart = (e) => { touchStartY = e.touches[0]?.clientY ?? 0; };
|
|
||||||
const onTouchMove = (e) => { if ((e.touches[0]?.clientY ?? 0) > touchStartY + 10) userScrolledUp = true; };
|
|
||||||
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: true });
|
|
||||||
el.addEventListener('keydown', onKey, { passive: true });
|
|
||||||
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
||||||
el.addEventListener('touchmove', onTouchMove, { passive: true });
|
|
||||||
|
|
||||||
let ro;
|
|
||||||
const cleanup = () => {
|
|
||||||
el.removeEventListener('wheel', onWheel);
|
|
||||||
el.removeEventListener('keydown', onKey);
|
|
||||||
el.removeEventListener('touchstart', onTouchStart);
|
|
||||||
el.removeEventListener('touchmove', onTouchMove);
|
|
||||||
if (ro) ro.disconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
|
||||||
// Debounce: batch rapid layout changes (e.g. progressive image renders)
|
|
||||||
// into a single smooth scroll instead of many jarring instant jumps.
|
|
||||||
let debounceTimer = null;
|
|
||||||
ro = new ResizeObserver(() => {
|
|
||||||
if (userScrolledUp) { cleanup(); return; }
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(() => {
|
|
||||||
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
||||||
}, 80);
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => scrollToBottom(true), 300);
|
|
||||||
setTimeout(() => scrollToBottom(true), 800);
|
|
||||||
setTimeout(() => scrollToBottom(true), 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(cleanup, durationMs);
|
|
||||||
|
|
||||||
// First snap is instant (no animation — the panel just opened)
|
|
||||||
scrollToBottom(true);
|
|
||||||
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchYtOembed(cardEl) {
|
async function fetchYtOembed(cardEl) {
|
||||||
@@ -495,19 +417,9 @@
|
|||||||
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
|
// Embedded images: scroll to bottom when loaded + open modal on click
|
||||||
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
|
node.querySelectorAll('.gchat-embed-img img').forEach(img => {
|
||||||
// Only snap to bottom on image load for NEW incoming messages, not history.
|
img.addEventListener('load', () => scrollToBottom(scrollForce));
|
||||||
// History already scrolls once at the end of loadHistory; an extra scroll
|
|
||||||
// here is what causes the double jump.
|
|
||||||
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
|
|
||||||
img.addEventListener('click', () => openImgModal(img.src));
|
|
||||||
img.style.cursor = 'zoom-in';
|
|
||||||
lazyImgObserver.observe(img);
|
|
||||||
});
|
|
||||||
// Already-src'd images (avatars etc.) — same rule
|
|
||||||
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
|
|
||||||
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
|
|
||||||
img.addEventListener('click', () => openImgModal(img.src));
|
img.addEventListener('click', () => openImgModal(img.src));
|
||||||
img.style.cursor = 'zoom-in';
|
img.style.cursor = 'zoom-in';
|
||||||
});
|
});
|
||||||
@@ -532,14 +444,11 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
const container = document.getElementById('gchat-messages');
|
const container = document.getElementById('gchat-messages');
|
||||||
if (!container) return;
|
if (container) container.innerHTML = '';
|
||||||
container.innerHTML = '';
|
(data.messages || []).forEach(m => appendMsg(m));
|
||||||
(data.messages || []).forEach(m => appendMsg(m, false));
|
scrollToBottom(true);
|
||||||
// Double rAF: wait for the browser to commit the layout (panel just became
|
// Also scroll after images have had time to paint
|
||||||
// visible from display:none) before reading scrollHeight.
|
setTimeout(() => scrollToBottom(true), 600);
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Chat] Failed to load history:', e);
|
console.error('[Chat] Failed to load history:', e);
|
||||||
}
|
}
|
||||||
@@ -643,9 +552,7 @@
|
|||||||
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
|
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
|
||||||
if (!isMinimized) {
|
if (!isMinimized) {
|
||||||
clearUnread();
|
clearUnread();
|
||||||
// Wait one rAF so the panel transitions from display:none to its full
|
loadHistory();
|
||||||
// height before loadHistory measures scrollHeight.
|
|
||||||
requestAnimationFrame(() => loadHistory());
|
|
||||||
if (!window.matchMedia('(pointer: coarse)').matches)
|
if (!window.matchMedia('(pointer: coarse)').matches)
|
||||||
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
|
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,69 +18,8 @@
|
|||||||
let draggingLayer = null;
|
let draggingLayer = null;
|
||||||
let hoveredLayer = null;
|
let hoveredLayer = null;
|
||||||
let img = new Image();
|
let img = new Image();
|
||||||
let hasLoadedImage = window.memeTemplate.id !== 'custom' && window.memeTemplate.category !== 'Custom';
|
|
||||||
|
|
||||||
// Show the local file picker only when no pre-selected template exists
|
|
||||||
if (!hasLoadedImage) {
|
|
||||||
const customSelector = document.getElementById('customTemplateSelector');
|
|
||||||
if (customSelector) customSelector.style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const memeFont = 'Impact, Charcoal, sans-serif';
|
const memeFont = 'Impact, Charcoal, sans-serif';
|
||||||
const memeLetterSpacing = '0.03em';
|
|
||||||
|
|
||||||
function wrapText(ctx, text, maxWidth) {
|
|
||||||
const paragraphs = text.split('\n');
|
|
||||||
const lines = [];
|
|
||||||
|
|
||||||
paragraphs.forEach(paragraph => {
|
|
||||||
if (paragraph.trim() === '') {
|
|
||||||
lines.push('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const words = paragraph.split(' ').filter(w => w !== '');
|
|
||||||
let currentLine = '';
|
|
||||||
|
|
||||||
words.forEach(word => {
|
|
||||||
const testLine = currentLine ? currentLine + ' ' + word : word;
|
|
||||||
let metrics = ctx.measureText(testLine);
|
|
||||||
|
|
||||||
if (metrics.width <= maxWidth) {
|
|
||||||
currentLine = testLine;
|
|
||||||
} else {
|
|
||||||
if (currentLine !== '') {
|
|
||||||
lines.push(currentLine);
|
|
||||||
currentLine = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics = ctx.measureText(word);
|
|
||||||
if (metrics.width <= maxWidth) {
|
|
||||||
currentLine = word;
|
|
||||||
} else {
|
|
||||||
let charLine = '';
|
|
||||||
for (let i = 0; i < word.length; i++) {
|
|
||||||
const char = word[i];
|
|
||||||
const testCharLine = charLine + char;
|
|
||||||
if (ctx.measureText(testCharLine).width > maxWidth && charLine !== '') {
|
|
||||||
lines.push(charLine);
|
|
||||||
charLine = char;
|
|
||||||
} else {
|
|
||||||
charLine = testCharLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentLine = charLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (currentLine) {
|
|
||||||
lines.push(currentLine);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Image Setup
|
// Image Setup
|
||||||
img.crossOrigin = "anonymous";
|
img.crossOrigin = "anonymous";
|
||||||
@@ -90,23 +29,11 @@
|
|||||||
|
|
||||||
const defaultSize = 40;
|
const defaultSize = 40;
|
||||||
|
|
||||||
// Initial layers - only set if we don't have any layers yet and we have loaded an image
|
// Initial layers
|
||||||
if (textLayers.length === 0 && hasLoadedImage) {
|
textLayers = [
|
||||||
textLayers = [
|
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
||||||
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
||||||
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
];
|
||||||
];
|
|
||||||
} else if (hasLoadedImage) {
|
|
||||||
// Keep the text layers but adjust their coordinates to be in-bounds if they exceed new boundaries
|
|
||||||
textLayers.forEach(layer => {
|
|
||||||
if (layer.x > canvas.width) {
|
|
||||||
layer.x = canvas.width / 2;
|
|
||||||
}
|
|
||||||
if (layer.y > canvas.height) {
|
|
||||||
layer.y = canvas.height - 100;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderInputs();
|
renderInputs();
|
||||||
draw();
|
draw();
|
||||||
@@ -115,65 +42,11 @@
|
|||||||
|
|
||||||
// Ensure font is loaded before first draw
|
// Ensure font is loaded before first draw
|
||||||
if (document.fonts) {
|
if (document.fonts) {
|
||||||
// Force browser to load the Impact font
|
|
||||||
document.fonts.load('bold 40px Impact');
|
|
||||||
document.fonts.load('bold 80px Impact');
|
|
||||||
document.fonts.load('bold 150px Impact');
|
|
||||||
document.fonts.ready.then(() => {
|
document.fonts.ready.then(() => {
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSlider(container, min, max, initValue, onChange) {
|
|
||||||
container.style.cssText = 'position:relative;height:28px;display:flex;align-items:center;flex:1;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;';
|
|
||||||
|
|
||||||
const track = document.createElement('div');
|
|
||||||
track.style.cssText = 'position:absolute;left:8px;right:8px;height:4px;background:#333;border-radius:2px;top:50%;transform:translateY(-50%);';
|
|
||||||
|
|
||||||
const fill = document.createElement('div');
|
|
||||||
fill.style.cssText = 'position:absolute;left:0;height:100%;background:var(--accent,#9f0);border-radius:2px;pointer-events:none;';
|
|
||||||
|
|
||||||
const thumb = document.createElement('div');
|
|
||||||
thumb.style.cssText = 'position:absolute;width:18px;height:18px;background:var(--accent,#9f0);border-radius:50%;top:50%;transform:translate(-50%,-50%);box-shadow:0 0 6px rgba(0,0,0,.6);pointer-events:none;transition:transform .1s;';
|
|
||||||
|
|
||||||
const setRatio = (r) => {
|
|
||||||
fill.style.width = (r * 100) + '%';
|
|
||||||
thumb.style.left = (r * 100) + '%';
|
|
||||||
};
|
|
||||||
setRatio((initValue - min) / (max - min));
|
|
||||||
|
|
||||||
track.appendChild(fill);
|
|
||||||
track.appendChild(thumb);
|
|
||||||
container.appendChild(track);
|
|
||||||
|
|
||||||
const valueFromClientX = (clientX) => {
|
|
||||||
const rect = track.getBoundingClientRect();
|
|
||||||
const r = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
||||||
setRatio(r);
|
|
||||||
return Math.round(min + r * (max - min));
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener('pointerdown', (e) => {
|
|
||||||
e.preventDefault(); // block keyboard + scroll takeover
|
|
||||||
container.setPointerCapture(e.pointerId);
|
|
||||||
thumb.style.transform = 'translate(-50%,-50%) scale(1.25)';
|
|
||||||
onChange(valueFromClientX(e.clientX));
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
container.addEventListener('pointermove', (e) => {
|
|
||||||
if (!container.hasPointerCapture(e.pointerId)) return;
|
|
||||||
onChange(valueFromClientX(e.clientX));
|
|
||||||
});
|
|
||||||
|
|
||||||
const onEnd = (e) => {
|
|
||||||
if (!container.hasPointerCapture(e.pointerId)) return;
|
|
||||||
container.releasePointerCapture(e.pointerId);
|
|
||||||
thumb.style.transform = 'translate(-50%,-50%) scale(1)';
|
|
||||||
};
|
|
||||||
container.addEventListener('pointerup', onEnd);
|
|
||||||
container.addEventListener('pointercancel', onEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInputs() {
|
function renderInputs() {
|
||||||
layersContainer.innerHTML = '';
|
layersContainer.innerHTML = '';
|
||||||
textLayers.forEach((layer, index) => {
|
textLayers.forEach((layer, index) => {
|
||||||
@@ -191,7 +64,7 @@
|
|||||||
|
|
||||||
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
||||||
<div class="layer-fs-slider" style="flex: 1;"></div>
|
<input type="range" class="layer-fs-input" min="10" max="200" value="${layer.fontSize}" style="flex: 1;">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -201,11 +74,11 @@
|
|||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
const fsSlider = div.querySelector('.layer-fs-slider');
|
const fsInput = div.querySelector('.layer-fs-input');
|
||||||
const fsVal = div.querySelector('.layer-fs-val');
|
const fsVal = div.querySelector('.layer-fs-val');
|
||||||
createSlider(fsSlider, 10, 200, layer.fontSize, (val) => {
|
fsInput.addEventListener('input', (e) => {
|
||||||
layer.fontSize = val;
|
layer.fontSize = parseInt(e.target.value);
|
||||||
fsVal.textContent = val;
|
fsVal.textContent = layer.fontSize;
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,10 +94,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
addTextBtn.addEventListener('click', () => {
|
addTextBtn.addEventListener('click', () => {
|
||||||
if (!hasLoadedImage) {
|
|
||||||
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
textLayers.push({
|
textLayers.push({
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
text: 'NEW TEXT',
|
text: 'NEW TEXT',
|
||||||
@@ -256,11 +125,10 @@
|
|||||||
|
|
||||||
const fontSize = layer.fontSize || 40;
|
const fontSize = layer.fontSize || 40;
|
||||||
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||||
ctx.letterSpacing = memeLetterSpacing;
|
|
||||||
|
|
||||||
let displayStr = layer.text.toUpperCase();
|
let displayStr = layer.text.toUpperCase();
|
||||||
const lines = wrapText(ctx, displayStr, canvas.width * 0.9);
|
const lines = displayStr.split('\n');
|
||||||
const h = lines.length * fontSize * 1.25;
|
const h = lines.length * fontSize * 1.1;
|
||||||
const w = canvas.width * 0.9;
|
const w = canvas.width * 0.9;
|
||||||
|
|
||||||
// Box for the dragged/hovered layer (top-most layer gets preference)
|
// Box for the dragged/hovered layer (top-most layer gets preference)
|
||||||
@@ -274,13 +142,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
const yOffset = i * (fontSize * 1.25);
|
const yOffset = i * (fontSize * 1.1);
|
||||||
const renderX = Math.round(layer.x);
|
const renderX = Math.round(layer.x);
|
||||||
const renderY = Math.round(layer.y + yOffset);
|
const renderY = Math.round(layer.y + yOffset);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
||||||
ctx.letterSpacing = memeLetterSpacing;
|
|
||||||
ctx.strokeStyle = 'black';
|
ctx.strokeStyle = 'black';
|
||||||
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
||||||
ctx.strokeText(line, renderX, renderY);
|
ctx.strokeText(line, renderX, renderY);
|
||||||
@@ -306,13 +173,9 @@
|
|||||||
const isInsideText = (pt, layer) => {
|
const isInsideText = (pt, layer) => {
|
||||||
if (!layer.text) return false;
|
if (!layer.text) return false;
|
||||||
const fontSize = layer.fontSize || 40;
|
const fontSize = layer.fontSize || 40;
|
||||||
ctx.save();
|
const lines = layer.text.split('\n');
|
||||||
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
|
||||||
ctx.letterSpacing = memeLetterSpacing;
|
|
||||||
const lines = wrapText(ctx, layer.text.toUpperCase(), canvas.width * 0.9);
|
|
||||||
ctx.restore();
|
|
||||||
const w = canvas.width * 0.95;
|
const w = canvas.width * 0.95;
|
||||||
const h = lines.length * fontSize * 1.25;
|
const h = lines.length * fontSize * 1.2;
|
||||||
|
|
||||||
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
||||||
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
||||||
@@ -369,22 +232,16 @@
|
|||||||
canvas.addEventListener('mousedown', onStart);
|
canvas.addEventListener('mousedown', onStart);
|
||||||
// Upload
|
// Upload
|
||||||
uploadBtn.addEventListener('click', async () => {
|
uploadBtn.addEventListener('click', async () => {
|
||||||
if (!hasLoadedImage) {
|
|
||||||
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
||||||
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
||||||
const templateId = (window.memeTemplate && window.memeTemplate.id) ? window.memeTemplate.id.toLowerCase() : '';
|
|
||||||
|
|
||||||
const isOrakelVon10 = subCategory === 'von10';
|
const isOrakelVon10 = subCategory === 'von10';
|
||||||
const isOrakelUser = subCategory === 'user';
|
const isOrakelUser = subCategory === 'user';
|
||||||
const isOrakelBingoApu = subCategory === 'bingoapu' || templateId === 'bingoapu';
|
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10;
|
||||||
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10 && !isOrakelBingoApu;
|
|
||||||
|
|
||||||
let uploadCanvas = canvas;
|
let uploadCanvas = canvas;
|
||||||
|
|
||||||
if (isOrakelNormal || isOrakelUser || isOrakelVon10 || isOrakelBingoApu) {
|
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
|
||||||
// Create an off-screen canvas to apply the orakel answer silently
|
// Create an off-screen canvas to apply the orakel answer silently
|
||||||
uploadCanvas = document.createElement('canvas');
|
uploadCanvas = document.createElement('canvas');
|
||||||
uploadCanvas.width = canvas.width;
|
uploadCanvas.width = canvas.width;
|
||||||
@@ -395,7 +252,6 @@
|
|||||||
uCtx.drawImage(canvas, 0, 0);
|
uCtx.drawImage(canvas, 0, 0);
|
||||||
|
|
||||||
let result = '';
|
let result = '';
|
||||||
let selectedCorner = null;
|
|
||||||
if (isOrakelNormal) {
|
if (isOrakelNormal) {
|
||||||
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
||||||
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
||||||
@@ -409,124 +265,92 @@
|
|||||||
}
|
}
|
||||||
} else if (isOrakelVon10) {
|
} else if (isOrakelVon10) {
|
||||||
result = Math.floor(Math.random() * 11).toString();
|
result = Math.floor(Math.random() * 11).toString();
|
||||||
} else if (isOrakelBingoApu) {
|
|
||||||
const corners = [
|
|
||||||
{ x: 60, y: 100, label: 'YES' },
|
|
||||||
{ x: 573, y: 100, label: 'NO' },
|
|
||||||
{ x: 60, y: 615, label: 'NO' },
|
|
||||||
{ x: 573, y: 615, label: 'YES' }
|
|
||||||
];
|
|
||||||
selectedCorner = corners[Math.floor(Math.random() * corners.length)];
|
|
||||||
result = selectedCorner.label;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Orakel result on the hidden canvas
|
// Draw Orakel result on the hidden canvas
|
||||||
uCtx.save();
|
uCtx.save();
|
||||||
if (isOrakelBingoApu) {
|
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
||||||
// Draw "KOPS" in normal meme text style on the selected corner
|
uCtx.textAlign = 'center';
|
||||||
uCtx.font = `bold 28px ${memeFont}`;
|
uCtx.textBaseline = 'middle';
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
uCtx.textAlign = 'center';
|
|
||||||
uCtx.textBaseline = 'middle';
|
|
||||||
uCtx.fillStyle = '#fff';
|
|
||||||
uCtx.strokeStyle = '#000';
|
|
||||||
uCtx.lineWidth = 4;
|
|
||||||
uCtx.miterLimit = 2;
|
|
||||||
|
|
||||||
uCtx.strokeText('KOPS', selectedCorner.x, selectedCorner.y);
|
if (isOrakelNormal) {
|
||||||
uCtx.fillText('KOPS', selectedCorner.x, selectedCorner.y);
|
uCtx.shadowBlur = 20;
|
||||||
|
uCtx.shadowColor = 'rgba(101, 37, 212, 1)';
|
||||||
|
} else if (isOrakelVon10) {
|
||||||
|
uCtx.shadowBlur = 0; // No shadow as requested
|
||||||
} else {
|
} else {
|
||||||
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
// No shadow for the User Orakel
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
uCtx.shadowBlur = 0;
|
||||||
uCtx.textAlign = 'center';
|
}
|
||||||
uCtx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
if (isOrakelNormal) {
|
uCtx.fillStyle = '#fff';
|
||||||
uCtx.shadowBlur = 20;
|
uCtx.strokeStyle = '#000';
|
||||||
uCtx.shadowColor = 'rgba(101, 37, 212, 1)';
|
uCtx.lineWidth = 10;
|
||||||
} else if (isOrakelVon10) {
|
uCtx.miterLimit = 2;
|
||||||
uCtx.shadowBlur = 0; // No shadow as requested
|
|
||||||
} else {
|
|
||||||
// No shadow for the User Orakel
|
|
||||||
uCtx.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uCtx.fillStyle = '#fff';
|
// Adjust position for user Orakel (reverting to +10 offset)
|
||||||
uCtx.strokeStyle = '#000';
|
let yPos = Math.round(isOrakelUser ? (uploadCanvas.height / 2 + 10) : (uploadCanvas.height / 2 + 50));
|
||||||
uCtx.lineWidth = 10;
|
|
||||||
uCtx.miterLimit = 2;
|
|
||||||
|
|
||||||
// Adjust position for user Orakel (reverting to +10 offset)
|
if (isOrakelVon10) {
|
||||||
let yPos = Math.round(isOrakelUser ? (uploadCanvas.height / 2 + 10) : (uploadCanvas.height / 2 + 50));
|
yPos = Math.round(uploadCanvas.height / 2 ); // 1px lower
|
||||||
|
}
|
||||||
|
|
||||||
if (isOrakelVon10) {
|
const xPos = Math.round(uploadCanvas.width / 2);
|
||||||
yPos = Math.round(uploadCanvas.height / 2 ); // 1px lower
|
|
||||||
}
|
|
||||||
|
|
||||||
const xPos = Math.round(uploadCanvas.width / 2);
|
// Auto-fit font size for user orakel — shrink until text fits within image width
|
||||||
|
let orakelFontSize = isOrakelVon10 ? 150 : 80;
|
||||||
|
const maxTextWidth = uploadCanvas.width - 80; // 40px padding each side
|
||||||
|
|
||||||
// Auto-fit font size for user orakel — shrink until text fits within image width
|
if (isOrakelUser) {
|
||||||
let orakelFontSize = isOrakelVon10 ? 150 : 80;
|
const parts = result.split('|||');
|
||||||
const maxTextWidth = uploadCanvas.width - 80; // 40px padding each side
|
const namePart = parts[0];
|
||||||
|
const idPart = parts.length > 1 ? `(${parts[1]})` : '';
|
||||||
|
const combinedText = idPart ? `${namePart} ${idPart}` : namePart;
|
||||||
|
|
||||||
if (isOrakelUser) {
|
// Even tighter threshold for User Orakel (approx 25% total padding)
|
||||||
const parts = result.split('|||');
|
const userMaxWidth = Math.round(uploadCanvas.width * 0.75);
|
||||||
const namePart = parts[0];
|
|
||||||
const idPart = parts.length > 1 ? `(${parts[1]})` : '';
|
|
||||||
const combinedText = idPart ? `${namePart} ${idPart}` : namePart;
|
|
||||||
|
|
||||||
// Even tighter threshold for User Orakel (approx 25% total padding)
|
let currentFontSize = 74;
|
||||||
const userMaxWidth = Math.round(uploadCanvas.width * 0.75);
|
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||||
|
|
||||||
let currentFontSize = 74;
|
// First attempt: Shrink entire text on one line down to 58px if needed
|
||||||
|
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
||||||
|
currentFontSize -= 2;
|
||||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
|
|
||||||
// First attempt: Shrink entire text on one line down to 58px if needed
|
|
||||||
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
|
||||||
currentFontSize -= 2;
|
|
||||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
|
||||||
|
|
||||||
if (combinedFits) {
|
|
||||||
// Single line — potentially shrunk for long names
|
|
||||||
uCtx.fillText(combinedText, xPos, yPos);
|
|
||||||
} else {
|
|
||||||
// Two lines — auto-fit just the name, ID below
|
|
||||||
let nameFontSize = 74;
|
|
||||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
|
||||||
nameFontSize -= 2;
|
|
||||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
|
||||||
const lineGap = Math.round(nameFontSize * 1.25);
|
|
||||||
const nameY = Math.round(yPos - lineGap / 2);
|
|
||||||
const idY = Math.round(yPos + lineGap / 2) + 2;
|
|
||||||
|
|
||||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
uCtx.fillText(namePart, xPos, nameY);
|
|
||||||
|
|
||||||
if (idPart) {
|
|
||||||
uCtx.font = `bold ${idFontSize}px Impact`;
|
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
uCtx.fillText(idPart, xPos, idY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal / von10 — single line as before
|
|
||||||
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
|
||||||
uCtx.letterSpacing = memeLetterSpacing;
|
|
||||||
uCtx.strokeText(result, xPos, yPos);
|
|
||||||
uCtx.fillText(result, xPos, yPos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
||||||
|
|
||||||
|
if (combinedFits) {
|
||||||
|
// Single line — potentially shrunk for long names
|
||||||
|
uCtx.fillText(combinedText, xPos, yPos);
|
||||||
|
} else {
|
||||||
|
// Two lines — auto-fit just the name, ID below
|
||||||
|
let nameFontSize = 74;
|
||||||
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
||||||
|
nameFontSize -= 2;
|
||||||
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
||||||
|
const lineGap = Math.round(nameFontSize * 0.65);
|
||||||
|
const nameY = Math.round(yPos - lineGap / 2);
|
||||||
|
const idY = Math.round(yPos + lineGap / 2) + 2;
|
||||||
|
|
||||||
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
uCtx.fillText(namePart, xPos, nameY);
|
||||||
|
|
||||||
|
if (idPart) {
|
||||||
|
uCtx.font = `bold ${idFontSize}px Impact`;
|
||||||
|
uCtx.fillText(idPart, xPos, idY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal / von10 — single line as before
|
||||||
|
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
||||||
|
uCtx.strokeText(result, xPos, yPos);
|
||||||
|
uCtx.fillText(result, xPos, yPos);
|
||||||
}
|
}
|
||||||
uCtx.restore();
|
uCtx.restore();
|
||||||
}
|
}
|
||||||
@@ -560,22 +384,13 @@
|
|||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.manual_approval) {
|
const dest = result.redirect || '/meme';
|
||||||
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
|
if (window.loadItemAjax) {
|
||||||
if (window.loadPageAjax) {
|
window.loadItemAjax(dest);
|
||||||
window.loadPageAjax('/');
|
} else if (window.loadPageAjax) {
|
||||||
} else {
|
window.loadPageAjax(dest);
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const dest = result.redirect || '/meme';
|
window.location.href = dest;
|
||||||
if (window.loadItemAjax) {
|
|
||||||
window.loadItemAjax(dest);
|
|
||||||
} else if (window.loadPageAjax) {
|
|
||||||
window.loadPageAjax(dest);
|
|
||||||
} else {
|
|
||||||
window.location.href = dest;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -589,79 +404,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom Local Image Selector logic
|
|
||||||
const fileInput = document.getElementById('customTemplateFile');
|
|
||||||
const selectFileBtn = document.getElementById('selectCustomFileBtn');
|
|
||||||
const canvasWrapper = document.querySelector('.canvas-wrapper');
|
|
||||||
|
|
||||||
const loadLocalImage = (file) => {
|
|
||||||
if (file && file.type.startsWith('image/')) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
hasLoadedImage = true;
|
|
||||||
img.src = event.target.result;
|
|
||||||
|
|
||||||
// Update template metadata
|
|
||||||
window.memeTemplate.name = file.name.replace(/\.[^/.]+$/, "");
|
|
||||||
|
|
||||||
// Update header title dynamically
|
|
||||||
const headerTitle = document.querySelector('.meme-title');
|
|
||||||
if (headerTitle) {
|
|
||||||
const baseTitle = window.f0ckI18n?.meme?.create_meme || 'Create Meme:';
|
|
||||||
headerTitle.innerHTML = `${baseTitle} ${window.memeTemplate.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update tags input value if tags are present
|
|
||||||
const tagsInput = document.getElementById('tags');
|
|
||||||
if (tagsInput) {
|
|
||||||
tagsInput.value = `meme, Custom, ${window.memeTemplate.name}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectFileBtn && fileInput) {
|
|
||||||
selectFileBtn.addEventListener('click', () => {
|
|
||||||
fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
loadLocalImage(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML5 Drag & Drop Support
|
|
||||||
if (canvas && canvasWrapper) {
|
|
||||||
const preventDefaults = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
||||||
canvasWrapper.addEventListener(eventName, preventDefaults, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(eventName => {
|
|
||||||
canvasWrapper.addEventListener(eventName, () => {
|
|
||||||
canvasWrapper.classList.add('drag-hover');
|
|
||||||
}, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(eventName => {
|
|
||||||
canvasWrapper.addEventListener(eventName, () => {
|
|
||||||
canvasWrapper.classList.remove('drag-hover');
|
|
||||||
}, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
canvasWrapper.addEventListener('drop', (e) => {
|
|
||||||
const dt = e.dataTransfer;
|
|
||||||
const file = dt.files[0];
|
|
||||||
loadLocalImage(file);
|
|
||||||
}, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial draw
|
// Initial draw
|
||||||
setTimeout(draw, 300);
|
setTimeout(draw, 300);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -209,12 +209,6 @@
|
|||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
function decodeHtmlEntities(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const txt = document.createElement('textarea');
|
|
||||||
txt.innerHTML = String(str);
|
|
||||||
return txt.value;
|
|
||||||
}
|
|
||||||
function timeAgo(iso) {
|
function timeAgo(iso) {
|
||||||
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
||||||
const i = window.f0ckI18n || {};
|
const i = window.f0ckI18n || {};
|
||||||
@@ -235,33 +229,28 @@
|
|||||||
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
|
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
|
||||||
}
|
}
|
||||||
function hashId() {
|
function hashId() {
|
||||||
// Check path first /abyss/1234 or /abyss/gif/1234
|
// Strip the leading '#' and allow numeric IDs or board/postid format
|
||||||
const pathClean = location.pathname.replace(/\/$/, '');
|
|
||||||
const pathMatch = pathClean.match(/\/abyss\/([a-zA-Z0-9_\/-]+)$/);
|
|
||||||
if (pathMatch) return pathMatch[1];
|
|
||||||
|
|
||||||
// Fallback to hash
|
|
||||||
const raw = location.hash.replace(/^#/, '').trim();
|
const raw = location.hash.replace(/^#/, '').trim();
|
||||||
if (/^\d+$/.test(raw)) return raw;
|
if (/^\d+$/.test(raw)) return raw;
|
||||||
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
|
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
let lastPushedUrl = location.pathname + location.hash;
|
let lastPushedHash = location.hash;
|
||||||
function pushHash(id) {
|
function pushHash(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const newUrl = '/abyss/' + id;
|
const newHash = '#' + id;
|
||||||
if (newUrl === lastPushedUrl) return;
|
if (newHash === lastPushedHash) return;
|
||||||
lastPushedUrl = newUrl;
|
lastPushedHash = newHash;
|
||||||
history.pushState({ scrollerId: id }, '', newUrl);
|
history.pushState({ scrollerId: id }, '', '/abyss' + newHash);
|
||||||
updateCacheActiveId(id);
|
updateCacheActiveId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle back/forward within abyss — scroll to the target slide
|
// Handle back/forward within abyss — scroll to the target slide
|
||||||
window.addEventListener('popstate', (e) => {
|
window.addEventListener('popstate', (e) => {
|
||||||
if (!document.body.classList.contains('scroller-active')) return;
|
if (!document.body.classList.contains('scroller-active')) return;
|
||||||
const id = e.state?.scrollerId || hashId();
|
const id = e.state?.scrollerId || location.hash.replace('#', '');
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
lastPushedUrl = '/abyss/' + id;
|
lastPushedHash = '#' + id;
|
||||||
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
|
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
|
||||||
if (slide) {
|
if (slide) {
|
||||||
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -718,12 +707,9 @@
|
|||||||
const id = contextLink.dataset.id;
|
const id = contextLink.dataset.id;
|
||||||
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
||||||
if (target) {
|
if (target) {
|
||||||
// Clear any previous persistent highlight
|
|
||||||
commentsList.querySelectorAll('.comment-linked').forEach(el => el.classList.remove('comment-linked'));
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
// Flash the animation for attention, then keep the persistent highlight
|
target.classList.add('highlight-comment');
|
||||||
target.classList.add('highlight-comment', 'comment-linked');
|
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
|
||||||
setTimeout(() => target.classList.remove('highlight-comment'), 2500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1380,15 +1366,18 @@
|
|||||||
${window.scrollerLoggedIn ? `
|
${window.scrollerLoggedIn ? `
|
||||||
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
||||||
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
||||||
|
<span class="scroll-btn-label"></span>
|
||||||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
|
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
|
||||||
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
||||||
|
<span class="scroll-btn-label"></span>
|
||||||
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
${window.scrollerLoggedIn ? `
|
${window.scrollerLoggedIn ? `
|
||||||
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
|
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
|
||||||
|
<span class="scroll-btn-label"></span>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
|
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
|
||||||
@@ -1396,7 +1385,7 @@
|
|||||||
</button>
|
</button>
|
||||||
${item.is_external ? (
|
${item.is_external ? (
|
||||||
item.local_id
|
item.local_id
|
||||||
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
? `<a class="scroll-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
||||||
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
||||||
</a>`
|
</a>`
|
||||||
@@ -1557,13 +1546,24 @@
|
|||||||
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
|
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
|
||||||
if (rBadge) {
|
if (rBadge) {
|
||||||
if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle');
|
if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle');
|
||||||
rBadge.addEventListener('click', e => {
|
rBadge.addEventListener('click', async e => {
|
||||||
if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold
|
if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const slideEl = rBadge.closest('.scroll-slide');
|
const slideEl = rBadge.closest('.scroll-slide');
|
||||||
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
|
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
|
||||||
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
|
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
|
||||||
cycleRatingOptimistic(rBadge, id, false);
|
try {
|
||||||
|
const resp = await fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
rBadge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||||
|
rBadge.textContent = data.rating_label;
|
||||||
|
rBadge.dataset.rating = data.rating_class;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1688,14 +1688,13 @@
|
|||||||
avatar: '/a/default.png',
|
avatar: '/a/default.png',
|
||||||
stamp: p.time,
|
stamp: p.time,
|
||||||
timeago: timeAgo(p.time * 1000),
|
timeago: timeAgo(p.time * 1000),
|
||||||
tags: ``,
|
tags: `4chan, /${data.board}/`,
|
||||||
is_video: isVideo,
|
is_video: isVideo,
|
||||||
is_image: isImage,
|
is_image: isImage,
|
||||||
is_audio: false,
|
is_audio: false,
|
||||||
comment_count: p.replies || 0,
|
comment_count: p.replies || 0,
|
||||||
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
|
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
|
||||||
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged'),
|
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
|
||||||
original_filename: p.filename ? `${decodeHtmlEntities(p.filename)}${ext}` : null
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -1826,7 +1825,7 @@
|
|||||||
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
|
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
|
||||||
const first = feed.querySelector('.scroll-slide:not([data-lock])');
|
const first = feed.querySelector('.scroll-slide:not([data-lock])');
|
||||||
const toActivate = target || first;
|
const toActivate = target || first;
|
||||||
if (toActivate) setTimeout(() => { toActivate.scrollIntoView({ behavior: 'instant', block: 'start' }); activateSlide(toActivate); hideLoader(); }, 200);
|
if (toActivate) setTimeout(() => { activateSlide(toActivate); hideLoader(); }, 200);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SCROLLER] Fetch error:', err);
|
console.error('[SCROLLER] Fetch error:', err);
|
||||||
@@ -1859,9 +1858,8 @@
|
|||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
url: item.external_media_url || item.dest,
|
url: item.external_media_url || item.dest,
|
||||||
rating: rating,
|
rating: rating,
|
||||||
tags: '',
|
tags: '4chan',
|
||||||
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
|
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`
|
||||||
...(item.original_filename ? { original_filename: item.original_filename } : {})
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -1887,7 +1885,7 @@
|
|||||||
// Update button to link to the new site-internal post
|
// Update button to link to the new site-internal post
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.outerHTML = `
|
btn.outerHTML = `
|
||||||
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
|
<a href="/${data.item_id}" target="_blank" class="scroll-btn success" style="text-decoration:none;">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
||||||
<span class="scroll-btn-label">View</span>
|
<span class="scroll-btn-label">View</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -1927,73 +1925,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleRatingOptimistic(badge, id, showToast = false) {
|
|
||||||
if (!badge || !id || isNaN(id)) return;
|
|
||||||
|
|
||||||
let currentRating = badge.dataset.rating || '';
|
|
||||||
if (!currentRating) {
|
|
||||||
if (badge.classList.contains('sfw')) currentRating = 'sfw';
|
|
||||||
else if (badge.classList.contains('nsfw')) currentRating = 'nsfw';
|
|
||||||
else if (badge.classList.contains('nsfl')) currentRating = 'nsfl';
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextRating = 'sfw';
|
|
||||||
if (currentRating === 'sfw') nextRating = 'nsfw';
|
|
||||||
else if (currentRating === 'nsfw') nextRating = 'nsfl';
|
|
||||||
|
|
||||||
const mapping = {
|
|
||||||
sfw: { label: 'SFW', cls: 'sfw', toast: '🛡 SFW' },
|
|
||||||
nsfw: { label: 'NSFW', cls: 'nsfw', toast: '🔥 NSFW' },
|
|
||||||
nsfl: { label: 'NSFL', cls: 'nsfl', toast: '💀 NSFL' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const info = mapping[nextRating];
|
|
||||||
|
|
||||||
const oldClassName = badge.className;
|
|
||||||
const oldTextContent = badge.textContent;
|
|
||||||
const oldDatasetRating = badge.dataset.rating;
|
|
||||||
|
|
||||||
// Track active request ID to ignore out-of-order race conditions on rapid keypresses
|
|
||||||
const reqId = (badge._lastCycleReqId || 0) + 1;
|
|
||||||
badge._lastCycleReqId = reqId;
|
|
||||||
|
|
||||||
// Optimistically apply new state
|
|
||||||
badge.className = `scroll-rating ${info.cls}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
|
||||||
badge.textContent = info.label;
|
|
||||||
badge.dataset.rating = info.cls;
|
|
||||||
|
|
||||||
if (showToast) {
|
|
||||||
showShareToast(info.toast);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
|
|
||||||
if (!data.success) {
|
|
||||||
revert();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Verify we match actual server result
|
|
||||||
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
|
||||||
badge.textContent = data.rating_label;
|
|
||||||
badge.dataset.rating = data.rating_class;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
|
|
||||||
revert();
|
|
||||||
});
|
|
||||||
|
|
||||||
function revert() {
|
|
||||||
badge.className = oldClassName;
|
|
||||||
badge.textContent = oldTextContent;
|
|
||||||
badge.dataset.rating = oldDatasetRating;
|
|
||||||
showShareToast('⚠️ Failed to update rating');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadFeed() {
|
function reloadFeed() {
|
||||||
clearCache();
|
clearCache();
|
||||||
@@ -2805,7 +2736,7 @@
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
username: window.scrollerUsername,
|
username: window.scrollerUsername,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
content: data.comment?.content ?? content,
|
content: content,
|
||||||
created_at: null
|
created_at: null
|
||||||
}, !!window.scrollerLoggedIn);
|
}, !!window.scrollerLoggedIn);
|
||||||
// Set avatar from global
|
// Set avatar from global
|
||||||
@@ -3205,12 +3136,26 @@
|
|||||||
else if (e.key === 'p' || e.key === 'P') {
|
else if (e.key === 'p' || e.key === 'P') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!currentSlide || !window.scrollerLoggedIn) return;
|
if (!currentSlide || !window.scrollerLoggedIn) return;
|
||||||
const itemId = currentSlide.dataset.localId || currentSlide.dataset.id;
|
const itemId = currentSlide.dataset.id;
|
||||||
if (!itemId) return;
|
if (!itemId) return;
|
||||||
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
fetch(`/api/v2/tags/${itemId}/cycle-rating`, {
|
||||||
if (badge) {
|
method: 'PUT',
|
||||||
cycleRatingOptimistic(badge, itemId, true);
|
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||||
}
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) return;
|
||||||
|
// Update the badge on the slide immediately
|
||||||
|
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
||||||
|
if (badge) {
|
||||||
|
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||||
|
badge.textContent = data.rating_label;
|
||||||
|
badge.dataset.rating = data.rating_class;
|
||||||
|
}
|
||||||
|
const labels = { sfw: '🛡 SFW', nsfw: '🔥 NSFW', nsfl: '💀 NSFL' };
|
||||||
|
showShareToast(labels[data.rating_class] ?? data.rating_label);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
|
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
|
||||||
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
|
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
|
||||||
@@ -3496,7 +3441,7 @@
|
|||||||
|
|
||||||
// Tab type arrays
|
// Tab type arrays
|
||||||
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||||
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
|
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
|
||||||
let sActiveTab = 'user';
|
let sActiveTab = 'user';
|
||||||
let sCachedNotifs = [];
|
let sCachedNotifs = [];
|
||||||
|
|
||||||
@@ -3600,9 +3545,6 @@
|
|||||||
} else if (n.type === 'report') {
|
} else if (n.type === 'report') {
|
||||||
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
|
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
|
||||||
msg = i18n.notif_new_report || 'New user report';
|
msg = i18n.notif_new_report || 'New user report';
|
||||||
} else if (n.type === 'warning') {
|
|
||||||
link = `/notifications?tab=system#notif-${n.id}`; user = i18n.notif_system || 'System';
|
|
||||||
msg = (i18n.account_warning && i18n.account_warning.title) || 'Account Warning';
|
|
||||||
} else {
|
} else {
|
||||||
link = `/${n.item_id}#c${n.reference_id}`;
|
link = `/${n.item_id}#c${n.reference_id}`;
|
||||||
if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you';
|
if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you';
|
||||||
@@ -3610,13 +3552,8 @@
|
|||||||
else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you';
|
else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you';
|
||||||
else msg = i18n.notif_commented || 'commented';
|
else msg = i18n.notif_commented || 'commented';
|
||||||
}
|
}
|
||||||
let thumb;
|
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
|
||||||
if (n.type === 'warning') {
|
const thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
|
||||||
thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`;
|
|
||||||
} else {
|
|
||||||
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
|
|
||||||
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
|
|
||||||
}
|
|
||||||
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||||
${thumb}
|
${thumb}
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,10 @@
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
|
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
|
||||||
let lastRenderedIds = ''; // track rendered comment IDs to skip needless re-renders
|
|
||||||
// Shared cache for activity across AJAX loads
|
// Shared cache for activity across AJAX loads
|
||||||
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
|
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
|
||||||
|
|
||||||
const loadEmojis = async () => {
|
const loadEmojis = async () => {
|
||||||
// Prefer CommentSystem's already-fetched cache to avoid a redundant request
|
|
||||||
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache)
|
|
||||||
|| null;
|
|
||||||
if (csCache && Object.keys(csCache).length > 0) {
|
|
||||||
Object.assign(customEmojis, csCache);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No shared cache yet — fetch directly
|
|
||||||
if (Object.keys(customEmojis).length > 0) return;
|
if (Object.keys(customEmojis).length > 0) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v2/emojis');
|
const res = await fetch('/api/v2/emojis');
|
||||||
@@ -34,11 +25,7 @@
|
|||||||
|
|
||||||
const renderEmoji = (match, name) => {
|
const renderEmoji = (match, name) => {
|
||||||
if (customEmojis[name]) {
|
if (customEmojis[name]) {
|
||||||
const url = customEmojis[name];
|
return `<img class="sidebar-comment-img emoji" src="${customEmojis[name]}" alt="${name}" title=":${name}:" loading="lazy">`;
|
||||||
if (url.endsWith('.webm')) {
|
|
||||||
return `<video class="sidebar-comment-img emoji" src="${url}" title=":${name}:" autoplay loop muted playsinline></video>`;
|
|
||||||
}
|
|
||||||
return `<img class="sidebar-comment-img emoji" src="${url}" alt="${name}" title=":${name}:" loading="lazy">`;
|
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
};
|
};
|
||||||
@@ -50,19 +37,9 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSidebarEmojiVideos = (container) => {
|
|
||||||
if (!container) return;
|
|
||||||
container.querySelectorAll('video.emoji').forEach(v => {
|
|
||||||
v.play().catch(() => {
|
|
||||||
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ytOembedCache = new Map(); // videoId -> meta object
|
const ytOembedCache = new Map(); // videoId -> meta object
|
||||||
const ytOembedPending = new Map(); // videoId -> Promise
|
const ytOembedPending = new Map(); // videoId -> Promise
|
||||||
|
|
||||||
|
|
||||||
const fetchSidebarYoutubeTitles = async (container) => {
|
const fetchSidebarYoutubeTitles = async (container) => {
|
||||||
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
|
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
|
||||||
if (links.length === 0) return;
|
if (links.length === 0) return;
|
||||||
@@ -80,40 +57,8 @@
|
|||||||
meta = await ytOembedPending.get(videoId);
|
meta = await ytOembedPending.get(videoId);
|
||||||
} else {
|
} else {
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
|
|
||||||
// 1. Try client-side oEmbed first (avoids Tor/proxy consent walls)
|
|
||||||
try {
|
|
||||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(ytUrl)}&format=json`;
|
|
||||||
const clientResp = await fetch(oembedUrl);
|
|
||||||
if (clientResp.ok) {
|
|
||||||
const clientData = await clientResp.json();
|
|
||||||
if (clientData.title) {
|
|
||||||
const metaObj = {
|
|
||||||
title: clientData.title,
|
|
||||||
site_name: 'youtube.com',
|
|
||||||
author: clientData.author_name || 'Unknown'
|
|
||||||
};
|
|
||||||
// Cache client-side
|
|
||||||
ytOembedCache.set(videoId, metaObj);
|
|
||||||
// Push to server cache so other clients/server benefit
|
|
||||||
try {
|
|
||||||
const csrf = window.f0ckSession?.csrf_token;
|
|
||||||
fetch('/api/v2/meta/cache', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(csrf ? { 'X-CSRF-Token': csrf } : {})
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ url: ytUrl, meta: metaObj })
|
|
||||||
}).catch(() => {});
|
|
||||||
} catch (_) {}
|
|
||||||
return metaObj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
// 2. Fall back to backend meta fetch
|
|
||||||
try {
|
try {
|
||||||
|
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
|
||||||
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
|
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
@@ -138,12 +83,6 @@
|
|||||||
titleSpan.textContent = 'YouTube Video';
|
titleSpan.textContent = 'YouTube Video';
|
||||||
}
|
}
|
||||||
titleSpan.dataset.loaded = 'true';
|
titleSpan.dataset.loaded = 'true';
|
||||||
|
|
||||||
// Re-check overflow since the new text might cause wrapping
|
|
||||||
const inner = link.closest('.comment-content-inner');
|
|
||||||
if (inner && typeof checkOverflow === 'function') {
|
|
||||||
checkOverflow(inner);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,10 +128,10 @@
|
|||||||
const hostsRegexPart = allowedHosts.join('|');
|
const hostsRegexPart = allowedHosts.join('|');
|
||||||
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
||||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||||
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
|
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?<!\\S)(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||||
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
||||||
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
||||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
renderer.blockquote = function (quote) {
|
renderer.blockquote = function (quote) {
|
||||||
@@ -283,13 +222,9 @@
|
|||||||
let processedLine = line;
|
let processedLine = line;
|
||||||
|
|
||||||
// Handle Mentions
|
// Handle Mentions
|
||||||
const mentionStore = [];
|
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
|
||||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
|
const user = g1 || g2;
|
||||||
const user = g1 || g2 || g4;
|
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||||
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
|
||||||
const idx = mentionStore.length;
|
|
||||||
mentionStore.push(html);
|
|
||||||
return `\x02MNT${idx}\x03`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle Comment Context Links (>>ID)
|
// Handle Comment Context Links (>>ID)
|
||||||
@@ -312,33 +247,13 @@
|
|||||||
return `[video](${fullUrl})`;
|
return `[video](${fullUrl})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use marked for each line individually.
|
// Use marked for each line individually
|
||||||
// Protect URLs and already-formed Markdown link/image tokens from the
|
let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_');
|
||||||
// italic-prevention pass so that underscores in query params
|
const bs = String.fromCharCode(92);
|
||||||
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
|
mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_');
|
||||||
const mdProtected = [];
|
|
||||||
// Match [text](url) /  tokens AND bare http(s) URLs
|
|
||||||
let mdSafe = processedLine.replace(
|
|
||||||
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g,
|
|
||||||
(match) => {
|
|
||||||
const idx = mdProtected.length;
|
|
||||||
mdProtected.push(match);
|
|
||||||
return `\x02MDURL${idx}\x03`;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Escape * and _ only in the non-URL portions
|
|
||||||
mdSafe = mdSafe
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/\*/g, '\\*')
|
|
||||||
.replace(/_/g, '\\_');
|
|
||||||
// Restore protected URLs/tokens
|
|
||||||
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
|
|
||||||
|
|
||||||
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
// Restore Mentions
|
|
||||||
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
|
|
||||||
|
|
||||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||||
@@ -375,14 +290,6 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Abyss label replacement
|
|
||||||
md = md.replace(
|
|
||||||
/<a\s[^>]*href="(?:https?:\/\/[^\/]+)?\/abyss(?:#|\/)(\d+)"[^>]*>([\s\S]*?)<\/a>/gi,
|
|
||||||
(match, abyssId) => {
|
|
||||||
return `<a href="/abyss/${abyssId}" class="sidebar-abyss-link" data-abyss-id="${abyssId}"><i class="fa-solid fa-dice-d6"></i> /abyss/${abyssId}</a>`;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build regex for allowed media hosters (video/audio)
|
// Build regex for allowed media hosters (video/audio)
|
||||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const mediaHosts = [escapedSiteHost];
|
const mediaHosts = [escapedSiteHost];
|
||||||
@@ -460,49 +367,9 @@
|
|||||||
const SIDEBAR_MAX_CHARS = 200;
|
const SIDEBAR_MAX_CHARS = 200;
|
||||||
const SIDEBAR_MAX_EMOJIS = 12;
|
const SIDEBAR_MAX_EMOJIS = 12;
|
||||||
|
|
||||||
const renderCommentAttachments = (files, content = '') => {
|
|
||||||
if (!files || files.length === 0) return '';
|
|
||||||
const items = files.map(f => {
|
|
||||||
const url = `/c/${f.dest}`;
|
|
||||||
if (content.includes(url)) return ''; // Skip if already rendered in content
|
|
||||||
if (f.mime.startsWith('image/')) {
|
|
||||||
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" class="sidebar-comment-img" alt="${escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
|
||||||
} else if (f.mime.startsWith('video/')) {
|
|
||||||
return `<div class="cf-attachment cf-video"><video src="${url}" class="sidebar-comment-img" controls preload="metadata"></video></div>`;
|
|
||||||
} else if (f.mime.startsWith('audio/')) {
|
|
||||||
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}).join('');
|
|
||||||
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSidebarPoll = (poll, commentId, itemId) => {
|
|
||||||
if (!poll) return '';
|
|
||||||
const href = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : '#');
|
|
||||||
return `<a class="sidebar-poll-preview" href="${href}"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(poll.question)}</a>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderActivityItem = (c) => {
|
const renderActivityItem = (c) => {
|
||||||
const rawContent = c.content || c.body || '';
|
const rawContent = c.content || c.body || '';
|
||||||
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||||
|
|
||||||
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
|
||||||
|
|
||||||
// Robust fallback for is_long if server didn't provide it, plus inline media checks
|
|
||||||
let isLong = c.is_long;
|
|
||||||
if (isLong === undefined) {
|
|
||||||
isLong = rawContent.length > 120
|
|
||||||
|| rawContent.split('\n').length > 2
|
|
||||||
|| (c.files && c.files.length > 0);
|
|
||||||
}
|
|
||||||
// Always force isLong if there are inline media tags that expand vertically or files are attached
|
|
||||||
if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
|
|
||||||
isLong = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
|
||||||
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
|
||||||
|
|
||||||
// Build avatar URL — same priority as the rest of the app
|
// Build avatar URL — same priority as the rest of the app
|
||||||
let avatarSrc = '/a/default.png';
|
let avatarSrc = '/a/default.png';
|
||||||
@@ -516,151 +383,71 @@
|
|||||||
const timeStr = c.created_at
|
const timeStr = c.created_at
|
||||||
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
|
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
|
||||||
: (c.timeago || 'just now');
|
: (c.timeago || 'just now');
|
||||||
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}" data-iso="${escapeHtml(c.created_at)}"` : '';
|
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}"` : '';
|
||||||
const fullDate = c.created_at
|
|
||||||
? (window.f0ckFormatDateFull ? window.f0ckFormatDateFull(c.created_at) : new Date(c.created_at).toISOString())
|
|
||||||
: '';
|
|
||||||
|
|
||||||
let itemPreview = '';
|
let itemPreview = '';
|
||||||
if (c.item_id) {
|
if (c.item_id) {
|
||||||
let mediaHtml = '';
|
let mediaHtml = '';
|
||||||
const rClass = c.item_rating_class || 'untagged';
|
|
||||||
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
|
||||||
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
|
||||||
const blurSfw = localStorage.getItem('blurSfw') === 'true';
|
|
||||||
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
|
|
||||||
|
|
||||||
let isBlurred = false;
|
|
||||||
if (rClass === 'nsfw' && blurNsfw) isBlurred = true;
|
|
||||||
else if (rClass === 'nsfl' && blurNsfl) isBlurred = true;
|
|
||||||
else if (rClass === 'sfw' && blurSfw) isBlurred = true;
|
|
||||||
else if (rClass === 'untagged' && blurUntagged) isBlurred = true;
|
|
||||||
|
|
||||||
let thumbUrl = `/t/${c.item_id}.webp`;
|
let thumbUrl = `/t/${c.item_id}.webp`;
|
||||||
if (isBlurred) {
|
|
||||||
thumbUrl = `/t/${c.item_id}_blur.webp`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
|
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
|
||||||
|
|
||||||
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" loading="lazy" onerror="this.style.display='none'" />`;
|
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" loading="lazy" onerror="this.style.display='none'" />`;
|
||||||
|
|
||||||
itemPreview = `
|
itemPreview = `
|
||||||
<div class="item-preview">
|
<div class="item-preview">
|
||||||
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
<a href="/${c.item_id}">${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="/${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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="comment" id="sc${c.id}">
|
<div class="comment" id="sc${c.id}">
|
||||||
<div class="comment-body">
|
<div class="comment-body">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<div class="comment-header-left">
|
<div class="comment-header-left">
|
||||||
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
|
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
|
||||||
<img src="${avatarSrc}" class="sidebar-avatar" loading="eager" onload="this.classList.add('loaded')" onerror="this.classList.add('loaded');this.src='/a/default.png'" />
|
<img src="${avatarSrc}" class="sidebar-avatar" alt="${c.username}" loading="lazy" />
|
||||||
</a>
|
</a>
|
||||||
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
|
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="comment-time timeago" tooltip="${fullDate}" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content${isLong ? ' has-overflow' : ''}"><div class="comment-content-inner">${displayContent}${attachmentsHtml}${pollHtml}</div><button class="read-more-btn"${isLong ? ' style="display:block"' : ''}>${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||||
${itemPreview}
|
${itemPreview}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkOverflow = () => {
|
||||||
const checkOverflow = (targetElement) => {
|
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
|
||||||
const targets = targetElement
|
|
||||||
? (targetElement.classList.contains('comment-content-inner') ? [targetElement] : targetElement.querySelectorAll('.comment-content-inner'))
|
|
||||||
: document.querySelectorAll('.sidebar-activity .comment-content-inner');
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
// Read phase: batch layout reads first
|
|
||||||
targets.forEach(inner => {
|
|
||||||
const container = inner.parentElement;
|
const container = inner.parentElement;
|
||||||
const btn = container ? container.querySelector('.read-more-btn') : null;
|
const btn = container.querySelector('.read-more-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
const isExpanded = container.classList.contains('expanded');
|
// If expanded, always show "see less"
|
||||||
const scrollHeight = inner.scrollHeight;
|
if (container.classList.contains('expanded')) {
|
||||||
const clientHeight = inner.clientHeight;
|
btn.style.display = 'block';
|
||||||
const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
|
|
||||||
media => {
|
|
||||||
if (media.tagName === 'IMG') {
|
|
||||||
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
|
|
||||||
}
|
|
||||||
if (media.tagName === 'VIDEO') {
|
|
||||||
return media.readyState < 1 && media.dataset.error !== 'true'; // HAVE_METADATA
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
container,
|
|
||||||
btn,
|
|
||||||
isExpanded,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight,
|
|
||||||
hasUnloadedImages
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write phase: perform DOM updates after all reads are completed.
|
|
||||||
// Visibility of the button is controlled entirely by the CSS rule on
|
|
||||||
// .comment-content.has-overflow > .read-more-btn — no btn.style.display here.
|
|
||||||
results.forEach(({ container, btn, isExpanded, scrollHeight, clientHeight, hasUnloadedImages }) => {
|
|
||||||
// Clear any leftover inline display style (e.g. from the HTML template's
|
|
||||||
// style="display:block") so the CSS class-based rule is the single source of truth.
|
|
||||||
btn.style.display = '';
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
|
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollHeight > clientHeight + 2) { // 2px buffer for rounding
|
if (inner.scrollHeight > inner.clientHeight + 2) { // 2px buffer for rounding
|
||||||
// Content overflows — ensure clamped state (may already be set from HTML default)
|
btn.style.display = 'block';
|
||||||
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
|
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
|
||||||
container.classList.add('has-overflow');
|
container.classList.add('has-overflow');
|
||||||
} else if (hasUnloadedImages) {
|
|
||||||
// Images haven't loaded yet — their height is 0 so we can't tell if content
|
|
||||||
// will overflow. Keep has-overflow set; attachMediaLoadListeners will
|
|
||||||
// re-run checkOverflow once images finish loading.
|
|
||||||
} else {
|
} else {
|
||||||
// Content fits and all images are loaded — safe to remove clamped state
|
btn.style.display = 'none';
|
||||||
container.classList.remove('has-overflow');
|
container.classList.remove('has-overflow');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachMediaLoadListeners = (element) => {
|
const attachMediaLoadListeners = (element) => {
|
||||||
// Only target images and videos inside comment-content-inner.
|
element.querySelectorAll('img, video').forEach(media => {
|
||||||
// Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
|
|
||||||
element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
|
|
||||||
if (media.dataset.loadListenerBound) return;
|
if (media.dataset.loadListenerBound) return;
|
||||||
media.dataset.loadListenerBound = 'true';
|
media.dataset.loadListenerBound = 'true';
|
||||||
|
|
||||||
const inner = media.closest('.comment-content-inner');
|
media.addEventListener('load', checkOverflow, { once: true });
|
||||||
if (inner) {
|
media.addEventListener('loadedmetadata', checkOverflow, { once: true });
|
||||||
const handler = () => checkOverflow(inner);
|
|
||||||
const errorHandler = () => {
|
|
||||||
media.dataset.error = 'true';
|
|
||||||
checkOverflow(inner);
|
|
||||||
};
|
|
||||||
media.addEventListener('load', handler, { once: true });
|
|
||||||
media.addEventListener('error', errorHandler, { once: true });
|
|
||||||
media.addEventListener('loadedmetadata', handler, { once: true });
|
|
||||||
|
|
||||||
// Also use ResizeObserver to catch layout changes (e.g. cached image layout deferred)
|
|
||||||
if (window.ResizeObserver) {
|
|
||||||
const ro = new ResizeObserver(() => checkOverflow(inner));
|
|
||||||
ro.observe(media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -672,7 +459,7 @@
|
|||||||
const contentDiv = readBtn.closest('.comment-content');
|
const contentDiv = readBtn.closest('.comment-content');
|
||||||
if (contentDiv) {
|
if (contentDiv) {
|
||||||
contentDiv.classList.toggle('expanded');
|
contentDiv.classList.toggle('expanded');
|
||||||
checkOverflow(contentDiv); // Re-sync button text and visibility for this element
|
checkOverflow(); // Re-sync button text and visibility
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -683,28 +470,43 @@
|
|||||||
const showSkeletons = () => {
|
const showSkeletons = () => {
|
||||||
const container = document.getElementById('sidebar-activity-container');
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = `
|
const variants = [
|
||||||
<div class="sidebar-loading-state">
|
`<div class="skeleton-line skeleton-text-long"></div>
|
||||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
<div class="skeleton-line skeleton-text-medium"></div>
|
||||||
<span>${window.f0ckI18n?.sidebar_loading_activity || 'Loading activity...'}</span>
|
<div class="skeleton-line skeleton-text-short"></div>`,
|
||||||
</div>
|
`<div class="skeleton-line skeleton-text-long"></div>
|
||||||
`;
|
<div class="skeleton-line skeleton-text-short"></div>`,
|
||||||
|
`<div class="skeleton-line skeleton-text-medium"></div>
|
||||||
|
<div class="skeleton-line skeleton-text-long"></div>
|
||||||
|
<div class="skeleton-line skeleton-text-short"></div>`,
|
||||||
|
`<div class="skeleton-line skeleton-text-long"></div>
|
||||||
|
<div class="skeleton-line skeleton-text-medium"></div>`,
|
||||||
|
`<div class="skeleton-line skeleton-text-short"></div>
|
||||||
|
<div class="skeleton-line skeleton-text-long"></div>`,
|
||||||
|
];
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < SIDEBAR_SKELETON_COUNT; i++) {
|
||||||
|
html += `
|
||||||
|
<div class="sidebar-skeleton-item">
|
||||||
|
<div class="skeleton-header">
|
||||||
|
<div class="skeleton-avatar"></div>
|
||||||
|
<div class="skeleton-meta">
|
||||||
|
<div class="skeleton-line skeleton-name"></div>
|
||||||
|
<div class="skeleton-line skeleton-time"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${variants[i % 5]}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
// Auto-play converted GIF videos
|
||||||
|
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFromCache = (force = false) => {
|
const renderFromCache = () => {
|
||||||
const container = document.getElementById('sidebar-activity-container');
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
if (!container || window._sidebarActivityCache.length === 0) return false;
|
if (!container || window._sidebarActivityCache.length === 0) return false;
|
||||||
|
|
||||||
const currentIds = window._sidebarActivityCache.map(c => String(c.id)).join(',');
|
|
||||||
// If the same comments are already rendered, leave the DOM completely untouched.
|
|
||||||
// This keeps video stickers playing and avoids any overflow state churn.
|
|
||||||
// force=true bypasses this guard for cases where content changed without ID changes
|
|
||||||
// (e.g. emojis just loaded and need to replace raw :codes: in existing comments).
|
|
||||||
if (!force && currentIds === lastRenderedIds && container.querySelector('.comment')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
lastRenderedIds = currentIds;
|
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
window._sidebarActivityCache.forEach(c => {
|
window._sidebarActivityCache.forEach(c => {
|
||||||
html += renderActivityItem(c);
|
html += renderActivityItem(c);
|
||||||
@@ -716,35 +518,15 @@
|
|||||||
container.appendChild(ioSentinel);
|
container.appendChild(ioSentinel);
|
||||||
}
|
}
|
||||||
attachMediaLoadListeners(container);
|
attachMediaLoadListeners(container);
|
||||||
// has-overflow is now set per-comment by the server (via is_long) so the
|
|
||||||
// correct clamped/unclamped state is baked into the HTML on first paint.
|
|
||||||
// checkOverflow still runs deferred to handle two edge cases:
|
|
||||||
// 1. Comments with images whose heights are 0 at inject time — the server
|
|
||||||
// correctly marks them is_long:true, but images that load extra-tall
|
|
||||||
// still need a re-check after load (handled by attachMediaLoadListeners).
|
|
||||||
// 2. Any mis-estimates: content that the heuristic got wrong is corrected
|
|
||||||
// after layout so measurements are accurate.
|
|
||||||
// Run checkOverflow synchronously immediately after setting innerHTML.
|
|
||||||
// This forces a synchronous layout (reflow) but guarantees the browser
|
|
||||||
// will not paint the intermediate state, completely eliminating any "flash"
|
|
||||||
// of the button popping in or out.
|
|
||||||
checkOverflow();
|
checkOverflow();
|
||||||
|
|
||||||
// checkOverflow still runs deferred to handle image loads (attachMediaLoadListeners)
|
|
||||||
// and a fallback 1s timeout for any incredibly slow layout edge cases.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(checkOverflow, 1000);
|
|
||||||
});
|
|
||||||
fetchSidebarYoutubeTitles(container);
|
fetchSidebarYoutubeTitles(container);
|
||||||
// Auto-play converted GIF videos and webm emoji stickers
|
// Auto-play converted GIF videos
|
||||||
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
playSidebarEmojiVideos(container);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_PAGE_LIMIT = 15;
|
const SIDEBAR_PAGE_LIMIT = 15;
|
||||||
const SIDEBAR_INITIAL_LIMIT = 15;
|
const SIDEBAR_INITIAL_LIMIT = 15;
|
||||||
const SIDEBAR_MAX_COMMENTS = 20;
|
|
||||||
|
|
||||||
const loadActivity = async (silent = false) => {
|
const loadActivity = async (silent = false) => {
|
||||||
const container = document.getElementById('sidebar-activity-container');
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
@@ -770,21 +552,22 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success && data.comments && data.comments.length > 0) {
|
if (data.success && data.comments && data.comments.length > 0) {
|
||||||
window._sidebarActivityCache = data.comments.slice(0, SIDEBAR_MAX_COMMENTS).map(c => ({
|
window._sidebarActivityCache = data.comments.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
body: c.content || c.body
|
body: c.content || c.body
|
||||||
}));
|
}));
|
||||||
hasMore = (data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT)
|
hasMore = data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT;
|
||||||
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
|
renderFromCache();
|
||||||
renderFromCache(); // no-op if IDs unchanged (renderFromCache self-guards)
|
// Also check after a delay to account for image/emoji loading shifts
|
||||||
|
setTimeout(checkOverflow, 500);
|
||||||
} else if (!hasCache) {
|
} else if (!hasCache) {
|
||||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_no_activity || 'No recent activity.') + '</div>';
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No recent activity.</div>';
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Sidebar Activity: Failed to load activity", e);
|
console.error("Sidebar Activity: Failed to load activity", e);
|
||||||
if (!hasCache) {
|
if (!hasCache) {
|
||||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.') + '</div>';
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Failed to load.</div>';
|
||||||
}
|
}
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -806,7 +589,7 @@
|
|||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
sentinel.id = 'sidebar-load-more-sentinel';
|
sentinel.id = 'sidebar-load-more-sentinel';
|
||||||
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
|
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
|
||||||
sentinel.textContent = window.f0ckI18n?.sidebar_loading_more || 'Loading…';
|
sentinel.textContent = 'Loading…';
|
||||||
container.appendChild(sentinel);
|
container.appendChild(sentinel);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -822,65 +605,40 @@
|
|||||||
|
|
||||||
if (data.success && data.comments && data.comments.length > 0) {
|
if (data.success && data.comments && data.comments.length > 0) {
|
||||||
currentPage++;
|
currentPage++;
|
||||||
|
hasMore = data.hasMore === true;
|
||||||
|
|
||||||
// Append only comments not already in the cache, up to the global cap
|
// Append only comments not already in the cache
|
||||||
const remaining = SIDEBAR_MAX_COMMENTS - window._sidebarActivityCache.length;
|
|
||||||
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
|
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
|
||||||
const newComments = data.comments
|
const newComments = data.comments.filter(c => !existingIds.has(String(c.id))).map(c => ({
|
||||||
.filter(c => !existingIds.has(String(c.id)))
|
...c,
|
||||||
.slice(0, remaining)
|
body: c.content || c.body
|
||||||
.map(c => ({ ...c, body: c.content || c.body }));
|
}));
|
||||||
|
|
||||||
window._sidebarActivityCache.push(...newComments);
|
window._sidebarActivityCache.push(...newComments);
|
||||||
|
|
||||||
// Stop loading more if the hard cap is reached
|
|
||||||
hasMore = data.hasMore === true
|
|
||||||
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
|
|
||||||
|
|
||||||
// Append new items to DOM
|
// Append new items to DOM
|
||||||
let html = '';
|
let html = '';
|
||||||
newComments.forEach(c => { html += renderActivityItem(c); });
|
newComments.forEach(c => { html += renderActivityItem(c); });
|
||||||
if (html) {
|
if (html) {
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = html;
|
temp.innerHTML = html;
|
||||||
const addedNodes = [];
|
|
||||||
while (temp.firstElementChild) {
|
while (temp.firstElementChild) {
|
||||||
const node = temp.firstElementChild;
|
container.appendChild(temp.firstElementChild);
|
||||||
container.appendChild(node);
|
|
||||||
addedNodes.push(node);
|
|
||||||
}
|
}
|
||||||
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
||||||
if (ioSentinel) container.appendChild(ioSentinel);
|
if (ioSentinel) container.appendChild(ioSentinel);
|
||||||
|
attachMediaLoadListeners(container);
|
||||||
addedNodes.forEach(node => {
|
checkOverflow();
|
||||||
attachMediaLoadListeners(node);
|
fetchSidebarYoutubeTitles(container);
|
||||||
fetchSidebarYoutubeTitles(node);
|
// Auto-play converted GIF videos
|
||||||
playSidebarEmojiVideos(node);
|
|
||||||
});
|
|
||||||
// Defer overflow checks to after layout so measurements are accurate.
|
|
||||||
addedNodes.forEach(node => checkOverflow(node));
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// Deferred re-check for image comments — mirrors renderFromCache behaviour.
|
|
||||||
// Images in newly appended nodes may not be laid out yet at append time.
|
|
||||||
setTimeout(() => addedNodes.forEach(node => checkOverflow(node)), 1000);
|
|
||||||
});
|
|
||||||
// Auto-play converted GIF videos and webm emoji stickers
|
|
||||||
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasMore) {
|
|
||||||
// Show end-of-feed indicator
|
|
||||||
const end = document.createElement('div');
|
|
||||||
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
|
||||||
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
|
|
||||||
container.appendChild(end);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
// Show end-of-feed indicator
|
// Show end-of-feed indicator
|
||||||
const end = document.createElement('div');
|
const end = document.createElement('div');
|
||||||
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
||||||
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
|
end.textContent = '─ end of activity ─';
|
||||||
container.appendChild(end);
|
container.appendChild(end);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -901,7 +659,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update cache (prepend, capped at SIDEBAR_MAX_COMMENTS)
|
// 2. Update cache (prepend, no hard cap — infinite scroll handles depth)
|
||||||
const newItem = {
|
const newItem = {
|
||||||
...data,
|
...data,
|
||||||
body: data.body || data.content,
|
body: data.body || data.content,
|
||||||
@@ -909,22 +667,9 @@
|
|||||||
};
|
};
|
||||||
window._sidebarActivityCache.unshift(newItem);
|
window._sidebarActivityCache.unshift(newItem);
|
||||||
|
|
||||||
// Trim cache to the hard cap and remove the evicted DOM node (if any)
|
|
||||||
if (window._sidebarActivityCache.length > SIDEBAR_MAX_COMMENTS) {
|
|
||||||
const evicted = window._sidebarActivityCache.pop();
|
|
||||||
if (container && evicted) {
|
|
||||||
const evictedEl = document.getElementById('sc' + evicted.id);
|
|
||||||
if (evictedEl) evictedEl.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update DOM if visible
|
// Update DOM if visible
|
||||||
if (container) {
|
if (container) {
|
||||||
// is_long may be provided by the server (SSE payload) or absent for legacy events.
|
const html = renderActivityItem(newItem);
|
||||||
// Default to true so the button is always visible; checkOverflow will hide it
|
|
||||||
// if the rendered content is actually short.
|
|
||||||
const itemWithLong = { ...newItem, is_long: newItem.is_long !== false };
|
|
||||||
const html = renderActivityItem(itemWithLong);
|
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = html;
|
temp.innerHTML = html;
|
||||||
const node = temp.firstElementChild;
|
const node = temp.firstElementChild;
|
||||||
@@ -932,26 +677,15 @@
|
|||||||
node.classList.add('new-item-fade');
|
node.classList.add('new-item-fade');
|
||||||
container.prepend(node);
|
container.prepend(node);
|
||||||
attachMediaLoadListeners(node);
|
attachMediaLoadListeners(node);
|
||||||
checkOverflow(node);
|
checkOverflow();
|
||||||
fetchSidebarYoutubeTitles(container);
|
fetchSidebarYoutubeTitles(container);
|
||||||
playSidebarEmojiVideos(node);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
// Run emoji loading and activity fetching in parallel — avatars appear
|
await loadEmojis();
|
||||||
// immediately without waiting for the emoji API to respond first.
|
loadActivity();
|
||||||
// After both settle, if emojis finished after the initial render, re-render
|
|
||||||
// from cache so custom emoji images show on first page load.
|
|
||||||
const emojiPromise = loadEmojis();
|
|
||||||
const activityPromise = loadActivity();
|
|
||||||
await activityPromise;
|
|
||||||
await emojiPromise;
|
|
||||||
// If emojis were not yet available when activity first rendered, re-render now.
|
|
||||||
if (Object.keys(customEmojis).length > 0 && window._sidebarActivityCache.length > 0) {
|
|
||||||
renderFromCache(true); // force: emojis are now loaded, content changed
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for live activity from f0ckm.js
|
// Listen for live activity from f0ckm.js
|
||||||
@@ -984,7 +718,7 @@
|
|||||||
void el.offsetWidth;
|
void el.offsetWidth;
|
||||||
el.classList.add('new-item-fade');
|
el.classList.add('new-item-fade');
|
||||||
attachMediaLoadListeners(inner);
|
attachMediaLoadListeners(inner);
|
||||||
requestAnimationFrame(() => checkOverflow(inner));
|
checkOverflow();
|
||||||
fetchSidebarYoutubeTitles(el);
|
fetchSidebarYoutubeTitles(el);
|
||||||
// Auto-play converted GIF videos
|
// Auto-play converted GIF videos
|
||||||
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
@@ -998,20 +732,6 @@
|
|||||||
handleLiveEdit(e.detail);
|
handleLiveEdit(e.detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
// When emojis are refreshed (e.g. after sticker pack import), sync the sidebar
|
|
||||||
// cache and re-render so newly imported stickers appear in existing entries.
|
|
||||||
window.addEventListener('f0ck:emojis_ready', () => {
|
|
||||||
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null;
|
|
||||||
if (!csCache || Object.keys(csCache).length === 0) return;
|
|
||||||
// Merge new emojis into local cache (additive — never removes)
|
|
||||||
const hadBefore = Object.keys(customEmojis).length;
|
|
||||||
Object.assign(customEmojis, csCache);
|
|
||||||
const hasNew = Object.keys(customEmojis).length > hadBefore;
|
|
||||||
if (hasNew && window._sidebarActivityCache.length > 0) {
|
|
||||||
renderFromCache(true); // force: new emoji images available, re-render
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||||
|
|
||||||
// Handle AJAX item loads
|
// Handle AJAX item loads
|
||||||
@@ -1024,7 +744,6 @@
|
|||||||
|
|
||||||
if (modeChanged) {
|
if (modeChanged) {
|
||||||
window._sidebarActivityCache = [];
|
window._sidebarActivityCache = [];
|
||||||
lastRenderedIds = '';
|
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
loadActivity(false); // Force reload with loading state
|
loadActivity(false); // Force reload with loading state
|
||||||
@@ -1046,7 +765,6 @@
|
|||||||
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||||
lastBoundMode = e.detail.mode;
|
lastBoundMode = e.detail.mode;
|
||||||
window._sidebarActivityCache = [];
|
window._sidebarActivityCache = [];
|
||||||
lastRenderedIds = '';
|
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
loadActivity(false);
|
loadActivity(false);
|
||||||
|
|||||||
@@ -71,13 +71,11 @@ window.TagAutocomplete = (() => {
|
|||||||
|
|
||||||
// Flag to prevent focusout from destroying dropdown while touching it
|
// Flag to prevent focusout from destroying dropdown while touching it
|
||||||
let dropdownTouching = false;
|
let dropdownTouching = false;
|
||||||
// Flag set when we intentionally blur to dismiss the keyboard on mobile
|
|
||||||
let keyboardDismissed = false;
|
|
||||||
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
||||||
dropdown.addEventListener('touchend', () => {
|
dropdown.addEventListener('touchend', () => {
|
||||||
dropdownTouching = false;
|
dropdownTouching = false;
|
||||||
// Note: do NOT re-focus input here — that would reopen the mobile keyboard.
|
// Re-focus input so user can keep typing after scrolling
|
||||||
// The keyboard only comes back when the user explicitly taps the input.
|
input.focus();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
||||||
|
|
||||||
@@ -269,51 +267,18 @@ window.TagAutocomplete = (() => {
|
|||||||
open(opts);
|
open(opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close when clicking/tapping outside.
|
// Close when clicking/tapping outside
|
||||||
// Desktop (mousedown): close immediately.
|
const onDocClick = (e) => {
|
||||||
// Mobile (touchstart): first tap outside dismisses the keyboard only (blur),
|
|
||||||
// leaving suggestions visible so the user can scroll up to see them.
|
|
||||||
// A second tap outside within 500 ms actually closes the dropdown.
|
|
||||||
let outsideTapCount = 0;
|
|
||||||
let outsideTapTimer = null;
|
|
||||||
|
|
||||||
const onDocMousedown = (e) => {
|
|
||||||
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
||||||
document.removeEventListener('mousedown', onDocMousedown);
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('touchstart', onDocClick);
|
||||||
destroy();
|
destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Delay attaching to avoid capturing the opening click
|
||||||
const onDocTouchstart = (e) => {
|
|
||||||
if (wrapper.contains(e.target) || e.target === anchorEl) return;
|
|
||||||
|
|
||||||
outsideTapCount++;
|
|
||||||
|
|
||||||
if (outsideTapCount === 1) {
|
|
||||||
// First outside tap — just blur to dismiss the keyboard.
|
|
||||||
// Suggestions remain visible so the user can scroll up to see them.
|
|
||||||
keyboardDismissed = true;
|
|
||||||
input.blur();
|
|
||||||
// Reset the flag after the blur event has fired.
|
|
||||||
setTimeout(() => { keyboardDismissed = false; }, 100);
|
|
||||||
|
|
||||||
// Reset the counter after the double-tap window expires.
|
|
||||||
outsideTapTimer = setTimeout(() => {
|
|
||||||
outsideTapCount = 0;
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
// Second outside tap — close the dropdown.
|
|
||||||
clearTimeout(outsideTapTimer);
|
|
||||||
outsideTapCount = 0;
|
|
||||||
document.removeEventListener('touchstart', onDocTouchstart);
|
|
||||||
destroy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delay attaching to avoid capturing the opening touch.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('mousedown', onDocMousedown);
|
document.addEventListener('mousedown', onDocClick);
|
||||||
document.addEventListener('touchstart', onDocTouchstart, { passive: true });
|
document.addEventListener('touchstart', onDocClick, { passive: true });
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Click on the wrapper area should refocus the input
|
// Click on the wrapper area should refocus the input
|
||||||
@@ -328,7 +293,6 @@ window.TagAutocomplete = (() => {
|
|||||||
// Delay to allow suggestion tap/scroll to complete first
|
// Delay to allow suggestion tap/scroll to complete first
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (dropdownTouching) return; // user is interacting with dropdown
|
if (dropdownTouching) return; // user is interacting with dropdown
|
||||||
if (keyboardDismissed) return; // intentional blur to hide mobile keyboard
|
|
||||||
// Don't close if focus is still within the wrapper
|
// Don't close if focus is still within the wrapper
|
||||||
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
||||||
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@
|
|||||||
a.textContent = tag.tag;
|
a.textContent = tag.tag;
|
||||||
|
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.classList.add("badge");
|
span.classList.add("badge", "mr-2");
|
||||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
span.classList.add('new-tag-glow');
|
span.classList.add('new-tag-glow');
|
||||||
}
|
}
|
||||||
@@ -120,13 +120,7 @@
|
|||||||
postid,
|
postid,
|
||||||
existingTags: tags,
|
existingTags: tags,
|
||||||
anchorEl: anchor,
|
anchorEl: anchor,
|
||||||
onSubmit: async (tag) => {
|
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||||
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
|
|
||||||
if (res.success && window.invalidateItemCache) {
|
|
||||||
window.invalidateItemCache(postid);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
renderTags
|
renderTags
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -195,6 +189,7 @@
|
|||||||
|
|
||||||
a.appendChild(img);
|
a.appendChild(img);
|
||||||
favcontainer.appendChild(a);
|
favcontainer.appendChild(a);
|
||||||
|
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||||
});
|
});
|
||||||
favcontainer.hidden = false;
|
favcontainer.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
if (!window.UserCommentSystem) {
|
class UserCommentSystem {
|
||||||
window.UserCommentSystem = class UserCommentSystem {
|
constructor() {
|
||||||
constructor() {
|
this.container = document.getElementById('user-comments-container');
|
||||||
this.container = document.getElementById('user-comments-container');
|
this.username = this.container ? this.container.dataset.user : null;
|
||||||
this.username = this.container ? this.container.dataset.user : null;
|
this.page = 1;
|
||||||
this.page = 1;
|
this.loading = false;
|
||||||
this.loading = false;
|
this.finished = false;
|
||||||
this.finished = false;
|
this.userColor = null;
|
||||||
this.userColor = null;
|
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.icons = {
|
this.icons = {
|
||||||
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
||||||
@@ -26,16 +23,12 @@ if (!window.UserCommentSystem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleLiveEdit(data) {
|
handleLiveEdit(data) {
|
||||||
if (!this.container || !document.body.contains(this.container)) {
|
if (!this.container) return;
|
||||||
window.removeEventListener('f0ck:comment_edited', this.editListener);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = document.getElementById('c' + data.comment_id);
|
const el = document.getElementById('c' + data.comment_id);
|
||||||
if (el && this.container.contains(el)) {
|
if (el && this.container.contains(el)) {
|
||||||
const contentEl = el.querySelector('.comment-content');
|
const contentEl = el.querySelector('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
|
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
|
||||||
this.playEmojiVideos(contentEl);
|
|
||||||
el.classList.remove('new-item-fade');
|
el.classList.remove('new-item-fade');
|
||||||
void el.offsetWidth;
|
void el.offsetWidth;
|
||||||
el.classList.add('new-item-fade');
|
el.classList.add('new-item-fade');
|
||||||
@@ -44,7 +37,7 @@ if (!window.UserCommentSystem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadEmojis();
|
this.loadEmojis();
|
||||||
this.loadMore();
|
this.loadMore();
|
||||||
this.loadMore();
|
this.loadMore();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
@@ -119,7 +112,6 @@ if (!window.UserCommentSystem) {
|
|||||||
const html = this.renderComment(c);
|
const html = this.renderComment(c);
|
||||||
this.container.insertAdjacentHTML('beforeend', html);
|
this.container.insertAdjacentHTML('beforeend', html);
|
||||||
});
|
});
|
||||||
this.playEmojiVideos(this.container);
|
|
||||||
this.page++;
|
this.page++;
|
||||||
} else {
|
} else {
|
||||||
this.finished = true;
|
this.finished = true;
|
||||||
@@ -135,89 +127,13 @@ if (!window.UserCommentSystem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playEmojiVideos(container) {
|
|
||||||
if (!container) return;
|
|
||||||
container.querySelectorAll('video.emoji').forEach(v => {
|
|
||||||
v.play().catch(() => {
|
|
||||||
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEmoji(match, name) {
|
renderEmoji(match, name) {
|
||||||
if (this.customEmojis && this.customEmojis[name]) {
|
if (this.customEmojis && this.customEmojis[name]) {
|
||||||
const url = this.customEmojis[name];
|
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||||
if (url.endsWith('.webm')) {
|
|
||||||
return `<video src="${url}" class="emoji" autoplay loop muted playsinline title=":${name}:"></video>`;
|
|
||||||
}
|
|
||||||
return `<img src="${url}" class="emoji" alt="${match}" title="${match}">`;
|
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCommentAttachments(files, content = '') {
|
|
||||||
if (!files || files.length === 0) return '';
|
|
||||||
const items = files.map(f => {
|
|
||||||
const url = `/c/${f.dest}`;
|
|
||||||
if (content && content.includes(url)) return ''; // Skip if already rendered in content
|
|
||||||
if (f.mime && f.mime.startsWith('image/')) {
|
|
||||||
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
|
||||||
} else if (f.mime && f.mime.startsWith('video/')) {
|
|
||||||
return `<div class="cf-attachment cf-video"><video src="${url}" controls preload="metadata"></video></div>`;
|
|
||||||
} else if (f.mime && f.mime.startsWith('audio/')) {
|
|
||||||
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}).join('');
|
|
||||||
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCommentPoll(poll, commentId) {
|
|
||||||
if (!poll) return '';
|
|
||||||
const i18n = window.f0ckI18n || {};
|
|
||||||
const session = window.f0ckSession || {};
|
|
||||||
const total = poll.total_votes || 0;
|
|
||||||
const voted = !!poll.user_vote_option_id;
|
|
||||||
const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
|
|
||||||
const isAnon = poll.is_anonymous !== false;
|
|
||||||
|
|
||||||
const optionsHtml = (poll.options || []).map(opt => {
|
|
||||||
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
|
|
||||||
const isVoted = poll.user_vote_option_id === opt.id;
|
|
||||||
const clickable = session.logged_in && !expired && !voted;
|
|
||||||
const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
|
|
||||||
? `<div class="poll-option-voters">${opt.voters.map(v => {
|
|
||||||
const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null };
|
|
||||||
const name = String(u.username || '');
|
|
||||||
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
|
|
||||||
return name ? `<a href="/user/${this.escapeHtml(name)}" title="${this.escapeHtml(name)}"><img class="poll-voter-avatar" src="${src}" alt="${this.escapeHtml(name)}" loading="lazy"></a>` : '';
|
|
||||||
}).join('')}</div>`
|
|
||||||
: '';
|
|
||||||
return `<div class="poll-option ${isVoted ? 'poll-option-voted' : ''} ${clickable ? 'poll-option-clickable' : ''}"
|
|
||||||
data-option-id="${opt.id}" data-poll-id="${poll.id}" data-comment-id="${commentId}">
|
|
||||||
<div class="poll-option-bar" style="width:${pct}%"></div>
|
|
||||||
<span class="poll-option-text">${this.escapeHtml(opt.text)}</span>
|
|
||||||
<span class="poll-option-pct">${pct}%</span>
|
|
||||||
${isVoted ? `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>` : ''}
|
|
||||||
${voterAvatars}
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const anonBadge = isAnon
|
|
||||||
? `<span class="poll-anon-badge" title="${i18n.poll_anonymous || 'Anonymous'}"><i class="fa-solid fa-user-secret"></i></span>`
|
|
||||||
: `<span class="poll-anon-badge poll-public-badge" title="${i18n.poll_public || 'Public votes'}"><i class="fa-solid fa-eye"></i></span>`;
|
|
||||||
|
|
||||||
return `<div class="comment-poll" data-poll-id="${poll.id}" data-is-anonymous="${isAnon ? '1' : '0'}">
|
|
||||||
<div class="poll-question">${this.escapeHtml(poll.question)}</div>
|
|
||||||
<div class="poll-options">${optionsHtml}</div>
|
|
||||||
<div class="poll-footer">
|
|
||||||
<span class="poll-total">${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}</span>
|
|
||||||
${anonBadge}
|
|
||||||
${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCommentContent(content, itemId = null) {
|
renderCommentContent(content, itemId = null) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
@@ -236,7 +152,7 @@ if (!window.UserCommentSystem) {
|
|||||||
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
||||||
|
|
||||||
// 2. Mentions
|
// 2. Mentions
|
||||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
|
||||||
|
|
||||||
const siteOrigin = window.location.origin;
|
const siteOrigin = window.location.origin;
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
@@ -275,32 +191,6 @@ if (!window.UserCommentSystem) {
|
|||||||
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
renderer.image = (href, title, text) => {
|
|
||||||
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
|
||||||
const imgHtml = `<img src="${src}" alt="${text || ''}"${title ? ` title="${title}"` : ''} onerror="this.onerror=null; this.outerHTML='<span class=\\'broken-image-text\\'>[image not found]</span>';">`;
|
|
||||||
if (window.f0ckSession?.is_admin && src && src.startsWith('/c/')) {
|
|
||||||
const filename = src.substring(3); // Remove '/c/'
|
|
||||||
return `<span class="image-embed-wrap">${imgHtml}<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[x]</button></span>`;
|
|
||||||
}
|
|
||||||
return imgHtml;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pre-compile regexes for image/video/audio embeds matching comments.js
|
|
||||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const allowedHosts = [escapedSiteHost];
|
|
||||||
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
|
||||||
window.f0ckAllowedImages.forEach(h => {
|
|
||||||
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const hostsRegexPart = allowedHosts.join('|');
|
|
||||||
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
|
|
||||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
|
||||||
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?(?:#gif)?))(?![\\)\\]])`, 'gi');
|
|
||||||
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
|
||||||
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
|
|
||||||
|
|
||||||
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||||
const renderedLines = escaped.split('\n').map(line => {
|
const renderedLines = escaped.split('\n').map(line => {
|
||||||
const trimmed = line.trimStart();
|
const trimmed = line.trimStart();
|
||||||
@@ -313,17 +203,7 @@ if (!window.UserCommentSystem) {
|
|||||||
if (line.length > 10000) return line;
|
if (line.length > 10000) return line;
|
||||||
if (!line.trim()) return ' ';
|
if (!line.trim()) return ' ';
|
||||||
|
|
||||||
let processedLine = line;
|
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
||||||
|
|
||||||
// Handle Mentions
|
|
||||||
const mentionStore = [];
|
|
||||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
|
|
||||||
const user = g1 || g2 || g4;
|
|
||||||
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
|
||||||
const idx = mentionStore.length;
|
|
||||||
mentionStore.push(html);
|
|
||||||
return `\x02MNT${idx}\x03`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Comment Context Links (>>ID)
|
// Handle Comment Context Links (>>ID)
|
||||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||||
@@ -331,34 +211,9 @@ if (!window.UserCommentSystem) {
|
|||||||
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle Image Embeds
|
|
||||||
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
|
||||||
let fullUrl = url;
|
|
||||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
|
||||||
fullUrl = '//' + url;
|
|
||||||
}
|
|
||||||
return ``;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Raw Video/Audio links so Marked converts them to <a>
|
|
||||||
processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
|
|
||||||
let fullUrl = url;
|
|
||||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
|
||||||
return `[video](${fullUrl})`;
|
|
||||||
});
|
|
||||||
|
|
||||||
processedLine = processedLine.replace(rawAudioRegex, (match, url) => {
|
|
||||||
let fullUrl = url;
|
|
||||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
|
||||||
return `[audio](${fullUrl})`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
// Restore Mentions
|
|
||||||
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
|
|
||||||
|
|
||||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||||
@@ -409,7 +264,35 @@ if (!window.UserCommentSystem) {
|
|||||||
const fullDate = new Date(c.created_at).toISOString();
|
const fullDate = new Date(c.created_at).toISOString();
|
||||||
const content = this.renderCommentContent(c.content, c.item_id);
|
const content = this.renderCommentContent(c.content, c.item_id);
|
||||||
|
|
||||||
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>`;
|
// Replicating the structure of comments.js but adapting for the list view
|
||||||
|
// We add a header indicating which item this comment belongs to
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comment" id="c${c.id}">
|
||||||
|
<div class="comment-avatar">
|
||||||
|
<a href="/${c.item_id}">
|
||||||
|
<img src="/t/${c.item_id}.webp" alt="">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="comment-header-left">
|
||||||
|
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
|
||||||
|
</div>
|
||||||
|
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">${content}</div>
|
||||||
|
<div class="comment-footer">
|
||||||
|
<div class="comment-footer-right">
|
||||||
|
<div class="comment-actions">
|
||||||
|
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
startLiveTimestamps() {
|
startLiveTimestamps() {
|
||||||
@@ -453,21 +336,15 @@ if (!window.UserCommentSystem) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Initializer for AJAX and standard load
|
// Initializer for AJAX and standard load
|
||||||
window.initUserComments = () => {
|
window.initUserComments = () => {
|
||||||
const container = document.getElementById('user-comments-container');
|
// Prevent multiple instances if already running on this container
|
||||||
if (container && !container.dataset.initialized) {
|
if (document.getElementById('user-comments-container')) {
|
||||||
container.dataset.initialized = 'true';
|
|
||||||
new UserCommentSystem();
|
new UserCommentSystem();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
|
||||||
window.initUserComments();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
window.initUserComments();
|
window.initUserComments();
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -5,33 +5,33 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
|||||||
<div class="v0ck_progress_filled"></div>
|
<div class="v0ck_progress_filled"></div>
|
||||||
<div class="v0ck_seek_marker"></div>
|
<div class="v0ck_seek_marker"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play" tabindex="-1">
|
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play">
|
||||||
<svg style="width: 20px; height: 20px;">
|
<svg style="width: 20px; height: 20px;">
|
||||||
<use id="v0ck_svg_play" href="${svg}#play"></use>
|
<use id="v0ck_svg_play" href="${svg}#play"></use>
|
||||||
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
|
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="v0ck_volume_group">
|
<div class="v0ck_volume_group">
|
||||||
<button class="v0ck_player_button v0ck_volume" tabindex="-1">
|
<button class="v0ck_player_button v0ck_volume">
|
||||||
<svg style="width: 20px; height: 20px;">
|
<svg style="width: 20px; height: 20px;">
|
||||||
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
|
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
|
||||||
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
|
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
|
||||||
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
|
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" tabindex="-1" />
|
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" />
|
||||||
</div>
|
</div>
|
||||||
<button class="v0ck_player_button v0ck_playtime" tabindex="-1">00:00 / 00:00</button>
|
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button>
|
||||||
<span style="flex: 30"></span>
|
<span style="flex: 30"></span>
|
||||||
|
|
||||||
<div class="v0ck_settings_container">
|
<div class="v0ck_settings_container">
|
||||||
<button class="v0ck_player_button v0ck_settings_btn" title="Settings" tabindex="-1">
|
<button class="v0ck_player_button v0ck_settings_btn" title="Settings">
|
||||||
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
|
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
|
||||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="v0ck_settings_menu v0ck_hidden">
|
<div class="v0ck_settings_menu v0ck_hidden">
|
||||||
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
|
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button>
|
||||||
<div class="v0ck_menu_item v0ck_bg_row">
|
<div class="v0ck_menu_item v0ck_bg_row">
|
||||||
<span class="v0ck_switch_label">Background</span>
|
<span class="v0ck_switch_label">Background</span>
|
||||||
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
|
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
|
||||||
@@ -44,32 +44,22 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
|||||||
<span class="v0ck_switch_label">Danmaku</span>
|
<span class="v0ck_switch_label">Danmaku</span>
|
||||||
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
|
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
|
||||||
</div>
|
</div>
|
||||||
<button id="v0ck_download" class="v0ck_menu_item" title="Download File" tabindex="-1">Download${size ? ` (${size})` : ''}</button>
|
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen" tabindex="-1">
|
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen">
|
||||||
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
|
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
||||||
<div class="v0ck_speed_indicator v0ck_hidden">
|
|
||||||
<svg viewBox="0 0 24 24" style="width: 16px; height: 16px; fill: currentColor; display: inline-block; vertical-align: middle; margin-right: 6px;">
|
|
||||||
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/>
|
|
||||||
</svg>
|
|
||||||
<span>2X Speed</span>
|
|
||||||
</div>
|
|
||||||
<div class="v0ck_overlay">
|
<div class="v0ck_overlay">
|
||||||
<svg style="width: 60px; height: 60px;">
|
<svg style="width: 60px; height: 60px;">
|
||||||
<use href="${svg}#play"></use>
|
<use href="${svg}#play"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="v0ck_hud v0ck_hidden">
|
<div class="v0ck_hud v0ck_hidden">
|
||||||
<svg>
|
<svg><use class="v0ck_hud_icon" href="${svg}#volume_full"></use></svg>
|
||||||
<use class="v0ck_hud_icon v0ck_hud_volume_full" href="${svg}#volume_full"></use>
|
|
||||||
<use class="v0ck_hud_icon v0ck_hud_volume_mid v0ck_hidden" href="${svg}#volume_mid"></use>
|
|
||||||
<use class="v0ck_hud_icon v0ck_hud_volume_mute v0ck_hidden" href="${svg}#volume_mute"></use>
|
|
||||||
</svg>
|
|
||||||
<div class="v0ck_hud_bar_container">
|
<div class="v0ck_hud_bar_container">
|
||||||
<div class="v0ck_hud_bar"></div>
|
<div class="v0ck_hud_bar"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +111,10 @@ class v0ck {
|
|||||||
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
|
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
parent.addEventListener('mouseenter', () => parent.classList.add("v0ck_hover"));
|
||||||
|
parent.addEventListener('mouseleave', () => parent.classList.remove("v0ck_hover"));
|
||||||
|
}
|
||||||
|
|
||||||
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
||||||
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
||||||
@@ -164,35 +157,18 @@ class v0ck {
|
|||||||
const playtime = player.querySelector('.v0ck_playtime');
|
const playtime = player.querySelector('.v0ck_playtime');
|
||||||
const overlay = player.querySelector('.v0ck_overlay');
|
const overlay = player.querySelector('.v0ck_overlay');
|
||||||
const volumeButton = player.querySelector('.v0ck_volume');
|
const volumeButton = player.querySelector('.v0ck_volume');
|
||||||
const volumeSymbols = volumeButton.querySelectorAll('use');
|
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
|
||||||
|
|
||||||
const defaultVolume = 0.5;
|
const defaultVolume = 0.5;
|
||||||
let mousedown = false;
|
let mousedown = false;
|
||||||
let _volume;
|
let _volume;
|
||||||
|
|
||||||
// Hold to speedup (2x) states
|
|
||||||
let speedUpTimeout;
|
|
||||||
let isSpeedingUp = false;
|
|
||||||
let restorePlaybackRate = 1;
|
|
||||||
let ignoreNextClick = false;
|
|
||||||
let wasPausedWhenStarted = false;
|
|
||||||
// Mobile tap-to-show-controls: true when this touch revealed the controls bar
|
|
||||||
let controlsJustShown = false;
|
|
||||||
const speedIndicator = player.querySelector('.v0ck_speed_indicator');
|
|
||||||
|
|
||||||
// (mouse position is now tracked via docMouseX/docMouseY in resetControlsTimer block)
|
|
||||||
|
|
||||||
function handleVolumeButton(vol) {
|
function handleVolumeButton(vol) {
|
||||||
[...volumeSymbols].forEach(s => s.classList.add('v0ck_hidden'));
|
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
|
||||||
let targetId = 'v0ck_svg_volume_full';
|
switch (true) {
|
||||||
if (vol === 0) {
|
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
|
||||||
targetId = 'v0ck_svg_volume_mute';
|
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
|
||||||
} else if (vol <= 0.5) {
|
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
|
||||||
targetId = 'v0ck_svg_volume_mid';
|
|
||||||
}
|
|
||||||
const activeSymbol = [...volumeSymbols].find(s => s.id === targetId);
|
|
||||||
if (activeSymbol) {
|
|
||||||
activeSymbol.classList.remove('v0ck_hidden');
|
|
||||||
}
|
}
|
||||||
localStorage.setItem("volume", vol);
|
localStorage.setItem("volume", vol);
|
||||||
}
|
}
|
||||||
@@ -286,31 +262,14 @@ class v0ck {
|
|||||||
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: on touchstart, record whether controls were hidden so the
|
|
||||||
// subsequent click can decide whether to show controls or toggle play.
|
|
||||||
player.addEventListener('touchstart', () => {
|
|
||||||
if (isMobile) {
|
|
||||||
controlsJustShown = !player.classList.contains('v0ck_hover');
|
|
||||||
}
|
|
||||||
}, { passive: true, capture: true });
|
|
||||||
|
|
||||||
player.addEventListener('click', e => {
|
player.addEventListener('click', e => {
|
||||||
if (ignoreNextClick) {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
ignoreNextClick = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const path = e.path || (e.composedPath && e.composedPath());
|
const path = e.path || (e.composedPath && e.composedPath());
|
||||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||||
if (!isControls) {
|
if (!isControls) {
|
||||||
if (isMobile && controlsJustShown) {
|
if (isMobile && !player.classList.contains('v0ck_hover')) {
|
||||||
// First tap: controls were just revealed by this touch — don't toggle play
|
|
||||||
controlsJustShown = false;
|
|
||||||
player.classList.add('v0ck_hover');
|
player.classList.add('v0ck_hover');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controlsJustShown = false;
|
|
||||||
togglePlay(e);
|
togglePlay(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -342,18 +301,9 @@ class v0ck {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loader events with debounce to prevent flicker on fast starts/seeking
|
// Loader events
|
||||||
let loaderTimeout = null;
|
const showLoader = () => loader.classList.remove('v0ck_hidden');
|
||||||
const showLoader = () => {
|
const hideLoader = () => loader.classList.add('v0ck_hidden');
|
||||||
if (loaderTimeout) clearTimeout(loaderTimeout);
|
|
||||||
loaderTimeout = setTimeout(() => {
|
|
||||||
loader.classList.remove('v0ck_hidden');
|
|
||||||
}, 250);
|
|
||||||
};
|
|
||||||
const hideLoader = () => {
|
|
||||||
if (loaderTimeout) clearTimeout(loaderTimeout);
|
|
||||||
loader.classList.add('v0ck_hidden');
|
|
||||||
};
|
|
||||||
video.addEventListener('waiting', showLoader);
|
video.addEventListener('waiting', showLoader);
|
||||||
video.addEventListener('stalled', showLoader);
|
video.addEventListener('stalled', showLoader);
|
||||||
video.addEventListener('canplay', hideLoader);
|
video.addEventListener('canplay', hideLoader);
|
||||||
@@ -375,21 +325,11 @@ class v0ck {
|
|||||||
hud.classList.remove('v0ck_hidden');
|
hud.classList.remove('v0ck_hidden');
|
||||||
hudBar.style.width = `${vol * 100}%`;
|
hudBar.style.width = `${vol * 100}%`;
|
||||||
|
|
||||||
// Update HUD icon based on volume by toggling hidden class
|
// Update HUD icon based on volume
|
||||||
const hudSymbols = hud.querySelectorAll('.v0ck_hud_icon');
|
let icon = 'volume_full';
|
||||||
hudSymbols.forEach(s => s.classList.add('v0ck_hidden'));
|
if (vol === 0) icon = 'volume_mute';
|
||||||
|
else if (vol <= 0.5) icon = 'volume_mid';
|
||||||
let targetClass = 'v0ck_hud_volume_full';
|
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
|
||||||
if (vol === 0) {
|
|
||||||
targetClass = 'v0ck_hud_volume_mute';
|
|
||||||
} else if (vol <= 0.5) {
|
|
||||||
targetClass = 'v0ck_hud_volume_mid';
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSymbol = [...hudSymbols].find(s => s.classList.contains(targetClass));
|
|
||||||
if (activeSymbol) {
|
|
||||||
activeSymbol.classList.remove('v0ck_hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(hudTimer);
|
clearTimeout(hudTimer);
|
||||||
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
||||||
@@ -408,7 +348,7 @@ class v0ck {
|
|||||||
startY = touch.clientY;
|
startY = touch.clientY;
|
||||||
startVol = video.volume;
|
startVol = video.volume;
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: true });
|
||||||
|
|
||||||
player.addEventListener('touchmove', e => {
|
player.addEventListener('touchmove', e => {
|
||||||
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
||||||
@@ -421,8 +361,6 @@ class v0ck {
|
|||||||
if (gestureType === 'none') {
|
if (gestureType === 'none') {
|
||||||
if (dy > dx && dy > 5) {
|
if (dy > dx && dy > 5) {
|
||||||
gestureType = 'volume';
|
gestureType = 'volume';
|
||||||
clearTimeout(speedUpTimeout);
|
|
||||||
endSpeedUp();
|
|
||||||
} else if (dx > dy && dx > 5) {
|
} else if (dx > dy && dx > 5) {
|
||||||
gestureType = 'other'; // Probably seeking or horizontal swipe
|
gestureType = 'other'; // Probably seeking or horizontal swipe
|
||||||
return;
|
return;
|
||||||
@@ -432,9 +370,6 @@ class v0ck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gestureType === 'volume') {
|
if (gestureType === 'volume') {
|
||||||
clearTimeout(speedUpTimeout);
|
|
||||||
endSpeedUp();
|
|
||||||
|
|
||||||
const deltaY = startY - touch.clientY; // swipe up is positive
|
const deltaY = startY - touch.clientY; // swipe up is positive
|
||||||
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
||||||
let newVol = startVol + (deltaY / sensitivity);
|
let newVol = startVol + (deltaY / sensitivity);
|
||||||
@@ -449,86 +384,9 @@ class v0ck {
|
|||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
// Desktop mouse volume gesture support (clicking and dragging vertically on the player)
|
|
||||||
let activeMouseGesture = false;
|
|
||||||
|
|
||||||
player.addEventListener('mousedown', e => {
|
|
||||||
if (isMobile) return;
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
const path = e.path || (e.composedPath && e.composedPath());
|
|
||||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
|
||||||
if (isControls) return;
|
|
||||||
|
|
||||||
gestureType = 'none';
|
|
||||||
startX = e.clientX;
|
|
||||||
startY = e.clientY;
|
|
||||||
startVol = video.volume;
|
|
||||||
activeMouseGesture = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', e => {
|
|
||||||
if (!activeMouseGesture || gestureType === 'other') return;
|
|
||||||
|
|
||||||
const dx = Math.abs(e.clientX - startX);
|
|
||||||
const dy = Math.abs(e.clientY - startY);
|
|
||||||
|
|
||||||
if (gestureType === 'none') {
|
|
||||||
if (dy > dx && dy > 5) {
|
|
||||||
gestureType = 'volume';
|
|
||||||
clearTimeout(speedUpTimeout);
|
|
||||||
endSpeedUp();
|
|
||||||
} else if (dx > dy && dx > 5) {
|
|
||||||
gestureType = 'other';
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gestureType === 'volume') {
|
|
||||||
clearTimeout(speedUpTimeout);
|
|
||||||
endSpeedUp();
|
|
||||||
ignoreNextClick = true;
|
|
||||||
|
|
||||||
const deltaY = startY - e.clientY; // swipe up is positive
|
|
||||||
const sensitivity = 200;
|
|
||||||
let newVol = startVol + (deltaY / sensitivity);
|
|
||||||
newVol = Math.max(0, Math.min(1, newVol));
|
|
||||||
|
|
||||||
video.volume = newVol;
|
|
||||||
volumeSlider.value = newVol;
|
|
||||||
_volume = newVol;
|
|
||||||
handleVolumeButton(newVol);
|
|
||||||
showHUD(newVol);
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
|
||||||
if (activeMouseGesture) {
|
|
||||||
activeMouseGesture = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
ignoreNextClick = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
skipButtons.forEach(button => button.addEventListener('click', skip));
|
skipButtons.forEach(button => button.addEventListener('click', skip));
|
||||||
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
||||||
ranges.forEach(range => range.addEventListener('input', handleRangeUpdate));
|
|
||||||
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
||||||
|
|
||||||
// Prevent touch events on the volume slider from bubbling to the player container (avoiding gesture conflicts and page scrolls)
|
|
||||||
if (volumeSlider) {
|
|
||||||
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(evt => {
|
|
||||||
volumeSlider.addEventListener(evt, e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}, { passive: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.addEventListener('mousedown', scrub);
|
progress.addEventListener('mousedown', scrub);
|
||||||
progress.addEventListener('touchstart', scrub, { passive: false });
|
progress.addEventListener('touchstart', scrub, { passive: false });
|
||||||
progress.addEventListener('touchmove', scrub, { passive: false });
|
progress.addEventListener('touchmove', scrub, { passive: false });
|
||||||
@@ -536,197 +394,11 @@ class v0ck {
|
|||||||
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
||||||
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
||||||
|
|
||||||
let keyHoldTimer = null;
|
|
||||||
let isHoldingKey = false;
|
|
||||||
let heldKey = null;
|
|
||||||
let reverseInterval = null;
|
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
|
||||||
if (!document.body.contains(video)) {
|
|
||||||
cleanupKeyboardListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block interaction if content warning is visible
|
|
||||||
const cwModal = document.getElementById('content-warning-modal');
|
|
||||||
if (cwModal && cwModal.style.display !== 'none') return;
|
|
||||||
|
|
||||||
// Don't fire if user is typing in an input, textarea or editing content
|
|
||||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable) return;
|
|
||||||
|
|
||||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
let newVol = video.volume;
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
newVol = Math.min(1, newVol + 0.05);
|
|
||||||
} else {
|
|
||||||
newVol = Math.max(0, newVol - 0.05);
|
|
||||||
}
|
|
||||||
video.volume = newVol;
|
|
||||||
if (volumeSlider) {
|
|
||||||
volumeSlider.value = newVol;
|
|
||||||
}
|
|
||||||
_volume = newVol;
|
|
||||||
handleVolumeButton(newVol);
|
|
||||||
showHUD(newVol);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "." || e.key === ",") {
|
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (!Number.isFinite(video.currentTime)) return;
|
|
||||||
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
|
|
||||||
const direction = e.key === "." ? 1 : -1;
|
|
||||||
|
|
||||||
if (heldKey && heldKey !== e.key) return;
|
|
||||||
const isCurrentHold = heldKey === e.key;
|
|
||||||
|
|
||||||
if (video.paused || isCurrentHold) {
|
|
||||||
if (!e.repeat) {
|
|
||||||
heldKey = e.key;
|
|
||||||
isHoldingKey = false;
|
|
||||||
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
|
||||||
keyHoldTimer = setTimeout(() => {
|
|
||||||
isHoldingKey = true;
|
|
||||||
if (heldKey === ".") {
|
|
||||||
video.play();
|
|
||||||
} else if (heldKey === ",") {
|
|
||||||
if (reverseInterval) clearInterval(reverseInterval);
|
|
||||||
reverseInterval = setInterval(() => {
|
|
||||||
if (video.currentTime > 0) {
|
|
||||||
video.currentTime = Math.max(0, video.currentTime - 0.04);
|
|
||||||
} else {
|
|
||||||
clearInterval(reverseInterval);
|
|
||||||
reverseInterval = null;
|
|
||||||
}
|
|
||||||
}, 40);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
} else {
|
|
||||||
if (keyHoldTimer && !isHoldingKey) {
|
|
||||||
clearTimeout(keyHoldTimer);
|
|
||||||
keyHoldTimer = null;
|
|
||||||
isHoldingKey = true;
|
|
||||||
if (heldKey === ".") {
|
|
||||||
video.play();
|
|
||||||
} else if (heldKey === ",") {
|
|
||||||
if (reverseInterval) clearInterval(reverseInterval);
|
|
||||||
reverseInterval = setInterval(() => {
|
|
||||||
if (video.currentTime > 0) {
|
|
||||||
video.currentTime = Math.max(0, video.currentTime - 0.04);
|
|
||||||
} else {
|
|
||||||
clearInterval(reverseInterval);
|
|
||||||
reverseInterval = null;
|
|
||||||
}
|
|
||||||
}, 40);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * 5));
|
|
||||||
}
|
|
||||||
showControlsAndReset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyUp(e) {
|
|
||||||
if (!document.body.contains(video)) {
|
|
||||||
cleanupKeyboardListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "." || e.key === ",") {
|
|
||||||
if (heldKey === e.key) {
|
|
||||||
if (keyHoldTimer) {
|
|
||||||
clearTimeout(keyHoldTimer);
|
|
||||||
keyHoldTimer = null;
|
|
||||||
}
|
|
||||||
if (isHoldingKey) {
|
|
||||||
if (heldKey === ".") {
|
|
||||||
video.pause();
|
|
||||||
} else if (heldKey === ",") {
|
|
||||||
if (reverseInterval) {
|
|
||||||
clearInterval(reverseInterval);
|
|
||||||
reverseInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (Number.isFinite(video.currentTime)) {
|
|
||||||
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
|
|
||||||
const direction = heldKey === "." ? 1 : -1;
|
|
||||||
const frameTime = 1 / 30;
|
|
||||||
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * frameTime));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
heldKey = null;
|
|
||||||
isHoldingKey = false;
|
|
||||||
showControlsAndReset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
if (!document.body.contains(video)) {
|
|
||||||
cleanupKeyboardListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
|
||||||
if (reverseInterval) clearInterval(reverseInterval);
|
|
||||||
keyHoldTimer = null;
|
|
||||||
reverseInterval = null;
|
|
||||||
if (isHoldingKey) {
|
|
||||||
if (heldKey === ".") {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
heldKey = null;
|
|
||||||
isHoldingKey = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupKeyboardListeners() {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
document.removeEventListener('keyup', handleKeyUp);
|
|
||||||
window.removeEventListener('blur', handleBlur);
|
|
||||||
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
|
||||||
if (reverseInterval) clearInterval(reverseInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
document.addEventListener('keyup', handleKeyUp);
|
|
||||||
window.addEventListener('blur', handleBlur);
|
|
||||||
|
|
||||||
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
||||||
handleVolumeButton(video.volume);
|
handleVolumeButton(video.volume);
|
||||||
|
|
||||||
const mediaObj = player.closest('.media-object');
|
|
||||||
let isBlurredDetail = false;
|
|
||||||
if (mediaObj && localStorage.getItem('blurDetail') !== 'false') {
|
|
||||||
const mode = mediaObj.getAttribute('data-mode');
|
|
||||||
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
|
||||||
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
|
||||||
const blurSfw = localStorage.getItem('blurSfw') === 'true';
|
|
||||||
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
|
|
||||||
|
|
||||||
let shouldBlurThis = false;
|
|
||||||
if (mode === 'nsfw') shouldBlurThis = blurNsfw;
|
|
||||||
else if (mode === 'nsfl') shouldBlurThis = blurNsfl;
|
|
||||||
else if (mode === 'sfw') shouldBlurThis = blurSfw;
|
|
||||||
else if (mode === 'untagged') shouldBlurThis = blurUntagged;
|
|
||||||
|
|
||||||
if (shouldBlurThis && !mediaObj.classList.contains('revealed')) {
|
|
||||||
isBlurredDetail = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade preload from 'metadata' → 'auto' for video elements so the browser
|
|
||||||
// starts buffering data immediately during player setup. With just 'metadata',
|
|
||||||
// play() triggers a brand-new range request and the user sees the loader spinner
|
|
||||||
// until enough data arrives. 'auto' closes that gap for both autoplay and manual play.
|
|
||||||
if (video.tagName === 'VIDEO' && video.preload !== 'auto') {
|
|
||||||
video.preload = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt autoplay and show overlay if blocked
|
// Attempt autoplay and show overlay if blocked
|
||||||
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
|
const shouldAutoplay = window.f0ckSession?.disable_autoplay !== true;
|
||||||
if (shouldAutoplay) {
|
if (shouldAutoplay) {
|
||||||
const playPromise = togglePlay();
|
const playPromise = togglePlay();
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
@@ -869,162 +541,6 @@ class v0ck {
|
|||||||
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controls auto-hide logic (auto hide controls after 2.5 seconds of inactivity)
|
|
||||||
let controlsTimer;
|
|
||||||
// True while the cursor is physically inside .v0ck_player_controls
|
|
||||||
let mouseIsOverControls = false;
|
|
||||||
|
|
||||||
// Track real mouse position at document level — completely independent of
|
|
||||||
// any element animation or synthetic events.
|
|
||||||
let docMouseX = -1;
|
|
||||||
let docMouseY = -1;
|
|
||||||
const onDocMouseMove = (e) => {
|
|
||||||
docMouseX = e.clientX;
|
|
||||||
docMouseY = e.clientY;
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', onDocMouseMove, { passive: true });
|
|
||||||
|
|
||||||
function resetControlsTimer() {
|
|
||||||
clearTimeout(controlsTimer);
|
|
||||||
// Never schedule auto-hide while the user is mousing over the controls bar
|
|
||||||
if (mouseIsOverControls) return;
|
|
||||||
const isFullscreen = player.classList.contains('v0ck_fullscreen');
|
|
||||||
if (!video.paused || isFullscreen) {
|
|
||||||
controlsTimer = setTimeout(() => {
|
|
||||||
player.classList.remove('v0ck_hover');
|
|
||||||
if (settingsMenu && !settingsMenu.classList.contains('v0ck_hidden')) {
|
|
||||||
settingsMenu.classList.add('v0ck_hidden');
|
|
||||||
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
|
||||||
}
|
|
||||||
}, 2500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showControlsAndReset(e) {
|
|
||||||
// Ignore synthetic pointer events fired by the browser during CSS animations
|
|
||||||
// (element shifts under stationary cursor). We compare against docMouseX/Y
|
|
||||||
// which is only ever updated by genuine user mouse movement.
|
|
||||||
if (e && e.clientX !== undefined && e.clientY !== undefined) {
|
|
||||||
if (e.clientX === docMouseX && e.clientY === docMouseY &&
|
|
||||||
player.classList.contains('v0ck_hover')) {
|
|
||||||
// Coordinates unchanged and controls already visible — synthetic event, skip.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
docMouseX = e.clientX;
|
|
||||||
docMouseY = e.clientY;
|
|
||||||
}
|
|
||||||
player.classList.add('v0ck_hover');
|
|
||||||
resetControlsTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Events that should show controls and reset/extend the auto-hide timer
|
|
||||||
const resetEvents = ['touchstart', 'touchmove', 'touchend', 'click', 'mousemove', 'mouseenter'];
|
|
||||||
resetEvents.forEach(evt => {
|
|
||||||
player.addEventListener(evt, showControlsAndReset, { capture: true, passive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// While hovering the controls bar: freeze the auto-hide timer completely.
|
|
||||||
const controlsBar = player.querySelector('.v0ck_player_controls');
|
|
||||||
if (controlsBar) {
|
|
||||||
controlsBar.addEventListener('mouseenter', () => {
|
|
||||||
mouseIsOverControls = true;
|
|
||||||
clearTimeout(controlsTimer); // cancel any countdown already in progress
|
|
||||||
}, { passive: true });
|
|
||||||
controlsBar.addEventListener('mouseleave', (e) => {
|
|
||||||
mouseIsOverControls = false;
|
|
||||||
// Only restart the timer if the cursor re-entered the player area
|
|
||||||
// (not when it left the player entirely — the player mouseleave handles that)
|
|
||||||
const r = player.getBoundingClientRect();
|
|
||||||
const stillInPlayer = e.clientX >= r.left && e.clientX <= r.right &&
|
|
||||||
e.clientY >= r.top && e.clientY <= r.bottom;
|
|
||||||
if (stillInPlayer) resetControlsTimer();
|
|
||||||
}, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide when cursor leaves the player entirely.
|
|
||||||
// NO capture:true — that would incorrectly intercept mouseleave events from
|
|
||||||
// child elements (e.g. the progress bar animating away from the cursor).
|
|
||||||
// Without capture, this only fires when the mouse truly leaves .v0ck itself.
|
|
||||||
player.addEventListener('mouseleave', (e) => {
|
|
||||||
const r = player.getBoundingClientRect();
|
|
||||||
// Grace zone: the controls bar peeks ~3px below the player when hidden.
|
|
||||||
// If the cursor is still in that strip, don't hide.
|
|
||||||
if (e.clientX >= r.left && e.clientX <= r.right &&
|
|
||||||
e.clientY > r.bottom && e.clientY <= r.bottom + 8) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If the cursor moved into the controls bar itself (or any child of it),
|
|
||||||
// keep the controls visible — the user is interacting with them.
|
|
||||||
const controls = player.querySelector('.v0ck_player_controls');
|
|
||||||
if (controls && e.relatedTarget && controls.contains(e.relatedTarget)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
docMouseX = -1;
|
|
||||||
docMouseY = -1;
|
|
||||||
player.classList.remove('v0ck_hover');
|
|
||||||
clearTimeout(controlsTimer);
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('play', resetControlsTimer);
|
|
||||||
video.addEventListener('playing', resetControlsTimer);
|
|
||||||
video.addEventListener('pause', () => clearTimeout(controlsTimer));
|
|
||||||
|
|
||||||
// Speedup 2x on Hold logic
|
|
||||||
function startSpeedUp(e) {
|
|
||||||
if (e.type === 'mousedown' && isMobile) return;
|
|
||||||
// Only left mouse click or touch triggers speedup
|
|
||||||
if (e.type === 'mousedown' && e.button !== 0) return;
|
|
||||||
|
|
||||||
// Don't speed up if clicking on controls or settings panel
|
|
||||||
const path = e.path || (e.composedPath && e.composedPath());
|
|
||||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
|
||||||
if (isControls) return;
|
|
||||||
|
|
||||||
clearTimeout(speedUpTimeout);
|
|
||||||
speedUpTimeout = setTimeout(() => {
|
|
||||||
isSpeedingUp = true;
|
|
||||||
ignoreNextClick = true;
|
|
||||||
wasPausedWhenStarted = video.paused;
|
|
||||||
restorePlaybackRate = video.playbackRate;
|
|
||||||
video.playbackRate = 2.0;
|
|
||||||
|
|
||||||
if (wasPausedWhenStarted) {
|
|
||||||
video.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (speedIndicator) {
|
|
||||||
speedIndicator.classList.remove('v0ck_hidden');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function endSpeedUp(e) {
|
|
||||||
clearTimeout(speedUpTimeout);
|
|
||||||
if (isSpeedingUp) {
|
|
||||||
isSpeedingUp = false;
|
|
||||||
video.playbackRate = restorePlaybackRate;
|
|
||||||
if (wasPausedWhenStarted) {
|
|
||||||
video.pause();
|
|
||||||
wasPausedWhenStarted = false;
|
|
||||||
}
|
|
||||||
if (speedIndicator) {
|
|
||||||
speedIndicator.classList.add('v0ck_hidden');
|
|
||||||
}
|
|
||||||
// Brief timeout before allowing normal clicking again to bypass the immediate click event
|
|
||||||
setTimeout(() => {
|
|
||||||
ignoreNextClick = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
player.addEventListener('mousedown', startSpeedUp);
|
|
||||||
player.addEventListener('touchstart', startSpeedUp, { passive: true });
|
|
||||||
player.addEventListener('mouseup', endSpeedUp);
|
|
||||||
player.addEventListener('mouseleave', endSpeedUp);
|
|
||||||
player.addEventListener('touchend', endSpeedUp);
|
|
||||||
player.addEventListener('touchcancel', endSpeedUp);
|
|
||||||
|
|
||||||
this.toggleFullScreen = toggleFullScreen;
|
this.toggleFullScreen = toggleFullScreen;
|
||||||
this.enterFullScreen = enterFullScreen;
|
this.enterFullScreen = enterFullScreen;
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* Backfill script: populate width/height for existing image and video items.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/backfill_dimensions.mjs
|
|
||||||
* node scripts/backfill_dimensions.mjs --dry-run (print without writing)
|
|
||||||
* node scripts/backfill_dimensions.mjs --limit 500 (process first N items)
|
|
||||||
*
|
|
||||||
* Skips: audio, flash, PDF, YouTube items (width/height left as NULL).
|
|
||||||
* Skips: items where the physical file is missing.
|
|
||||||
* Safe to run multiple times — only processes rows where width IS NULL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn as _spawn } from 'child_process';
|
|
||||||
import db from '../src/inc/sql.mjs';
|
|
||||||
import cfg from '../src/inc/config.mjs';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
|
|
||||||
const isDryRun = process.argv.includes('--dry-run');
|
|
||||||
const limitArg = process.argv.indexOf('--limit');
|
|
||||||
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
|
|
||||||
const BATCH = 50;
|
|
||||||
|
|
||||||
// ---- tiny spawn helper (no shell) ----
|
|
||||||
const spawn = (cmd, args, opts = {}) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const child = _spawn(cmd, args, opts);
|
|
||||||
let out = '';
|
|
||||||
let err = '';
|
|
||||||
child.stdout?.on('data', d => { out += d; });
|
|
||||||
child.stderr?.on('data', d => { err += d; });
|
|
||||||
child.on('close', code => {
|
|
||||||
if (code !== 0 && !opts.ignoreExitCode) {
|
|
||||||
const e = new Error(`${cmd} exited ${code}`);
|
|
||||||
e.stderr = err;
|
|
||||||
return reject(e);
|
|
||||||
}
|
|
||||||
resolve(out);
|
|
||||||
});
|
|
||||||
child.on('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- probe helpers ----
|
|
||||||
async function getDimsImage(filePath) {
|
|
||||||
try {
|
|
||||||
// magick identify -format "%wx%h" reports raw pixel dimensions
|
|
||||||
const out = await spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { ignoreExitCode: true });
|
|
||||||
// Take the first line (multi-frame GIF etc. may have many)
|
|
||||||
const line = out.trim().split('\n')[0];
|
|
||||||
const match = line.match(/^(\d+)x(\d+)$/);
|
|
||||||
if (match) return { w: parseInt(match[1], 10), h: parseInt(match[2], 10) };
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDimsVideo(filePath) {
|
|
||||||
try {
|
|
||||||
const out = await spawn('ffprobe', [
|
|
||||||
'-v', 'error', '-select_streams', 'v:0',
|
|
||||||
'-show_entries', 'stream=width,height',
|
|
||||||
'-of', 'csv=p=0', filePath
|
|
||||||
], { ignoreExitCode: true });
|
|
||||||
const parts = out.trim().split(',');
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
const w = parseInt(parts[0], 10);
|
|
||||||
const h = parseInt(parts[1], 10);
|
|
||||||
if (w > 0 && h > 0) return { w, h };
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- main ----
|
|
||||||
async function run() {
|
|
||||||
console.log(`[BACKFILL] Starting dimension backfill${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (limit: ${limit})` : ''}`);
|
|
||||||
|
|
||||||
// Count pending
|
|
||||||
const [{ count }] = await db`
|
|
||||||
SELECT count(*) FROM items
|
|
||||||
WHERE width IS NULL
|
|
||||||
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
|
|
||||||
`;
|
|
||||||
const total = parseInt(count, 10);
|
|
||||||
const toProcess = limit ? Math.min(total, limit) : total;
|
|
||||||
console.log(`[BACKFILL] ${total} items need backfill${limit ? `, processing up to ${toProcess}` : ''}`);
|
|
||||||
|
|
||||||
let processed = 0;
|
|
||||||
let updated = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
// NOTE: Always query at OFFSET 0 — updated rows leave the WHERE width IS NULL set,
|
|
||||||
// so the result set shrinks naturally each batch. Using a moving OFFSET would skip
|
|
||||||
// as many rows as were just updated (pagination-with-mutation bug).
|
|
||||||
while (processed < toProcess) {
|
|
||||||
const batchSize = limit ? Math.min(BATCH, toProcess - processed) : BATCH;
|
|
||||||
const rows = await db`
|
|
||||||
SELECT id, dest, mime FROM items
|
|
||||||
WHERE width IS NULL
|
|
||||||
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ${batchSize}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (rows.length === 0) break;
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (processed >= toProcess) break;
|
|
||||||
processed++;
|
|
||||||
|
|
||||||
const filePath = path.join(cfg.paths.b, row.dest);
|
|
||||||
|
|
||||||
// Check file exists (may be deleted/purged)
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
} catch {
|
|
||||||
console.log(`[BACKFILL] SKIP #${row.id} — file missing: ${row.dest}`);
|
|
||||||
skipped++;
|
|
||||||
// Mark with 0 so it doesn't re-appear in future runs
|
|
||||||
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dims = null;
|
|
||||||
try {
|
|
||||||
if (row.mime.startsWith('image/')) {
|
|
||||||
dims = await getDimsImage(filePath);
|
|
||||||
} else if (row.mime.startsWith('video/')) {
|
|
||||||
dims = await getDimsVideo(filePath);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[BACKFILL] PROBE ERROR #${row.id}: ${e.message}`);
|
|
||||||
failed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dims) {
|
|
||||||
console.log(`[BACKFILL] SKIP #${row.id} — no dimensions found (${row.mime})`);
|
|
||||||
skipped++;
|
|
||||||
// Mark with 0 so it doesn't re-appear in future runs and cause infinite loops
|
|
||||||
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[BACKFILL] ${isDryRun ? '[DRY]' : 'UPDATE'} #${row.id} → ${dims.w}×${dims.h}`);
|
|
||||||
if (!isDryRun) {
|
|
||||||
await db`UPDATE items SET width = ${dims.w}, height = ${dims.h} WHERE id = ${row.id}`;
|
|
||||||
}
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[BACKFILL] Progress: ${processed} / ${toProcess} (updated=${updated}, skipped=${skipped}, failed=${failed})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[BACKFILL] Done. updated=${updated}, skipped=${skipped}, failed=${failed}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('[BACKFILL] Fatal error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import db from '../src/inc/sql.mjs';
|
|
||||||
import cfg from '../src/inc/config.mjs';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
const isDryRun = process.argv.includes('--dry-run');
|
|
||||||
const limitArg = process.argv.indexOf('--limit');
|
|
||||||
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
|
|
||||||
const BATCH_SIZE = 1000;
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log(`[BACKFILL] Starting long UUID migration & backfill script${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (Limit: ${limit})` : ''}`);
|
|
||||||
|
|
||||||
if (!isDryRun) {
|
|
||||||
console.log('[MIGRATION] Applying database schema migrations...');
|
|
||||||
// Drop views first because they depend on columns we want to alter
|
|
||||||
await db`DROP VIEW IF EXISTS public.items_sfw CASCADE`;
|
|
||||||
await db`DROP VIEW IF EXISTS public.items_li CASCADE`;
|
|
||||||
|
|
||||||
// Alter dest columns in items and comment_files
|
|
||||||
await db`ALTER TABLE public.items ALTER COLUMN dest TYPE character varying(255)`;
|
|
||||||
await db`ALTER TABLE public.comment_files ALTER COLUMN dest TYPE character varying(255)`;
|
|
||||||
|
|
||||||
// Recreate public.items_li view
|
|
||||||
await db`
|
|
||||||
CREATE OR REPLACE VIEW public.items_li AS
|
|
||||||
SELECT items.id,
|
|
||||||
items.src,
|
|
||||||
items.dest,
|
|
||||||
items.mime,
|
|
||||||
items.size,
|
|
||||||
items.checksum,
|
|
||||||
items.username,
|
|
||||||
items.userchannel,
|
|
||||||
items.usernetwork,
|
|
||||||
items.stamp
|
|
||||||
FROM ((public.items
|
|
||||||
JOIN public.tags_assign ta1 ON (((ta1.tag_id = 1) AND (ta1.item_id = items.id))))
|
|
||||||
JOIN public.tags_assign ta2 ON (((NOT (ta2.tag_id IN ( SELECT tags_nsfp.id
|
|
||||||
FROM public.tags_nsfp))) AND (ta2.item_id = items.id))))
|
|
||||||
WHERE items.active
|
|
||||||
GROUP BY items.id;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Recreate public.items_sfw view
|
|
||||||
await db`
|
|
||||||
CREATE OR REPLACE VIEW public.items_sfw AS
|
|
||||||
SELECT ( SELECT
|
|
||||||
CASE
|
|
||||||
WHEN (tags_assign.tag_id > 0) THEN tags_assign.tag_id
|
|
||||||
ELSE 0
|
|
||||||
END AS "case"
|
|
||||||
FROM public.tags_assign
|
|
||||||
WHERE ((tags_assign.tag_id = ANY (ARRAY[1, 2])) AND (tags_assign.item_id = items.id))) AS sfw,
|
|
||||||
( SELECT
|
|
||||||
CASE
|
|
||||||
WHEN (tags_assign.tag_id > 0) THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END AS "case"
|
|
||||||
FROM public.tags_assign
|
|
||||||
WHERE ((tags_assign.tag_id IN ( SELECT tags_nsfp.id
|
|
||||||
FROM public.tags_nsfp)) AND (tags_assign.item_id = items.id))
|
|
||||||
LIMIT 1) AS nsfp,
|
|
||||||
id,
|
|
||||||
src,
|
|
||||||
dest,
|
|
||||||
mime,
|
|
||||||
size,
|
|
||||||
checksum,
|
|
||||||
username,
|
|
||||||
userchannel,
|
|
||||||
usernetwork,
|
|
||||||
stamp,
|
|
||||||
active
|
|
||||||
FROM public.items;
|
|
||||||
`;
|
|
||||||
console.log('[MIGRATION] Schema changes applied and views successfully recreated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map to keep track of all renamed files: old_filename -> new_filename
|
|
||||||
const renameMap = new Map();
|
|
||||||
|
|
||||||
// 1. Process items
|
|
||||||
console.log('[BACKFILL] Fetching items to process...');
|
|
||||||
const items = await db`SELECT id, dest, mime FROM items`;
|
|
||||||
console.log(`[BACKFILL] Found ${items.length} items in DB.`);
|
|
||||||
|
|
||||||
let itemsProcessed = 0;
|
|
||||||
let itemsRenamedCount = 0;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (limit && itemsProcessed >= limit) break;
|
|
||||||
itemsProcessed++;
|
|
||||||
|
|
||||||
// YouTube embeds store dest as "yt:VIDEO_ID" — not a real file, skip entirely
|
|
||||||
if (item.mime === 'video/youtube') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = path.extname(item.dest);
|
|
||||||
const base = path.basename(item.dest, ext);
|
|
||||||
|
|
||||||
// If name is already 48 characters long, skip
|
|
||||||
if (base.length === 48) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUuid = crypto.randomBytes(24).toString('hex');
|
|
||||||
const newDest = `${newUuid}${ext}`;
|
|
||||||
renameMap.set(item.dest, newDest);
|
|
||||||
|
|
||||||
// Physical files could be in active, pending or deleted folders
|
|
||||||
const pathsToCheck = [
|
|
||||||
path.join(cfg.paths.b, item.dest),
|
|
||||||
path.join(cfg.paths.pending, 'b', item.dest),
|
|
||||||
path.join(cfg.paths.deleted, 'b', item.dest)
|
|
||||||
];
|
|
||||||
|
|
||||||
let foundPath = null;
|
|
||||||
for (const p of pathsToCheck) {
|
|
||||||
try {
|
|
||||||
const stat = await fs.lstat(p);
|
|
||||||
if (!stat.isSymbolicLink()) {
|
|
||||||
foundPath = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundPath) {
|
|
||||||
const newPath = path.join(path.dirname(foundPath), newDest);
|
|
||||||
console.log(`[BACKFILL] Renaming item file: ${foundPath} -> ${newPath}`);
|
|
||||||
if (!isDryRun) {
|
|
||||||
await fs.rename(foundPath, newPath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`[BACKFILL] [WARNING] Item physical file not found/is symlink for: ${item.dest}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDryRun) {
|
|
||||||
await db`UPDATE items SET dest = ${newDest} WHERE id = ${item.id}`;
|
|
||||||
}
|
|
||||||
itemsRenamedCount++;
|
|
||||||
|
|
||||||
if (itemsRenamedCount % BATCH_SIZE === 0) {
|
|
||||||
console.log(`[BACKFILL] Processed ${itemsRenamedCount} item renames...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Process comment_files
|
|
||||||
console.log('[BACKFILL] Fetching comment files to process...');
|
|
||||||
const commentFiles = await db`SELECT id, dest FROM comment_files`;
|
|
||||||
console.log(`[BACKFILL] Found ${commentFiles.length} comment files in DB.`);
|
|
||||||
|
|
||||||
let commentsProcessed = 0;
|
|
||||||
let commentsRenamedCount = 0;
|
|
||||||
|
|
||||||
for (const cf of commentFiles) {
|
|
||||||
if (limit && commentsProcessed >= limit) break;
|
|
||||||
commentsProcessed++;
|
|
||||||
|
|
||||||
const ext = path.extname(cf.dest);
|
|
||||||
const base = path.basename(cf.dest, ext);
|
|
||||||
|
|
||||||
if (base.length === 48) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUuid = crypto.randomBytes(24).toString('hex');
|
|
||||||
const newDest = `${newUuid}${ext}`;
|
|
||||||
renameMap.set(cf.dest, newDest);
|
|
||||||
|
|
||||||
const oldPath = path.join(cfg.paths.c, cf.dest);
|
|
||||||
const newPath = path.join(cfg.paths.c, newDest);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stat = await fs.lstat(oldPath);
|
|
||||||
if (!stat.isSymbolicLink()) {
|
|
||||||
console.log(`[BACKFILL] Renaming comment file: ${oldPath} -> ${newPath}`);
|
|
||||||
if (!isDryRun) {
|
|
||||||
await fs.rename(oldPath, newPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
// Rename corresponding thumbnail in t/ (cf_${old_uuid}.webp -> cf_${new_uuid}.webp)
|
|
||||||
const oldThumbPath = path.join(cfg.paths.t, `cf_${base}.webp`);
|
|
||||||
const newThumbPath = path.join(cfg.paths.t, `cf_${newUuid}.webp`);
|
|
||||||
try {
|
|
||||||
await fs.access(oldThumbPath);
|
|
||||||
console.log(`[BACKFILL] Renaming comment thumbnail: ${oldThumbPath} -> ${newThumbPath}`);
|
|
||||||
if (!isDryRun) {
|
|
||||||
await fs.rename(oldThumbPath, newThumbPath);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
if (!isDryRun) {
|
|
||||||
await db`UPDATE comment_files SET dest = ${newDest} WHERE id = ${cf.id}`;
|
|
||||||
}
|
|
||||||
commentsRenamedCount++;
|
|
||||||
|
|
||||||
if (commentsRenamedCount % BATCH_SIZE === 0) {
|
|
||||||
console.log(`[BACKFILL] Processed ${commentsRenamedCount} comment file renames...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update Symlinks in c/
|
|
||||||
console.log('[BACKFILL] Scanning /c/ directory for symlinks to update...');
|
|
||||||
try {
|
|
||||||
const filesInC = await fs.readdir(cfg.paths.c);
|
|
||||||
for (const f of filesInC) {
|
|
||||||
const filePath = path.join(cfg.paths.c, f);
|
|
||||||
try {
|
|
||||||
const stat = await fs.lstat(filePath);
|
|
||||||
if (stat.isSymbolicLink()) {
|
|
||||||
const target = await fs.readlink(filePath);
|
|
||||||
const targetBasename = path.basename(target);
|
|
||||||
if (renameMap.has(targetBasename)) {
|
|
||||||
const newTargetBasename = renameMap.get(targetBasename);
|
|
||||||
// Re-construct the symlink target path, pointing to public/b or public/c
|
|
||||||
const resolvedTargetAbs = path.resolve(path.dirname(filePath), target);
|
|
||||||
const resolvedTargetNewAbs = path.join(path.dirname(resolvedTargetAbs), newTargetBasename);
|
|
||||||
const relativeNewTarget = path.relative(path.dirname(filePath), resolvedTargetNewAbs);
|
|
||||||
|
|
||||||
console.log(`[BACKFILL] Updating symlink: ${filePath} -> ${relativeNewTarget}`);
|
|
||||||
if (!isDryRun) {
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
await fs.symlink(relativeNewTarget, filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[BACKFILL] [ERROR] Failed processing file/symlink ${filePath}:`, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[BACKFILL] Error reading /c/ directory:', e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[BACKFILL] Completed. Items renamed: ${itemsRenamedCount}. Comment files renamed: ${commentsRenamedCount}.`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('[BACKFILL] Fatal error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import db from "../src/inc/sql.mjs";
|
import db from "../src/inc/sql.mjs";
|
||||||
import lib from "../src/inc/lib.mjs";
|
import lib from "../src/inc/lib.mjs";
|
||||||
import cfg from "../src/inc/config.mjs";
|
import cfg from "../src/inc/config.mjs";
|
||||||
|
import { getDefaultLayout } from "../src/inc/settings.mjs";
|
||||||
|
|
||||||
const [username, password] = process.argv.slice(2);
|
const [username, password] = process.argv.slice(2);
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ async function createAdmin() {
|
|||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||||
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${(cfg.websrv.default_layout ?? 'modern') !== 'legacy'}, false, false)
|
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${getDefaultLayout() === 'modern'}, false, false)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(`--- Admin User ${username} Created Successfully ---`);
|
console.log(`--- Admin User ${username} Created Successfully ---`);
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* fix_youtube_dest.mjs
|
|
||||||
*
|
|
||||||
* One-time fix: the backfill_uuid_filenames.mjs script failed to exclude
|
|
||||||
* YouTube items, which store dest as "yt:VIDEO_ID" rather than a real file.
|
|
||||||
* As a result, those dest values were replaced with random UUIDs, breaking
|
|
||||||
* embed URLs like youtube.com/embed/<uuid>.
|
|
||||||
*
|
|
||||||
* This script:
|
|
||||||
* 1. Finds all items with mime = 'video/youtube'
|
|
||||||
* 2. For each, re-extracts the video ID from the src column
|
|
||||||
* (src holds the original YouTube watch URL: https://www.youtube.com/watch?v=VIDEO_ID)
|
|
||||||
* 3. Restores dest to "yt:VIDEO_ID"
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/fix_youtube_dest.mjs # live run
|
|
||||||
* node scripts/fix_youtube_dest.mjs --dry-run # preview only, no DB changes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import db from '../src/inc/sql.mjs';
|
|
||||||
|
|
||||||
const isDryRun = process.argv.includes('--dry-run');
|
|
||||||
|
|
||||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log(`[FIX-YT-DEST] Starting${isDryRun ? ' (DRY RUN)' : ''}...`);
|
|
||||||
|
|
||||||
const rows = await db`
|
|
||||||
SELECT id, dest, src
|
|
||||||
FROM items
|
|
||||||
WHERE mime = 'video/youtube'
|
|
||||||
ORDER BY id
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log(`[FIX-YT-DEST] Found ${rows.length} YouTube item(s) to check.`);
|
|
||||||
|
|
||||||
let fixed = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
// Already correct format
|
|
||||||
if (row.dest && row.dest.startsWith('yt:')) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract video ID from src URL
|
|
||||||
const match = row.src && row.src.match(ytRegex);
|
|
||||||
if (!match) {
|
|
||||||
console.warn(`[FIX-YT-DEST] [WARN] Item ${row.id}: cannot extract video ID from src="${row.src}", dest="${row.dest}" — skipping`);
|
|
||||||
failed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoId = match[1];
|
|
||||||
const newDest = `yt:${videoId}`;
|
|
||||||
|
|
||||||
console.log(`[FIX-YT-DEST] Item ${row.id}: "${row.dest}" → "${newDest}" (src: ${row.src})`);
|
|
||||||
|
|
||||||
if (!isDryRun) {
|
|
||||||
await db`UPDATE items SET dest = ${newDest} WHERE id = ${row.id}`;
|
|
||||||
}
|
|
||||||
fixed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n[FIX-YT-DEST] Done.`);
|
|
||||||
console.log(` Fixed: ${fixed}`);
|
|
||||||
console.log(` Skipped: ${skipped} (already correct)`);
|
|
||||||
console.log(` Failed: ${failed} (no video ID recoverable from src)`);
|
|
||||||
|
|
||||||
if (isDryRun) {
|
|
||||||
console.log('\n[FIX-YT-DEST] DRY RUN — no changes were written to the database.');
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('[FIX-YT-DEST] Fatal error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -8,10 +8,6 @@
|
|||||||
* node regen.mjs --all - Regenerate ALL items
|
* node regen.mjs --all - Regenerate ALL items
|
||||||
* node regen.mjs --audio - Regenerate all audio items
|
* node regen.mjs --audio - Regenerate all audio items
|
||||||
* node regen.mjs --pdf - Regenerate all PDF items
|
* node regen.mjs --pdf - Regenerate all PDF items
|
||||||
* node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items
|
|
||||||
*
|
|
||||||
* Flash (SWF) items are always excluded — their thumbnails are set via the
|
|
||||||
* Ruffle snapshot mechanism and must never be touched by this script.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import db from "../src/inc/sql.mjs";
|
import db from "../src/inc/sql.mjs";
|
||||||
@@ -30,40 +26,14 @@ if (args.length === 0) {
|
|||||||
console.log(' node regen.mjs --audio - Regenerate all audio items');
|
console.log(' node regen.mjs --audio - Regenerate all audio items');
|
||||||
console.log(' node regen.mjs --pdf - Regenerate all PDF items');
|
console.log(' node regen.mjs --pdf - Regenerate all PDF items');
|
||||||
console.log(' node regen.mjs --youtube - Regenerate all YouTube thumbnails');
|
console.log(' node regen.mjs --youtube - Regenerate all YouTube thumbnails');
|
||||||
console.log(' node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items');
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash mime types — never regenerate these
|
|
||||||
const FLASH_MIMES = [
|
|
||||||
'application/x-shockwave-flash',
|
|
||||||
'application/vnd.adobe.flash.movie',
|
|
||||||
];
|
|
||||||
const isFlash = (mime) => FLASH_MIMES.includes(mime?.toLowerCase());
|
|
||||||
|
|
||||||
const THUMB_SIZE = 512;
|
const THUMB_SIZE = 512;
|
||||||
const blurOnly = args.includes('--blur');
|
|
||||||
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
|
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
|
||||||
|
|
||||||
const regen = async (item) => {
|
const regen = async (item) => {
|
||||||
const { id, dest, mime, src } = item;
|
const { id, dest, mime, src } = item;
|
||||||
|
|
||||||
if (isFlash(mime)) {
|
|
||||||
console.log(`[${id}] Skipped (Flash/SWF — thumbnail managed by Ruffle snapshot)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blurOnly) {
|
|
||||||
console.log(`[${id}] Regenerating blurred thumbnail only: ${dest}`);
|
|
||||||
try {
|
|
||||||
await queue.genBlurredThumbnail(id, false);
|
|
||||||
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
|
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -79,23 +49,23 @@ const regen = async (item) => {
|
|||||||
console.log(`[${id}] ✓ Thumbnail regenerated`);
|
console.log(`[${id}] ✓ Thumbnail regenerated`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate blurred thumbnail unconditionally
|
// Regenerate blurred thumbnail if item has NSFW tag
|
||||||
await queue.genBlurredThumbnail(id, false);
|
const nsfw = await db`SELECT 1 FROM tags_assign WHERE item_id = ${id} AND tag_id = 2 LIMIT 1`;
|
||||||
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
if (nsfw.length > 0) {
|
||||||
|
await queue.genBlurredThumbnail(id, false);
|
||||||
|
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared NOT IN clause for Flash exclusion
|
|
||||||
const flashExclude = db`mime NOT IN (${db(FLASH_MIMES)})`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let items;
|
let items;
|
||||||
|
|
||||||
if (args.includes('--all')) {
|
if (args.includes('--all')) {
|
||||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND ${flashExclude} ORDER BY id`;
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false ORDER BY id`;
|
||||||
console.log(`Regenerating ALL ${items.length} non-Flash items...\n`);
|
console.log(`Regenerating ALL ${items.length} items...\n`);
|
||||||
} else if (args.includes('--audio')) {
|
} else if (args.includes('--audio')) {
|
||||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
|
||||||
console.log(`Regenerating ${items.length} audio items...\n`);
|
console.log(`Regenerating ${items.length} audio items...\n`);
|
||||||
@@ -105,14 +75,6 @@ try {
|
|||||||
} else if (args.includes('--youtube')) {
|
} else if (args.includes('--youtube')) {
|
||||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ORDER BY id`;
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ORDER BY id`;
|
||||||
console.log(`Regenerating ${items.length} YouTube items...\n`);
|
console.log(`Regenerating ${items.length} YouTube items...\n`);
|
||||||
} else if (blurOnly) {
|
|
||||||
items = await db`
|
|
||||||
SELECT id, dest, mime, src
|
|
||||||
FROM items
|
|
||||||
WHERE active = true AND is_deleted = false AND ${flashExclude}
|
|
||||||
ORDER BY id
|
|
||||||
`;
|
|
||||||
console.log(`Regenerating ONLY blurred thumbnails for all ${items.length} non-Flash items...\n`);
|
|
||||||
} else {
|
} else {
|
||||||
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
|
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@@ -135,4 +97,3 @@ try {
|
|||||||
console.error('Fatal error:', err);
|
console.error('Fatal error:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ const sendJson = (res, data, code = 200) => {
|
|||||||
|
|
||||||
// Generate UUID using the same method as video uploads
|
// Generate UUID using the same method as video uploads
|
||||||
const genuuid = async () => {
|
const genuuid = async () => {
|
||||||
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
return (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
||||||
return raw.substring(0, 48);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAvatarUpload = async (req, res) => {
|
export const handleAvatarUpload = async (req, res) => {
|
||||||
@@ -162,14 +161,6 @@ export const handleAvatarUpload = async (req, res) => {
|
|||||||
where user_id = ${+req.session.id}
|
where user_id = ${+req.session.id}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const payloadStr = JSON.stringify({
|
|
||||||
user_id: req.session.id,
|
|
||||||
user: req.session.user,
|
|
||||||
avatar_file: finalFilename,
|
|
||||||
avatar: null
|
|
||||||
});
|
|
||||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
|
||||||
|
|
||||||
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
||||||
return sendJson(res, {
|
return sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -233,13 +224,6 @@ export const handleAvatarDelete = async (req, res) => {
|
|||||||
where user_id = ${+req.session.id}
|
where user_id = ${+req.session.id}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const payloadStr = JSON.stringify({
|
|
||||||
user_id: req.session.id,
|
|
||||||
user: req.session.user,
|
|
||||||
avatar_file: null
|
|
||||||
});
|
|
||||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
|
||||||
|
|
||||||
console.log('[AVATAR HANDLER] Delete complete');
|
console.log('[AVATAR HANDLER] Delete complete');
|
||||||
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
import cfg from "./inc/config.mjs";
|
|
||||||
import path from "path";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import db from "./inc/sql.mjs";
|
|
||||||
import lib from "./inc/lib.mjs";
|
|
||||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
|
||||||
import { execFile as _execFile } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
|
|
||||||
const execFile = promisify(_execFile);
|
|
||||||
|
|
||||||
// Helper for JSON response
|
|
||||||
const sendJson = (res, data, code = 200) => {
|
|
||||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate UUID using the same method as video uploads
|
|
||||||
const genuuid = async () => {
|
|
||||||
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
|
||||||
return raw.substring(0, 48);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleBannerUpload = async (req, res) => {
|
|
||||||
console.log('[BANNER HANDLER] Upload started');
|
|
||||||
|
|
||||||
// Manual Session Lookup
|
|
||||||
let user = [];
|
|
||||||
if (req.cookies && req.cookies.session) {
|
|
||||||
user = await db`
|
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
|
||||||
from "user_sessions"
|
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
|
||||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
|
||||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.length === 0) {
|
|
||||||
console.log('[BANNER HANDLER] Unauthorized - No valid session found');
|
|
||||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session = user[0];
|
|
||||||
console.log('[BANNER HANDLER] Authorized:', req.session.user);
|
|
||||||
|
|
||||||
// CSRF validation
|
|
||||||
if (req.session.csrf_token) {
|
|
||||||
const csrfToken = req.headers['x-csrf-token'];
|
|
||||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
|
||||||
console.warn(`[CSRF] Blocked banner upload for user ${req.session.user}. Invalid token.`);
|
|
||||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
|
||||||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
|
||||||
|
|
||||||
if (!boundaryMatch) {
|
|
||||||
console.log('[BANNER HANDLER] No boundary');
|
|
||||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[BANNER HANDLER] Collecting body...');
|
|
||||||
const body = await collectBody(req);
|
|
||||||
console.log('[BANNER HANDLER] Body collected, size:', body.length);
|
|
||||||
|
|
||||||
console.log('[BANNER HANDLER] Parsing multipart...');
|
|
||||||
const parts = parseMultipart(body, boundaryMatch[1]);
|
|
||||||
const file = parts.file;
|
|
||||||
console.log('[BANNER HANDLER] Parsed, file present:', !!file, 'keys:', Object.keys(parts));
|
|
||||||
|
|
||||||
if (!file || !file.data) {
|
|
||||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (5MB max)
|
|
||||||
const maxSize = 5 * 1024 * 1024;
|
|
||||||
if (file.data.length > maxSize) {
|
|
||||||
return sendJson(res, {
|
|
||||||
success: false,
|
|
||||||
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowed MIME types
|
|
||||||
const allowedMimes = [
|
|
||||||
'image/gif',
|
|
||||||
'image/jpeg',
|
|
||||||
'image/jpg',
|
|
||||||
'image/png',
|
|
||||||
'image/webp'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Validate MIME type from content-type header
|
|
||||||
let mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
|
|
||||||
console.log('[BANNER HANDLER] File MIME from header:', mime);
|
|
||||||
if (!allowedMimes.includes(mime)) {
|
|
||||||
return sendJson(res, {
|
|
||||||
success: false,
|
|
||||||
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to tmp and verify with file magic
|
|
||||||
console.log('[BANNER HANDLER] Generating UUID...');
|
|
||||||
const uuid = await genuuid();
|
|
||||||
const tmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_tmp`);
|
|
||||||
const finalFilename = `banner_${uuid}.webp`;
|
|
||||||
const finalPath = path.join(cfg.paths.a, finalFilename);
|
|
||||||
|
|
||||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
|
||||||
await fs.mkdir(cfg.paths.a, { recursive: true });
|
|
||||||
|
|
||||||
console.log('[BANNER HANDLER] Writing tmp file:', tmpPath);
|
|
||||||
await fs.writeFile(tmpPath, file.data);
|
|
||||||
|
|
||||||
// Verify MIME with file magic
|
|
||||||
console.log('[BANNER HANDLER] Checking MIME with file magic...');
|
|
||||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
|
||||||
console.log('[BANNER HANDLER] Actual MIME:', actualMime.trim());
|
|
||||||
const allowedActualMimes = [
|
|
||||||
'image/gif',
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/webp'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!allowedActualMimes.includes(actualMime.trim())) {
|
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
|
||||||
return sendJson(res, {
|
|
||||||
success: false,
|
|
||||||
msg: `Invalid file type detected: ${actualMime.trim()}`
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to webp using ImageMagick — landscape banner crop (1200x400)
|
|
||||||
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
|
|
||||||
console.log('[BANNER HANDLER] Running magick...');
|
|
||||||
try {
|
|
||||||
await execFile('magick', [`${tmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalPath]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
|
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
|
||||||
return sendJson(res, { success: false, msg: 'Failed to process image: ' + err.message }, 500);
|
|
||||||
}
|
|
||||||
console.log('[BANNER HANDLER] Magick done, output:', finalPath);
|
|
||||||
|
|
||||||
const file_orig = parts.file_orig;
|
|
||||||
if (file_orig && file_orig.data) {
|
|
||||||
console.log('[BANNER HANDLER] Processing original file...');
|
|
||||||
const origTmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_orig_tmp`);
|
|
||||||
const finalOrigPath = path.join(cfg.paths.a, `banner_${uuid}_orig.webp`);
|
|
||||||
await fs.writeFile(origTmpPath, file_orig.data);
|
|
||||||
try {
|
|
||||||
await execFile('magick', [`${origTmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalOrigPath]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[BANNER HANDLER] Magick error on original:', err.message);
|
|
||||||
}
|
|
||||||
await fs.unlink(origTmpPath).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current banner_file to delete old one (after magick succeeds)
|
|
||||||
let currentBanner = null;
|
|
||||||
try {
|
|
||||||
currentBanner = (await db`
|
|
||||||
select banner_file from user_options where user_id = ${+req.session.id}
|
|
||||||
`)[0]?.banner_file;
|
|
||||||
} catch (dbErr) {
|
|
||||||
console.error('[BANNER HANDLER] Could not fetch current banner (column may not exist yet):', dbErr.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up tmp file
|
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
|
||||||
|
|
||||||
// Delete old banner file if exists
|
|
||||||
if (currentBanner) {
|
|
||||||
const oldPath = path.join(cfg.paths.a, currentBanner);
|
|
||||||
await fs.unlink(oldPath).catch(() => { });
|
|
||||||
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
|
|
||||||
await fs.unlink(oldOrigPath).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
console.log('[BANNER HANDLER] Updating database...');
|
|
||||||
try {
|
|
||||||
await db`
|
|
||||||
update user_options
|
|
||||||
set banner_file = ${finalFilename},
|
|
||||||
banner_position = ${parts.banner_position || 'center'},
|
|
||||||
banner_size = ${parts.banner_size || 'cover'}
|
|
||||||
where user_id = ${+req.session.id}
|
|
||||||
`;
|
|
||||||
const payloadStr = JSON.stringify({
|
|
||||||
user_id: +req.session.id,
|
|
||||||
user: req.session.user,
|
|
||||||
banner_file: finalFilename,
|
|
||||||
banner_position: parts.banner_position || 'center',
|
|
||||||
banner_size: parts.banner_size || 'cover'
|
|
||||||
});
|
|
||||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
|
||||||
} catch (dbErr) {
|
|
||||||
console.error('[BANNER HANDLER] DB update failed:', dbErr.message);
|
|
||||||
await fs.unlink(finalPath).catch(() => {});
|
|
||||||
return sendJson(res, { success: false, msg: 'DB error — did you run the migration? ' + dbErr.message }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[BANNER HANDLER] Upload complete:', finalFilename);
|
|
||||||
return sendJson(res, {
|
|
||||||
success: true,
|
|
||||||
banner_file: finalFilename,
|
|
||||||
msg: 'Banner uploaded successfully'
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === 'BODY_TOO_LARGE') {
|
|
||||||
return sendJson(res, { success: false, msg: 'File too large (5 MB max for banners)' }, 413);
|
|
||||||
}
|
|
||||||
console.error('[BANNER HANDLER ERROR]', err.message, err.stack);
|
|
||||||
try {
|
|
||||||
return sendJson(res, { success: false, msg: err.message || 'Banner upload failed' }, 500);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleBannerDelete = async (req, res) => {
|
|
||||||
console.log('[BANNER HANDLER] Delete started');
|
|
||||||
|
|
||||||
// Manual Session Lookup
|
|
||||||
let user = [];
|
|
||||||
if (req.cookies && req.cookies.session) {
|
|
||||||
user = await db`
|
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
|
||||||
from "user_sessions"
|
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
|
||||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
|
||||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.length === 0) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session = user[0];
|
|
||||||
|
|
||||||
// CSRF validation
|
|
||||||
if (req.session.csrf_token) {
|
|
||||||
const csrfToken = req.headers['x-csrf-token'];
|
|
||||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
|
||||||
console.warn(`[CSRF] Blocked banner delete for user ${req.session.user}. Invalid token.`);
|
|
||||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentBanner = (await db`
|
|
||||||
select banner_file from user_options where user_id = ${+req.session.id}
|
|
||||||
`)[0]?.banner_file;
|
|
||||||
|
|
||||||
if (currentBanner) {
|
|
||||||
const oldPath = path.join(cfg.paths.a, currentBanner);
|
|
||||||
await fs.unlink(oldPath).catch(() => { });
|
|
||||||
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
|
|
||||||
await fs.unlink(oldOrigPath).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db`
|
|
||||||
update user_options
|
|
||||||
set banner_file = null
|
|
||||||
where user_id = ${+req.session.id}
|
|
||||||
`;
|
|
||||||
const payloadStr = JSON.stringify({
|
|
||||||
user_id: +req.session.id,
|
|
||||||
user: req.session.user,
|
|
||||||
banner_file: null
|
|
||||||
});
|
|
||||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
|
||||||
|
|
||||||
console.log('[BANNER HANDLER] Delete complete');
|
|
||||||
return sendJson(res, { success: true, msg: 'Custom banner removed' }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[BANNER DELETE ERROR]', err);
|
|
||||||
return sendJson(res, { success: false, msg: 'Failed to remove banner' }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -12,6 +12,7 @@ const sendJson = (res, data, code = 200) => {
|
|||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// One-time migration: ensure comment_files table exists
|
||||||
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||||
@@ -24,12 +25,8 @@ db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
|||||||
original_filename TEXT,
|
original_filename TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`.catch(() => { });
|
)`.catch(() => { });
|
||||||
db`CREATE SEQUENCE IF NOT EXISTS comment_files_id_seq`.catch(() => { });
|
|
||||||
db`ALTER TABLE comment_files ALTER COLUMN id SET DEFAULT nextval('comment_files_id_seq')`.catch(() => { });
|
|
||||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
|
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
|
||||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
|
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
|
||||||
db`ALTER TABLE public.comment_files ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id)`.catch(() => { });
|
|
||||||
db`ALTER TABLE public.comment_files REPLICA IDENTITY DEFAULT`.catch(() => { });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse multipart form data supporting multiple files with the same field name.
|
* Parse multipart form data supporting multiple files with the same field name.
|
||||||
@@ -95,19 +92,12 @@ const parseMultipartFiles = (buffer, boundary) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the allowed MIME list for comment uploads.
|
* Build the allowed MIME list for comment uploads (image/*, video/*, audio/*).
|
||||||
* Respects cfg.websrv.fileupload_comments_mimes (e.g. ["image", "video", "audio"]) to
|
* Filters from cfg.mimes, excluding PDF, SWF, etc.
|
||||||
* allow a different set of categories than the global allowedMimes used for page uploads.
|
|
||||||
* Falls back to image/video/audio if the setting is absent.
|
|
||||||
*/
|
*/
|
||||||
const getAllowedCommentMimes = () => {
|
const getAllowedCommentMimes = () => {
|
||||||
const allowedCats = Array.isArray(cfg.websrv.fileupload_comments_mimes)
|
|
||||||
? cfg.websrv.fileupload_comments_mimes.map(c => c.toLowerCase())
|
|
||||||
: ['image', 'video', 'audio'];
|
|
||||||
return Object.keys(cfg.mimes).filter(mime =>
|
return Object.keys(cfg.mimes).filter(mime =>
|
||||||
allowedCats.some(cat =>
|
mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')
|
||||||
cat.includes('/') ? mime === cat : mime.startsWith(`${cat}/`)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,7 +174,6 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
|
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const allowedMimes = getAllowedCommentMimes();
|
const allowedMimes = getAllowedCommentMimes();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
@@ -385,23 +374,29 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
phash = await queue.generatePHash(tmpPath);
|
phash = await queue.generatePHash(tmpPath);
|
||||||
if (phash && !linkedToExisting) {
|
if (phash && !linkedToExisting) {
|
||||||
// Check comment_files for visual duplicate using fast SQL query
|
// Check comment_files for visual duplicate
|
||||||
const commentMatch = await queue.checkcommentrepostphash(phash);
|
const cfItems = await db`
|
||||||
if (commentMatch) {
|
SELECT id, phash, dest FROM comment_files
|
||||||
const existingAbsPath = path.join(cfg.paths.c, commentMatch.dest);
|
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
|
||||||
try {
|
`;
|
||||||
const realTarget = await fs.realpath(existingAbsPath);
|
for (const cf of cfItems) {
|
||||||
const destPath = path.join(cfg.paths.c, filename);
|
if (isPhashMatch(phash, cf.phash)) {
|
||||||
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
const existingAbsPath = path.join(cfg.paths.c, cf.dest);
|
||||||
await fs.symlink(relTarget, destPath);
|
try {
|
||||||
linkedToExisting = true;
|
const realTarget = await fs.realpath(existingAbsPath);
|
||||||
console.log(`[COMMENT_UPLOAD] PHash match in comment_files: ${filename} → ${relTarget}`);
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
} catch (e) {
|
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||||
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
await fs.symlink(relTarget, destPath);
|
||||||
|
linkedToExisting = true;
|
||||||
|
console.log(`[COMMENT_UPLOAD] PHash match in comment_files: ${filename} → ${relTarget}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check items table for visual duplicate using fast SQL query
|
// Also check items table for visual duplicate
|
||||||
if (!linkedToExisting) {
|
if (!linkedToExisting) {
|
||||||
const phashMatch = await queue.checkrepostphash(phash);
|
const phashMatch = await queue.checkrepostphash(phash);
|
||||||
if (phashMatch) {
|
if (phashMatch) {
|
||||||
@@ -483,74 +478,6 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/v2/comments/upload/:id
|
|
||||||
* Called by the client when the user removes a staged (not-yet-posted) attachment
|
|
||||||
* from the compose area. Only deletes if the row still has comment_id = NULL
|
|
||||||
* (i.e. it was never linked to a real comment) and belongs to the requesting user.
|
|
||||||
*/
|
|
||||||
export const handleCommentUploadCancel = async (req, res, fileId) => {
|
|
||||||
// Manual session lookup (same pattern as handleCommentUpload)
|
|
||||||
if (req.cookies?.session) {
|
|
||||||
try {
|
|
||||||
const user = await db`
|
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
|
||||||
from "user_sessions"
|
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
|
||||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
|
||||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
if (user.length > 0) {
|
|
||||||
req.session = user[0];
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Session lookup failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.session) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(fileId, 10);
|
|
||||||
if (!id || isNaN(id)) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Invalid file ID' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Only allow deletion of own unlinked files
|
|
||||||
const rows = await db`
|
|
||||||
SELECT id, dest FROM comment_files
|
|
||||||
WHERE id = ${id}
|
|
||||||
AND user_id = ${req.session.id}
|
|
||||||
AND comment_id IS NULL
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!rows.length) {
|
|
||||||
// Either doesn't exist, belongs to someone else, or already linked — silently OK
|
|
||||||
return sendJson(res, { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dest } = rows[0];
|
|
||||||
await db`DELETE FROM comment_files WHERE id = ${id}`;
|
|
||||||
|
|
||||||
// Delete file and thumbnail from disk
|
|
||||||
const filePath = path.join(cfg.paths.c, dest);
|
|
||||||
const uuid = dest.split('.')[0];
|
|
||||||
const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
|
|
||||||
await fs.unlink(filePath).catch(() => {});
|
|
||||||
await fs.unlink(thumbPath).catch(() => {});
|
|
||||||
|
|
||||||
console.log(`[COMMENT_UPLOAD] Cancelled (user-removed) attachment deleted: ${dest}`);
|
|
||||||
return sendJson(res, { success: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[COMMENT_UPLOAD] Cancel error:', err);
|
|
||||||
return sendJson(res, { success: false, msg: 'Delete failed' }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate thumbnail for a comment file.
|
* Generate thumbnail for a comment file.
|
||||||
* Outputs to /t/cf_<uuid>.webp
|
* Outputs to /t/cf_<uuid>.webp
|
||||||
@@ -574,33 +501,7 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
|||||||
const ffThumbSize = Math.max(size, 512);
|
const ffThumbSize = Math.max(size, 512);
|
||||||
const seeks = ['20%', '40%', '60%', '80%'];
|
const seeks = ['20%', '40%', '60%', '80%'];
|
||||||
for (const seek of seeks) {
|
for (const seek of seeks) {
|
||||||
try {
|
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||||
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[COMMENT_UPLOAD] ffmpegthumbnailer failed at ${seek} for ${filename}, trying ffmpeg fallback: ${err.message}`);
|
|
||||||
let seekSeconds = 0;
|
|
||||||
try {
|
|
||||||
const durationStr = (await queue.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', realSource])).stdout.trim();
|
|
||||||
const duration = parseFloat(durationStr);
|
|
||||||
if (!isNaN(duration) && duration > 0) {
|
|
||||||
const pct = parseFloat(seek) / 100;
|
|
||||||
seekSeconds = duration * pct;
|
|
||||||
}
|
|
||||||
} catch (probeErr) {
|
|
||||||
seekSeconds = seek === '20%' ? 2 : seek === '40%' ? 5 : seek === '60%' ? 8 : 10;
|
|
||||||
}
|
|
||||||
// Fallback to ffmpeg, overriding the color transfer characteristic to standard bt709 (1) in case of unsupported trc properties (e.g. log316)
|
|
||||||
await queue.spawn('ffmpeg', [
|
|
||||||
'-y',
|
|
||||||
'-ss', String(seekSeconds),
|
|
||||||
'-color_trc', '1',
|
|
||||||
'-i', realSource,
|
|
||||||
'-frames:v', '1',
|
|
||||||
'-update', '1',
|
|
||||||
'-vf', `scale=${ffThumbSize}:${ffThumbSize}:force_original_aspect_ratio=increase,crop=${ffThumbSize}:${ffThumbSize}`,
|
|
||||||
tmpFile
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||||
if (parseFloat(stdout.trim()) > 0.05) break;
|
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||||
@@ -640,4 +541,41 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
|||||||
await fs.unlink(tmpFile).catch(() => { });
|
await fs.unlink(tmpFile).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHash matching helper (same logic as queue.checkrepostphash)
|
||||||
|
*/
|
||||||
|
function isPhashMatch(newHash, dbHash) {
|
||||||
|
if (!newHash || !dbHash) return false;
|
||||||
|
const newHashes = newHash.split('_');
|
||||||
|
const dbHashes = dbHash.split('_');
|
||||||
|
const THRESHOLD = 15;
|
||||||
|
|
||||||
|
const getHammingDistance = (h1, h2) => {
|
||||||
|
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
||||||
|
let distance = 0;
|
||||||
|
for (let i = 0; i < h1.length; i += 2) {
|
||||||
|
const v1 = parseInt(h1.substr(i, 2), 16);
|
||||||
|
const v2 = parseInt(h2.substr(i, 2), 16);
|
||||||
|
let xor = v1 ^ v2;
|
||||||
|
while (xor) {
|
||||||
|
distance += xor & 1;
|
||||||
|
xor >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
||||||
|
let matches = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < framesToCompare; i++) {
|
||||||
|
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
||||||
|
if (dist <= THRESHOLD) matches++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (framesToCompare >= 3 && matches >= 2) return true;
|
||||||
|
if (framesToCompare === 1 && matches === 1) return true;
|
||||||
|
if (framesToCompare === 2 && matches >= 2) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* dm_attachment_handler.mjs — Server-side handler for encrypted DM attachments.
|
|
||||||
*
|
|
||||||
* All uploaded content is opaque AES-GCM ciphertext — the server cannot read it.
|
|
||||||
* Files are stored at /e/<id> (configured via DM_ATTACHMENT_DIR env or /e/).
|
|
||||||
*
|
|
||||||
* Routes (registered as bypass middlewares in index.mjs):
|
|
||||||
* POST /api/dm/attachment/upload/:recipientId — receive ciphertext blob, store on disk
|
|
||||||
* GET /api/dm/attachment/:id — stream ciphertext back (auth required)
|
|
||||||
* DELETE /api/dm/attachment/:id — delete (sender only or admin)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import db from './inc/sql.mjs';
|
|
||||||
import lib from './inc/lib.mjs';
|
|
||||||
import cfg from './inc/config.mjs';
|
|
||||||
import { collectBody } from './inc/multipart.mjs';
|
|
||||||
import { getDmAttachments, getDmAttachmentExpiryDays } from './inc/settings.mjs';
|
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ATTACHMENT_DIR = process.env.DM_ATTACHMENT_DIR || cfg.paths.e;
|
|
||||||
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB plaintext; ciphertext is ~1.33× due to base64url
|
|
||||||
|
|
||||||
// Ensure storage dir exists at startup
|
|
||||||
fs.mkdir(ATTACHMENT_DIR, { recursive: true }).catch(e =>
|
|
||||||
console.error('[DM_ATT] Failed to create attachment dir:', e.message)
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function cleanupExpiredAttachments() {
|
|
||||||
try {
|
|
||||||
const expired = await db`
|
|
||||||
SELECT id, file_path FROM dm_attachments
|
|
||||||
WHERE expires_at < now()
|
|
||||||
`;
|
|
||||||
if (!expired.length) return;
|
|
||||||
|
|
||||||
let deleted = 0;
|
|
||||||
for (const row of expired) {
|
|
||||||
await fs.unlink(row.file_path).catch(() => {}); // ignore already-missing files
|
|
||||||
deleted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = expired.map(r => r.id);
|
|
||||||
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
|
|
||||||
|
|
||||||
console.log(`[DM_ATT] Cleanup: removed ${deleted} expired attachment(s)`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[DM_ATT] Cleanup error:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run once at startup, then every 6 hours
|
|
||||||
cleanupExpiredAttachments();
|
|
||||||
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function resolveSession(req) {
|
|
||||||
if (!req.cookies?.session) return null;
|
|
||||||
try {
|
|
||||||
const rows = await db`
|
|
||||||
SELECT u.id, u.login, u.user, u.admin, u.is_moderator, s.csrf_token
|
|
||||||
FROM user_sessions s
|
|
||||||
LEFT JOIN "user" u ON u.id = s.user_id
|
|
||||||
WHERE s.session = ${lib.sha256(req.cookies.session)}
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
return rows.length ? rows[0] : null;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(res, data, code = 200) {
|
|
||||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCsrf(req, session) {
|
|
||||||
const token = req.headers['x-csrf-token'];
|
|
||||||
return session?.csrf_token && token && token === session.csrf_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Multipart parser (minimal — only parses fields + one binary file part) ───
|
|
||||||
|
|
||||||
function parseAttachmentMultipart(buffer, boundary) {
|
|
||||||
const boundaryBuf = Buffer.from(`--${boundary}`);
|
|
||||||
const segments = [];
|
|
||||||
let start = 0, idx;
|
|
||||||
while ((idx = buffer.indexOf(boundaryBuf, start)) !== -1) {
|
|
||||||
if (start !== 0) segments.push(buffer.slice(start, idx - 2));
|
|
||||||
start = idx + boundaryBuf.length + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = { fields: {}, file: null };
|
|
||||||
for (const seg of segments) {
|
|
||||||
const hdrEnd = seg.indexOf('\r\n\r\n');
|
|
||||||
if (hdrEnd === -1) continue;
|
|
||||||
const headers = seg.slice(0, hdrEnd).toString();
|
|
||||||
const body = seg.slice(hdrEnd + 4);
|
|
||||||
const nameM = headers.match(/name="([^"]+)"/);
|
|
||||||
if (!nameM) continue;
|
|
||||||
const hasFile = headers.includes('filename=') || headers.includes('filename*=');
|
|
||||||
if (hasFile && !result.file) {
|
|
||||||
const ctM = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
||||||
result.file = {
|
|
||||||
data: body,
|
|
||||||
contentType: ctM ? ctM[1].trim() : 'application/octet-stream'
|
|
||||||
};
|
|
||||||
} else if (!hasFile) {
|
|
||||||
result.fields[nameM[1]] = body.toString().trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Upload handler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function handleDmAttachmentUpload(req, res, recipientId) {
|
|
||||||
if (!getDmAttachments()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
|
||||||
|
|
||||||
const session = await resolveSession(req);
|
|
||||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
|
||||||
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
|
||||||
|
|
||||||
const rid = parseInt(recipientId, 10);
|
|
||||||
if (!rid || rid === session.id) return sendJson(res, { success: false, msg: 'Invalid recipient' }, 400);
|
|
||||||
|
|
||||||
// Check recipient exists
|
|
||||||
const recip = await db`SELECT id FROM "user" WHERE id = ${rid} LIMIT 1`;
|
|
||||||
if (!recip.length) return sendJson(res, { success: false, msg: 'Recipient not found' }, 404);
|
|
||||||
|
|
||||||
const ct = req.headers['content-type'] || '';
|
|
||||||
const bndM = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
|
||||||
if (!ct.includes('multipart/form-data') || !bndM) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Expected multipart/form-data' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawBody;
|
|
||||||
try {
|
|
||||||
// MAX_BYTES * 1.4 overhead for base64url encoding + multipart framing
|
|
||||||
rawBody = await collectBody(req, Math.ceil(MAX_BYTES * 1.4) + 4096);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'BODY_TOO_LARGE') return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundary = bndM[1] || bndM[2];
|
|
||||||
const { fields, file } = parseAttachmentMultipart(rawBody, boundary);
|
|
||||||
|
|
||||||
if (!file) return sendJson(res, { success: false, msg: 'No file part found' }, 400);
|
|
||||||
|
|
||||||
const iv = (fields.iv || '').trim();
|
|
||||||
const originalName = (fields.original_name || '').slice(0, 255);
|
|
||||||
const mimeHint = (fields.mime_hint || '').slice(0, 100);
|
|
||||||
const sizeBytes = parseInt(fields.size_bytes || '0', 10) || 0;
|
|
||||||
|
|
||||||
if (!iv || iv.length > 32) return sendJson(res, { success: false, msg: 'Missing or invalid iv' }, 400);
|
|
||||||
if (file.data.length > Math.ceil(MAX_BYTES * 1.4)) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Insert DB record first to get an ID
|
|
||||||
const expiresAt = new Date(Date.now() + getDmAttachmentExpiryDays() * 86400000);
|
|
||||||
const [row] = await db`
|
|
||||||
INSERT INTO dm_attachments ${db({
|
|
||||||
sender_id: session.id,
|
|
||||||
recipient_id: rid,
|
|
||||||
iv,
|
|
||||||
file_path: '',
|
|
||||||
original_name: originalName,
|
|
||||||
mime_hint: mimeHint,
|
|
||||||
size_bytes: sizeBytes,
|
|
||||||
expires_at: expiresAt
|
|
||||||
})}
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
|
|
||||||
const filePath = path.join(ATTACHMENT_DIR, String(row.id));
|
|
||||||
await fs.writeFile(filePath, file.data);
|
|
||||||
|
|
||||||
// Update file_path now that we know the ID
|
|
||||||
await db`UPDATE dm_attachments SET file_path = ${filePath} WHERE id = ${row.id}`;
|
|
||||||
|
|
||||||
return sendJson(res, { success: true, id: String(row.id) });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[DM_ATT] Upload error:', err);
|
|
||||||
return sendJson(res, { success: false, msg: 'Server error' }, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Download handler ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function handleDmAttachmentDownload(req, res, attachmentId) {
|
|
||||||
const session = await resolveSession(req);
|
|
||||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
|
||||||
|
|
||||||
const id = parseInt(attachmentId, 10);
|
|
||||||
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
|
|
||||||
|
|
||||||
const rows = await db`
|
|
||||||
SELECT id, sender_id, recipient_id, iv, file_path, original_name, mime_hint
|
|
||||||
FROM dm_attachments
|
|
||||||
WHERE id = ${id}
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
|
||||||
|
|
||||||
const att = rows[0];
|
|
||||||
// Only sender or recipient may download
|
|
||||||
if (att.sender_id !== session.id && att.recipient_id !== session.id && !session.admin) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(att.file_path);
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
'Content-Length': String(data.length),
|
|
||||||
'Cache-Control': 'private, no-store',
|
|
||||||
'X-DM-IV': att.iv,
|
|
||||||
'X-Original-Name': encodeURIComponent(att.original_name || 'attachment'),
|
|
||||||
'X-Mime-Hint': att.mime_hint || ''
|
|
||||||
});
|
|
||||||
res.end(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[DM_ATT] Download error:', err);
|
|
||||||
return sendJson(res, { success: false, msg: 'File not found on disk' }, 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Delete handler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function handleDmAttachmentDelete(req, res, attachmentId) {
|
|
||||||
const session = await resolveSession(req);
|
|
||||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
|
||||||
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
|
||||||
|
|
||||||
const id = parseInt(attachmentId, 10);
|
|
||||||
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
|
|
||||||
|
|
||||||
const rows = await db`SELECT id, sender_id, file_path FROM dm_attachments WHERE id = ${id} LIMIT 1`;
|
|
||||||
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
|
||||||
|
|
||||||
const att = rows[0];
|
|
||||||
if (att.sender_id !== session.id && !session.admin) {
|
|
||||||
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.unlink(att.file_path).catch(() => {});
|
|
||||||
await db`DELETE FROM dm_attachments WHERE id = ${id}`;
|
|
||||||
return sendJson(res, { success: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[DM_ATT] Delete error:', err);
|
|
||||||
return sendJson(res, { success: false, msg: 'Server error' }, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { execFile as _execFile } from "child_process";
|
import { execFile as _execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
const execFile = promisify(_execFile);
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
@@ -81,11 +80,11 @@ export const handleEmojiUpload = async (req, res) => {
|
|||||||
|
|
||||||
const file = parts.file;
|
const file = parts.file;
|
||||||
if (file && file.data && file.data.length > 0) {
|
if (file && file.data && file.data.length > 0) {
|
||||||
const randSuffix = crypto.randomBytes(24).toString('hex');
|
const randSuffix = Math.random().toString(36).substring(7);
|
||||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||||
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||||
|
|
||||||
const webpFilename = `${randSuffix}.webp`;
|
const webpFilename = `${name}_${randSuffix}.webp`;
|
||||||
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||||
|
|
||||||
if (originalExt === 'webp') {
|
if (originalExt === 'webp') {
|
||||||
@@ -136,133 +135,3 @@ export const handleEmojiUpload = async (req, res) => {
|
|||||||
return sendJson(res, { success: false, message: err.message }, 500);
|
return sendJson(res, { success: false, message: err.message }, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleEmojiEdit = async (req, res) => {
|
|
||||||
// Manual Session Lookup
|
|
||||||
let user = [];
|
|
||||||
if (req.cookies && req.cookies.session) {
|
|
||||||
user = await db`
|
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
|
||||||
from "user_sessions"
|
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
|
||||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.length === 0 || !user[0].admin) {
|
|
||||||
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session = user[0];
|
|
||||||
|
|
||||||
// CSRF validation
|
|
||||||
if (req.session.csrf_token) {
|
|
||||||
const csrfToken = req.headers['x-csrf-token'];
|
|
||||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
|
||||||
console.warn(`[CSRF] Blocked emoji edit for user ${req.session.user}. Invalid token.`);
|
|
||||||
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = req.params.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
|
||||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
|
||||||
|
|
||||||
if (!boundaryMatch) {
|
|
||||||
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
let boundary = boundaryMatch[1].trim();
|
|
||||||
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
|
||||||
boundary = boundary.substring(1, boundary.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyBuffer = await collectBody(req);
|
|
||||||
const parts = parseMultipart(bodyBuffer, boundary);
|
|
||||||
|
|
||||||
const name = (parts.name || '').trim().toLowerCase();
|
|
||||||
let url = (parts.url || '').trim();
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return sendJson(res, { success: false, message: 'Emoji name is required' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-z0-9_-]+$/.test(name)) {
|
|
||||||
return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the current emoji record
|
|
||||||
const current = await db`SELECT id, name, url FROM custom_emojis WHERE id = ${id} LIMIT 1`;
|
|
||||||
if (current.length === 0) {
|
|
||||||
return sendJson(res, { success: false, message: 'Emoji not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check name collision (allow keeping the same name)
|
|
||||||
if (name !== current[0].name) {
|
|
||||||
const conflict = await db`SELECT id FROM custom_emojis WHERE name = ${name} AND id != ${id} LIMIT 1`;
|
|
||||||
if (conflict.length > 0) {
|
|
||||||
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = parts.file;
|
|
||||||
if (file && file.data && file.data.length > 0) {
|
|
||||||
const randSuffix = crypto.randomBytes(24).toString('hex');
|
|
||||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
|
||||||
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
|
||||||
|
|
||||||
const webpFilename = `${randSuffix}.webp`;
|
|
||||||
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
|
||||||
|
|
||||||
if (originalExt === 'webp') {
|
|
||||||
await fs.writeFile(webpPath, file.data);
|
|
||||||
} else {
|
|
||||||
const tmpFilename = `${name}_${randSuffix}_tmp.${originalExt}`;
|
|
||||||
const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
|
|
||||||
await fs.writeFile(tmpPath, file.data);
|
|
||||||
try {
|
|
||||||
await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
|
|
||||||
} finally {
|
|
||||||
await fs.unlink(tmpPath).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stat = await fs.stat(webpPath);
|
|
||||||
if (!stat || stat.size === 0) throw new Error('File write/conversion verification failed');
|
|
||||||
|
|
||||||
// Delete the old local file if it was a hosted emoji
|
|
||||||
if (current[0].url && current[0].url.startsWith('/s/emojis/')) {
|
|
||||||
const oldFilename = path.basename(current[0].url);
|
|
||||||
const oldPath = path.join(cfg.paths.emojis, oldFilename);
|
|
||||||
await fs.unlink(oldPath).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
url = `/s/emojis/${webpFilename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no new file and no new URL, keep the existing URL
|
|
||||||
if (!url) {
|
|
||||||
url = current[0].url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await db`
|
|
||||||
UPDATE custom_emojis
|
|
||||||
SET name = ${name}, url = ${url}
|
|
||||||
WHERE id = ${id}
|
|
||||||
RETURNING id, name, url
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db`NOTIFY emojis_updated, '{}'`;
|
|
||||||
return sendJson(res, { success: true, emoji: updated[0] });
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === '23505') {
|
|
||||||
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
|
||||||
}
|
|
||||||
console.error('[EMOJI EDIT ERROR]', err);
|
|
||||||
return sendJson(res, { success: false, message: err.message }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -269,9 +269,6 @@ export const handleHallUpdate = async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/v2/admin/halls — create a new hall
|
// POST /api/v2/admin/halls — create a new hall
|
||||||
export const handleHallCreate = async (req, res) => {
|
export const handleHallCreate = async (req, res) => {
|
||||||
const session = await lookupSession(req);
|
|
||||||
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
|
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
|
||||||
if (!token || token !== session.csrf_token) {
|
if (!token || token !== session.csrf_token) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user