Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a6e0f44f3 | |||
| 1eef246bc1 | |||
| 64508b555a | |||
| 9377f05454 | |||
| 162a6bc0fc | |||
| 2fdbb1276a | |||
| dac84a6328 | |||
| 794e21c66f | |||
| 11a4535ca9 | |||
| 3359e9f0d8 | |||
| 5c25182f6a | |||
| c644f7fa2a | |||
| e793b50348 | |||
| 53417063e4 | |||
| d6e273a065 | |||
| 0b2ece9447 | |||
| 63ea313742 | |||
| 619a6f9557 | |||
| 39646aa8c5 | |||
| 3e4f8c80b2 | |||
| d700ed1aa5 | |||
| 8b5da35341 | |||
| 3d1579eed9 | |||
| ef44808210 | |||
| 5ebc3adff9 | |||
| fcf55b0156 | |||
| febf0f9ae5 | |||
| 6fb5cc5c35 | |||
| 972371e7c8 | |||
| 7dbd7fe270 | |||
| 57d41913d1 | |||
| d872e7bae3 | |||
| 4a2bd0bd9a | |||
| 82298681af | |||
| 22db479bc2 | |||
| 9ec07e8e6c | |||
| 402a043b57 | |||
| 9f07dae200 | |||
| 23cbc689fb | |||
| b710f606ec | |||
| c92306867f | |||
| 97604bda70 | |||
| 3f7958f869 | |||
| ebd7855f15 | |||
| fb3cbc76ec | |||
| 2865a2d4e7 | |||
| f074d82bf6 | |||
| 07a874ec99 | |||
| e894795fe0 | |||
| 2e53e7e173 | |||
| 8cdd4fa69a | |||
| 66386213a1 | |||
| 06dd14712d | |||
| 3e8c7c03ee | |||
| cb1b91c4c9 | |||
| 665924c84e | |||
| 22dc1cd25b | |||
| a8a093450f | |||
| 3e20394c85 | |||
| f1611be50a | |||
| 2f9e0f19e4 | |||
| ff735f49fa | |||
| 6e01e6e36f | |||
| 40ce0ccbb1 | |||
| b9c7e24757 | |||
| 9ae1d55521 | |||
| 98ee76f61c | |||
| b10ddac6f3 | |||
| 3179d15b42 | |||
| 6dd70a0fc2 | |||
| 6ab0505e7e | |||
| 162f1b40d0 | |||
| 9d2b0137da | |||
| e179eb1955 | |||
| a80e6dab96 | |||
| c22ae5bde0 | |||
| c9f5dc02fa | |||
| 4fd15ec338 | |||
| feb408338a | |||
| c7414e4b21 | |||
| 1262b15a5b | |||
| 2cd8bd91ff | |||
| 7b186dcdb2 | |||
| 973753d231 | |||
| c30b3535ae | |||
| eed8b993e1 | |||
| 9c239769e6 | |||
| 6e07194b27 | |||
| 866a8003fe | |||
| 5ab1a9614a | |||
| e45df6a769 | |||
| 99eb9735a1 | |||
| b651621cee | |||
| d3ce2e38f0 | |||
| 096786516e | |||
| ec895a285b | |||
| 20aaf183d8 | |||
| 25ab4ba949 | |||
| 6a52445987 | |||
| 63c39ebe90 | |||
| 0a21b6c8f8 | |||
| a7aabac956 | |||
| 9f5510bc04 | |||
| 54e2e8871b | |||
| c99f3e11d8 | |||
| d14feaf3eb | |||
| 5e35fd48d4 | |||
| a07c21f22b | |||
| 65fb96f675 | |||
| 20a6ffcb04 | |||
| 8518e87c8d | |||
| a3148848f2 | |||
| abedf7a789 | |||
| e61e24dfe8 | |||
| 44af6ae061 | |||
| 0739d72334 | |||
| 848055446a | |||
| 88e6b6efb9 | |||
| eea70ed62e | |||
| b6c906636c | |||
| 55b5a0ba9f | |||
| 5d60d80fc9 | |||
| 4dfb80b149 | |||
| b319f21cd4 | |||
| 28ed8d985c | |||
| 0f5e795793 | |||
| 78d14a1b56 | |||
| 3f149531da | |||
| 7a4432277d | |||
| 84eee7e13d | |||
| 2206b3eb86 | |||
| 5529724982 | |||
| 9c6070afff | |||
| 5bfa336773 | |||
| 517444f9ce | |||
| edb92ffa2a |
@@ -1,9 +1,9 @@
|
||||
POSTGRES_USER=f0ckm
|
||||
POSTGRES_DB=f0ckm
|
||||
POSTGRES_PASSWORD=f0ckm
|
||||
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
||||
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
||||
# COMPOSE_PROFILES=f0ckm-nginx
|
||||
# --- Nginx & Tor Profiles (Optional) ---
|
||||
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
|
||||
# COMPOSE_PROFILES=tor
|
||||
#
|
||||
# VIRTUAL_HOST=yourdomain.com
|
||||
# LETSENCRYPT_HOST=yourdomain.com
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ public/tag_cache
|
||||
config.json
|
||||
bundle.css
|
||||
public/s/fonts/impact.woff
|
||||
f0ck-svelte/*
|
||||
20
README.md
20
README.md
@@ -78,3 +78,23 @@ now visit http://localhost:1337 in your browser, you can develop without needing
|
||||
## NGINX
|
||||
|
||||
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
||||
|
||||
## Tor Hidden Service (.onion)
|
||||
|
||||
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
|
||||
|
||||
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
|
||||
|
||||
```bash
|
||||
cat ./f0ckm-data/tor/f0ckm_hs/hostname
|
||||
```
|
||||
|
||||
### Automatic Onion-Location Header
|
||||
|
||||
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
|
||||
|
||||
```json
|
||||
"main": {
|
||||
"onion": "http://yourgeneratedaddress.onion"
|
||||
}
|
||||
```
|
||||
|
||||
11
build.bash
11
build.bash
@@ -16,7 +16,7 @@ fi
|
||||
# Parse arguments
|
||||
TARGET="master"
|
||||
CACHE_FLAG=""
|
||||
BRANCH_TARGET=""
|
||||
CUSTOM_BRANCH=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
@@ -27,8 +27,8 @@ for arg in "$@"; do
|
||||
CACHE_FLAG="--no-cache"
|
||||
;;
|
||||
*)
|
||||
if [ -z "$BRANCH_TARGET" ]; then
|
||||
BRANCH_TARGET=$arg
|
||||
if [ -z "$CUSTOM_BRANCH" ]; then
|
||||
CUSTOM_BRANCH=$arg
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -43,7 +43,10 @@ fi
|
||||
# Set Image name and branch defaults
|
||||
IMAGE_NAME="f0ckm"
|
||||
|
||||
if [ "$TARGET" == "master" ]; then
|
||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
||||
BRANCH_TARGET="$CUSTOM_BRANCH"
|
||||
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
|
||||
elif [ "$TARGET" == "master" ]; then
|
||||
echo "--- PRODUCTION DEPLOYMENT (master) ---"
|
||||
BRANCH_TARGET="master"
|
||||
elif [ "$TARGET" == "stg" ]; then
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
client_max_body_size 10000M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
|
||||
9
config/tor/torrc
Normal file
9
config/tor/torrc
Normal file
@@ -0,0 +1,9 @@
|
||||
# Tor configuration for f0ckm
|
||||
|
||||
# Enable SOCKS proxy for internal container & external connections
|
||||
SocksPort 0.0.0.0:9050
|
||||
|
||||
# Tor Hidden Service (v3 Onion Service) configuration
|
||||
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
|
||||
HiddenServiceDir /var/lib/tor/f0ckm_hs/
|
||||
HiddenServicePort 80 f0ckm:1337
|
||||
@@ -5,6 +5,7 @@
|
||||
"domain": "example.com",
|
||||
"regex": "example\\.com"
|
||||
},
|
||||
"onion": "http://your-onion-address.onion",
|
||||
"socks": "socks5://127.0.0.1:9050",
|
||||
"mail": "admin@example.com",
|
||||
"maxfilesize": 104857600,
|
||||
@@ -28,6 +29,11 @@
|
||||
],
|
||||
"enable_pdf": false,
|
||||
"enable_nsfl": false,
|
||||
"enable_private_uploads": true,
|
||||
"enable_expiring_uploads": true,
|
||||
"default_upload_visibility": 0,
|
||||
"allow_user_upload_visibility": true,
|
||||
"enable_item_slugs": true,
|
||||
"nsfl_tag_id": 4,
|
||||
"allowedMimes": [
|
||||
"audio",
|
||||
@@ -61,6 +67,7 @@
|
||||
"show_koepfe": false,
|
||||
"hide_sidebar_default": true,
|
||||
"koepfe": [],
|
||||
"enable_tor_hs": true,
|
||||
"enable_global_chat": true,
|
||||
"enable_danmaku": true,
|
||||
"private_messages": true,
|
||||
@@ -121,7 +128,10 @@
|
||||
"enable_profile_description": true,
|
||||
"user_alternative_infobox": false,
|
||||
"user_banner_enabled": true,
|
||||
"comment_banner_enabled": true,
|
||||
"comment_banner_max_height": 300,
|
||||
"user_alternative_steuerung": false,
|
||||
"expose_repost_links_to_guests": false,
|
||||
"enable_swf": false,
|
||||
"swf_thumb": "/s/img/swf.png",
|
||||
"enable_archive": true,
|
||||
@@ -132,6 +142,8 @@
|
||||
"open_registration_require_mail_andor_token": false,
|
||||
"private_society": false,
|
||||
"private_society_gate": "cloudflare",
|
||||
"private_society_gate_location": "Frankfurt",
|
||||
"private_society_gate_template": "_gate_template",
|
||||
"public_nsfw": false,
|
||||
"paths": {
|
||||
"images": "/b",
|
||||
|
||||
@@ -31,6 +31,8 @@ services:
|
||||
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
|
||||
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
|
||||
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
|
||||
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
|
||||
environment:
|
||||
@@ -39,6 +41,12 @@ services:
|
||||
- VIRTUAL_PORT=1337
|
||||
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
|
||||
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
|
||||
- DB_HOST=f0ckm-db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${POSTGRES_USER:-f0ckm}
|
||||
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
|
||||
- DB_NAME=${POSTGRES_DB:-f0ckm}
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "1337:1337"
|
||||
restart: unless-stopped
|
||||
@@ -49,9 +57,14 @@ services:
|
||||
tor:
|
||||
image: dockurr/tor
|
||||
container_name: tor
|
||||
profiles:
|
||||
- tor
|
||||
ports:
|
||||
- "9050:9050"
|
||||
- "8118:8118"
|
||||
- "127.0.0.1:9050:9050"
|
||||
- "127.0.0.1:8118:8118"
|
||||
volumes:
|
||||
- ./config/tor:/etc/tor:ro
|
||||
- ./f0ckm-data/tor:/var/lib/tor:Z
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: always
|
||||
@@ -97,19 +110,17 @@ services:
|
||||
f0ckm-nginx:
|
||||
image: nginxproxy/nginx-proxy:latest
|
||||
container_name: f0ckm-nginx
|
||||
network_mode: "host"
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
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:
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
0
f0ckm-data/koepfe/.gitkeep
Normal file
0
f0ckm-data/koepfe/.gitkeep
Normal file
3
migrations/add_expiring_uploads.sql
Normal file
3
migrations/add_expiring_uploads.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Migration: Add expires_at column to items table for expiring uploads
|
||||
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;
|
||||
5
migrations/add_favorites_private.sql
Normal file
5
migrations/add_favorites_private.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add favorites_private column to user_options table
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';
|
||||
5
migrations/add_hide_fav_badge.sql
Normal file
5
migrations/add_hide_fav_badge.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add hide_fav_badge column to user_options table
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';
|
||||
38
migrations/add_private_unlisted_uploads.sql
Normal file
38
migrations/add_private_unlisted_uploads.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Add visibility and slug columns to items table
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
|
||||
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
|
||||
|
||||
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||
|
||||
-- Populate missing slugs for existing items
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
new_slug text;
|
||||
i integer;
|
||||
BEGIN
|
||||
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
|
||||
LOOP
|
||||
new_slug := '';
|
||||
FOR i IN 1..11 LOOP
|
||||
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
|
||||
END LOOP;
|
||||
BEGIN
|
||||
UPDATE items SET slug = new_slug WHERE id = r.id;
|
||||
EXIT;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
END;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Fix any items with NULL visibility (should default to 0 = public)
|
||||
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
|
||||
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;
|
||||
File diff suppressed because it is too large
Load Diff
2
migrations/fix_null_visibility.sql
Normal file
2
migrations/fix_null_visibility.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Fix items with NULL visibility (should default to 0 = public)
|
||||
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -75,9 +75,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -492,9 +492,9 @@
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"build": "node scripts/build-css.mjs",
|
||||
"copy-vendor": "node scripts/copy-vendor.mjs",
|
||||
"seed": "node scripts/seed.mjs",
|
||||
"create-admin": "node scripts/create-admin.mjs"
|
||||
"create-admin": "node scripts/create-admin.mjs",
|
||||
"import": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/import.mjs"
|
||||
},
|
||||
"author": "Kibi Kelburton",
|
||||
"license": "MIT",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,9 @@
|
||||
/* Meme Creator Styles */
|
||||
|
||||
.meme-creator-container {
|
||||
box-sizing: border-box !important;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
@@ -111,6 +112,7 @@
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
max-width: 650px;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
background: #000;
|
||||
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||
@@ -238,22 +240,49 @@ canvas#memeCanvas {
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
|
||||
/* Mobile Stacking - Simple 2-Row Layout */
|
||||
/* Mobile Stacking - Clean Flexbox Column Layout */
|
||||
@media (max-width: 950px) {
|
||||
.meme-editor-layout {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 0.6fr auto !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 20px;
|
||||
}
|
||||
.meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
grid-row: 2;
|
||||
max-width: 500px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
grid-row: 1;
|
||||
max-width: 650px !important;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
|
||||
This prevents the canvas from shrinking too much when the sidebar takes space. */
|
||||
@media (max-width: 1250px) {
|
||||
body:not(.sidebar-right-hidden) .meme-editor-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 20px;
|
||||
}
|
||||
body:not(.sidebar-right-hidden) .meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 500px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
body:not(.sidebar-right-hidden) .canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 650px !important;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
@@ -294,20 +323,19 @@ canvas#memeCanvas {
|
||||
|
||||
/* Sidebar space reservation for meme pages */
|
||||
.meme-layout-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100%;
|
||||
transition: padding-right 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
@media (min-width: 1000px) {
|
||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||
.meme-layout-wrapper {
|
||||
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
|
||||
padding-right: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapse reserved space when sidebar is hidden */
|
||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||
padding-right: 0;
|
||||
}
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -362,6 +362,8 @@
|
||||
}
|
||||
|
||||
.upload-form.shitpost-mode-active .global-rating-section,
|
||||
.upload-form.shitpost-mode-active .global-visibility-section,
|
||||
.upload-form.shitpost-mode-active .global-expiry-section,
|
||||
.upload-form.shitpost-mode-active .global-comment-section,
|
||||
.upload-form.shitpost-mode-active .global-tag-section {
|
||||
display: none !important;
|
||||
@@ -419,6 +421,26 @@
|
||||
border-color: var(--badge-nsfl);
|
||||
}
|
||||
|
||||
/* Visibility Container - Only checked option is colored, unchecked options are gray */
|
||||
.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw {
|
||||
background: rgba(81, 207, 102, 0.2);
|
||||
color: #51cf66;
|
||||
border-color: rgba(81, 207, 102, 0.5);
|
||||
}
|
||||
|
||||
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw {
|
||||
background: rgba(255, 187, 51, 0.2);
|
||||
color: #ffbb33;
|
||||
border-color: rgba(255, 187, 51, 0.5);
|
||||
}
|
||||
|
||||
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff4444;
|
||||
border-color: rgba(255, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.item-rating-label:hover {
|
||||
@@ -662,6 +684,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
|
||||
background: rgba(81, 207, 102, 0.2);
|
||||
border-color: #51cf66;
|
||||
color: #51cf66;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
|
||||
background: rgba(255, 187, 51, 0.2);
|
||||
border-color: #ffbb33;
|
||||
color: #ffbb33;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* Tags */
|
||||
.tag-input-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
5
public/s/img/ghost_fav.svg
Normal file
5
public/s/img/ghost_fav.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#242526"/>
|
||||
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
|
||||
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -1,18 +1,24 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
const favoEl = document.querySelector("#a_favo");
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
|
||||
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
||||
poster: (usernameEl?.dataset?.username || '').trim() || null,
|
||||
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -180,6 +186,9 @@
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class CommentSystem {
|
||||
document.body.classList.remove('sidebar-left-hidden');
|
||||
const layout = this.container.closest('.item-layout-container');
|
||||
if (layout) layout.classList.remove('sidebar-hidden');
|
||||
} else if (isHidden) {
|
||||
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
|
||||
this.container.classList.add('faded-out');
|
||||
this.container.style.display = 'none';
|
||||
document.body.classList.add('sidebar-left-hidden');
|
||||
@@ -468,9 +468,15 @@ class CommentSystem {
|
||||
// 2. Check for duplicates (if we just posted it ourselves via optimistic insert).
|
||||
// Even on early return, ensure the button is present if body was truncated.
|
||||
if (document.getElementById('c' + data.id)) {
|
||||
const el = document.getElementById('c' + data.id);
|
||||
if (el && data.banner_file && data.banner_file !== 'null') {
|
||||
el.style.setProperty('--author-banner', `url('/a/${data.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', data.banner_position === 'center' ? 'center top' : (data.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (data.banner_size && data.banner_size !== 'cover') ? data.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
if (data.body && data.body.endsWith('\u2026')) {
|
||||
console.log('[handleLiveComment] duplicate+truncated, ensuring button for', data.id);
|
||||
const el = document.getElementById('c' + data.id);
|
||||
const contentEl = el?.querySelector('.comment-content');
|
||||
if (contentEl && !contentEl.querySelector('.load-full-comment-btn')) {
|
||||
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
|
||||
@@ -507,6 +513,10 @@ class CommentSystem {
|
||||
avatar: data.avatar,
|
||||
avatar_file: data.avatar_file,
|
||||
username_color: data.username_color,
|
||||
banner_file: data.banner_file || null,
|
||||
banner_position: data.banner_position || null,
|
||||
banner_size: data.banner_size || null,
|
||||
banner_repeat: data.banner_repeat || null,
|
||||
video_time: data.video_time ?? null,
|
||||
is_new: true,
|
||||
is_deleted: false,
|
||||
@@ -593,10 +603,23 @@ class CommentSystem {
|
||||
// Update in-memory data so future reconciles use the full content
|
||||
if (this.lastData) {
|
||||
const cached = this.lastData.find(c => String(c.id) === String(commentId));
|
||||
if (cached) cached.content = fullContent;
|
||||
if (cached) {
|
||||
cached.content = fullContent;
|
||||
if (json.comment.banner_file) cached.banner_file = json.comment.banner_file;
|
||||
if (json.comment.banner_position) cached.banner_position = json.comment.banner_position;
|
||||
if (json.comment.banner_size) cached.banner_size = json.comment.banner_size;
|
||||
if (json.comment.banner_repeat) cached.banner_repeat = json.comment.banner_repeat;
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.dataset.raw = this.escapeHtml(fullContent);
|
||||
if (json.comment && json.comment.banner_file) {
|
||||
el.style.setProperty('--author-banner', `url('/a/${json.comment.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', json.comment.banner_position === 'center' ? 'center top' : (json.comment.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (json.comment.banner_size && json.comment.banner_size !== 'cover') ? json.comment.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
|
||||
contentEl.dataset.raw = fullContent;
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
@@ -622,6 +645,7 @@ class CommentSystem {
|
||||
|
||||
const contentEl = el.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
contentEl.dataset.raw = data.content;
|
||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
|
||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
@@ -857,26 +881,14 @@ class CommentSystem {
|
||||
* border-right) is fully restored after the effect completes.
|
||||
*
|
||||
* @param {Element} el - The .comment element to animate.
|
||||
* @param {boolean} entering - True to also add 'comment-entering' (own post);
|
||||
* false for 'new-item-fade' only (live others).
|
||||
* @param {boolean} entering - Unused (kept for compatibility).
|
||||
*/
|
||||
static _animateNewComment(el, entering = false) {
|
||||
if (!el) return;
|
||||
|
||||
const classes = entering
|
||||
? ['comment-entering', 'new-item-fade']
|
||||
: ['new-item-fade'];
|
||||
|
||||
// Reset in case the element already has a stale animation
|
||||
classes.forEach(c => el.classList.remove(c));
|
||||
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
|
||||
|
||||
classes.forEach(c => el.classList.add(c));
|
||||
|
||||
const cleanup = () => {
|
||||
classes.forEach(c => el.classList.remove(c));
|
||||
};
|
||||
el.addEventListener('animationend', cleanup, { once: true });
|
||||
el.classList.add('new-item-fade');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('new-item-fade');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
|
||||
@@ -892,7 +904,6 @@ class CommentSystem {
|
||||
if (el) {
|
||||
// Remove highlight from any previously highlighted or newly-posted comment
|
||||
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
|
||||
document.querySelectorAll('.comment-entering').forEach(c => c.classList.remove('comment-entering'));
|
||||
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
|
||||
|
||||
// Always smooth-scroll so there's no jarring jump
|
||||
@@ -1038,7 +1049,7 @@ class CommentSystem {
|
||||
preview.dataset.id = targetId;
|
||||
|
||||
// Remove temporary animation/highlight classes
|
||||
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
|
||||
preview.classList.remove('new-item-fade', 'comment-highlighted');
|
||||
|
||||
// Remove input forms or action buttons
|
||||
const actions = preview.querySelector('.comment-actions');
|
||||
@@ -1051,10 +1062,6 @@ class CommentSystem {
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.zIndex = (100000 + level).toString();
|
||||
|
||||
// Try to place it to the right of the link, or top/bottom if needed
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
document.body.appendChild(preview);
|
||||
CommentSystem.playEmojiVideos(preview);
|
||||
|
||||
@@ -1079,16 +1086,28 @@ class CommentSystem {
|
||||
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
|
||||
// Boundary checks
|
||||
if (left + previewRect.width > window.innerWidth) {
|
||||
// Default: position preview to the right of the link
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
// If there's NOT enough space on the right, open to the left
|
||||
if (left + previewRect.width > window.innerWidth - 10) {
|
||||
left = rect.left - previewRect.width - 10;
|
||||
}
|
||||
if (left < 0) left = 10;
|
||||
|
||||
if (top + previewRect.height > window.innerHeight) {
|
||||
// Clamp to screen boundaries if left/right are both tight
|
||||
if (left < 10) {
|
||||
left = 10;
|
||||
}
|
||||
if (left + previewRect.width > window.innerWidth - 10) {
|
||||
left = Math.max(10, window.innerWidth - previewRect.width - 10);
|
||||
}
|
||||
|
||||
// Clamp top & bottom boundary
|
||||
if (top + previewRect.height > window.innerHeight - 10) {
|
||||
top = window.innerHeight - previewRect.height - 10;
|
||||
}
|
||||
if (top < 0) top = 10;
|
||||
if (top < 10) top = 10;
|
||||
|
||||
preview.style.left = left + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
@@ -1152,6 +1171,7 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
quoteComment(id, openerEl, body) {
|
||||
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
|
||||
// If no reply input open anywhere, open the local one
|
||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||
let isNew = false;
|
||||
@@ -1172,12 +1192,13 @@ class CommentSystem {
|
||||
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
||||
const contentEl = body.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
const LINE_MAX = 200;
|
||||
const rawText = (contentEl.dataset.raw || '').trim();
|
||||
// Preserve all lines but cap any single line exceeding LINE_MAX chars
|
||||
const lines = rawText.split('\n').map(line =>
|
||||
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
|
||||
);
|
||||
let rawText = (contentEl.dataset.raw || '').trim();
|
||||
if (rawText.includes('>') || rawText.includes('<') || rawText.includes('&')) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = rawText;
|
||||
rawText = txt.value;
|
||||
}
|
||||
const lines = rawText.split('\n');
|
||||
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
if (isNew) {
|
||||
@@ -1512,6 +1533,13 @@ class CommentSystem {
|
||||
el.classList.toggle('pinned', !!incoming.is_pinned);
|
||||
const pinIcon = el.querySelector('.pin-icon');
|
||||
if (pinIcon) pinIcon.style.display = incoming.is_pinned ? 'block' : 'none';
|
||||
|
||||
if (incoming && incoming.banner_file && incoming.banner_file !== 'null') {
|
||||
el.style.setProperty('--author-banner', `url('/a/${incoming.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', incoming.banner_position === 'center' ? 'center top' : (incoming.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (incoming.banner_size && incoming.banner_size !== 'cover') ? incoming.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1632,18 +1660,26 @@ class CommentSystem {
|
||||
syncLevel(roots, list, false);
|
||||
}
|
||||
|
||||
// Maximum characters to fully render in the item view per comment
|
||||
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
|
||||
// Maximum characters & lines to fully render in the item view per comment
|
||||
static get ITEM_VIEW_MAX_CHARS() { return 500; }
|
||||
static get ITEM_VIEW_MAX_LINES() { return 6; }
|
||||
|
||||
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
||||
if (!content) return '';
|
||||
|
||||
// Truncate extremely long comments before any processing
|
||||
// Truncate long comments before any processing
|
||||
let truncated = false;
|
||||
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
|
||||
if (!bypassTruncation) {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
|
||||
truncated = true;
|
||||
}
|
||||
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
console.warn('Marked.js not loaded, falling back to plain text');
|
||||
@@ -1791,9 +1827,10 @@ class CommentSystem {
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||
const renderedContent = quoteEmojis
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${renderedContent}</span>`;
|
||||
}
|
||||
|
||||
@@ -2171,7 +2208,15 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}"><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
const bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${comment.banner_file}'); --author-banner-position: ${comment.banner_position === 'center' ? 'center top' : (comment.banner_position || 'center top')}; --author-banner-size: ${(comment.banner_size && comment.banner_size !== 'cover') ? comment.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null);
|
||||
const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null);
|
||||
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
@@ -2199,7 +2244,7 @@ class CommentSystem {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
renderCommentAttachments(files, content = '') {
|
||||
@@ -2407,10 +2452,50 @@ class CommentSystem {
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Global click listener to close popups (useful for mobile dismissal)
|
||||
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
|
||||
document.addEventListener('click', (e) => {
|
||||
const isLink = e.target.closest('.comment-context-link');
|
||||
const isPopup = e.target.closest('.comment-preview-popup');
|
||||
const target = e.target;
|
||||
|
||||
// Load full comment (expand truncated)
|
||||
const loadFullBtn = target.closest('.load-full-comment-btn');
|
||||
if (loadFullBtn) {
|
||||
const contentEl = loadFullBtn.closest('.comment-content');
|
||||
if (contentEl) {
|
||||
if (contentEl.querySelector('.collapse-comment-btn')) return;
|
||||
const commentEl = contentEl.closest('.comment');
|
||||
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
|
||||
contentEl.insertAdjacentHTML('beforeend',
|
||||
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
||||
);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse full comment back to truncated view
|
||||
const collapseBtn = target.closest('.collapse-comment-btn');
|
||||
if (collapseBtn) {
|
||||
const contentEl = collapseBtn.closest('.comment-content');
|
||||
if (contentEl) {
|
||||
if (contentEl.querySelector('.load-full-comment-btn')) return;
|
||||
const commentEl = contentEl.closest('.comment');
|
||||
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isLink = target.closest('.comment-context-link');
|
||||
const isPopup = target.closest('.comment-preview-popup');
|
||||
|
||||
if (!isLink && !isPopup) {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
@@ -2825,7 +2910,10 @@ class CommentSystem {
|
||||
if (loadFullBtn) {
|
||||
const contentEl = loadFullBtn.closest('.comment-content');
|
||||
if (contentEl) {
|
||||
const fullContent = contentEl.dataset.raw;
|
||||
if (contentEl.querySelector('.collapse-comment-btn')) return;
|
||||
const commentEl = contentEl.closest('.comment');
|
||||
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||
// Append "see less" button after full content
|
||||
@@ -2833,6 +2921,7 @@ class CommentSystem {
|
||||
contentEl.insertAdjacentHTML('beforeend',
|
||||
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
||||
);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2843,9 +2932,13 @@ class CommentSystem {
|
||||
if (collapseBtn) {
|
||||
const contentEl = collapseBtn.closest('.comment-content');
|
||||
if (contentEl) {
|
||||
const fullContent = contentEl.dataset.raw;
|
||||
if (contentEl.querySelector('.load-full-comment-btn')) return;
|
||||
const commentEl = contentEl.closest('.comment');
|
||||
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -3166,6 +3259,9 @@ class CommentSystem {
|
||||
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
|
||||
const el = target.closest('.comment-permalink, .comment-time');
|
||||
if (el) {
|
||||
if (!window.f0ckSession || !window.f0ckSession.logged_in) {
|
||||
return; // Let standard anchor link behavior scroll/jump to #c{id}
|
||||
}
|
||||
const id = el.dataset.id;
|
||||
const commentEl = el.closest('[id^="c"]');
|
||||
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
||||
@@ -3336,9 +3432,15 @@ class CommentSystem {
|
||||
params.append('has_poll', '1');
|
||||
}
|
||||
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
if (csrfToken) params.append('csrf_token', csrfToken);
|
||||
|
||||
const res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
@@ -3438,6 +3540,50 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedBannerFile = (json.comment?.banner_file && json.comment.banner_file !== 'null') ? json.comment.banner_file : ((session.banner_file && session.banner_file !== 'null') ? session.banner_file : null);
|
||||
let resolvedBannerPosition = (json.comment?.banner_position && json.comment.banner_position !== 'null') ? json.comment.banner_position : ((session.banner_position && session.banner_position !== 'null') ? session.banner_position : null);
|
||||
let resolvedBannerSize = (json.comment?.banner_size && json.comment.banner_size !== 'null') ? json.comment.banner_size : ((session.banner_size && session.banner_size !== 'null') ? session.banner_size : null);
|
||||
let resolvedBannerRepeat = (json.comment?.banner_repeat && json.comment.banner_repeat !== 'null') ? json.comment.banner_repeat : ((session.banner_repeat && session.banner_repeat !== 'null') ? session.banner_repeat : null);
|
||||
|
||||
if (!resolvedBannerFile && this.lastData && currentUsername) {
|
||||
const existingByMe = this.lastData.find(c =>
|
||||
c.username && c.username.toLowerCase() === currentUsername.toLowerCase() && c.banner_file && c.banner_file !== 'null'
|
||||
);
|
||||
if (existingByMe) {
|
||||
resolvedBannerFile = existingByMe.banner_file || null;
|
||||
resolvedBannerPosition = existingByMe.banner_position || null;
|
||||
resolvedBannerSize = existingByMe.banner_size || null;
|
||||
resolvedBannerRepeat = existingByMe.banner_repeat || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBannerFile && currentUsername) {
|
||||
const existingCommentEl = document.querySelector(`.comment-author[href="/user/${currentUsername}"], .user-infobox-block[style*="--author-banner"]`);
|
||||
if (existingCommentEl) {
|
||||
const commentDiv = existingCommentEl.closest('.comment') || existingCommentEl.closest('.user-infobox-block');
|
||||
if (commentDiv) {
|
||||
const style = commentDiv.getAttribute('style') || '';
|
||||
const bannerMatch = style.match(/--author-banner:\s*url\(['"]?\/a\/([^'"]+)['"]?\)/);
|
||||
if (bannerMatch) {
|
||||
resolvedBannerFile = bannerMatch[1];
|
||||
const posMatch = style.match(/--author-banner-position:\s*([^;]+)/);
|
||||
if (posMatch) resolvedBannerPosition = posMatch[1].trim();
|
||||
const sizeMatch = style.match(/--author-banner-size:\s*([^;]+)/);
|
||||
if (sizeMatch) resolvedBannerSize = sizeMatch[1].trim();
|
||||
const repeatMatch = style.match(/--author-banner-repeat:\s*([^;]+)/);
|
||||
if (repeatMatch) resolvedBannerRepeat = repeatMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedBannerFile && window.f0ckSession) {
|
||||
window.f0ckSession.banner_file = resolvedBannerFile;
|
||||
if (resolvedBannerPosition) window.f0ckSession.banner_position = resolvedBannerPosition;
|
||||
if (resolvedBannerSize) window.f0ckSession.banner_size = resolvedBannerSize;
|
||||
if (resolvedBannerRepeat) window.f0ckSession.banner_repeat = resolvedBannerRepeat;
|
||||
}
|
||||
|
||||
const newComment = {
|
||||
id: json.comment.id,
|
||||
item_id: this.itemId,
|
||||
@@ -3446,11 +3592,15 @@ class CommentSystem {
|
||||
files: files,
|
||||
created_at: json.comment.created_at || new Date().toISOString(),
|
||||
username: currentUsername,
|
||||
user_id: session.id || null,
|
||||
user_id: (json.comment && json.comment.user_id) || session.id || session.user_id || null,
|
||||
display_name: session.display_name || null,
|
||||
avatar: resolvedAvatar,
|
||||
avatar_file: resolvedAvatarFile,
|
||||
username_color: session.username_color || null,
|
||||
username_color: (json.comment && json.comment.username_color) || session.username_color || null,
|
||||
banner_file: resolvedBannerFile,
|
||||
banner_position: resolvedBannerPosition,
|
||||
banner_size: resolvedBannerSize,
|
||||
banner_repeat: resolvedBannerRepeat,
|
||||
is_deleted: false,
|
||||
is_pinned: false,
|
||||
video_time: json.comment.video_time ?? null,
|
||||
@@ -3477,7 +3627,7 @@ class CommentSystem {
|
||||
if (!this.lastData) this.lastData = [];
|
||||
const existingInLastData = this.lastData.find(c => c.id === newComment.id);
|
||||
if (existingInLastData) {
|
||||
existingInLastData.files = files;
|
||||
Object.assign(existingInLastData, newComment);
|
||||
} else {
|
||||
if (this.sort === 'new') this.lastData.unshift(newComment);
|
||||
else this.lastData.push(newComment);
|
||||
@@ -3604,9 +3754,9 @@ class CommentSystem {
|
||||
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
|
||||
// optimistic DOM insert to guarantee the button regardless of rendering path.
|
||||
_ensureTruncationButton(commentEl, rawContent) {
|
||||
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
|
||||
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
console.log('[ensureBtn] below threshold, skipping');
|
||||
if (!rawContent) return;
|
||||
const lineCount = rawContent.split('\n').length;
|
||||
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||
return;
|
||||
}
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
@@ -3808,10 +3958,13 @@ class CommentSystem {
|
||||
toggleComments() {
|
||||
if (!this.container) return;
|
||||
|
||||
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
|
||||
if (document.body.classList.contains('layout-modern')) {
|
||||
if (window.innerWidth < 1000) return;
|
||||
if (typeof window.toggleSidebarLeft === 'function') {
|
||||
window.toggleSidebarLeft();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const layout = this.container.closest('.item-layout-container');
|
||||
const sidebar = this.container.closest('.item-sidebar-left');
|
||||
@@ -4457,20 +4610,71 @@ class CommentSystem {
|
||||
}, { passive: true });
|
||||
};
|
||||
|
||||
// Helper: reliable touch handling for tab buttons on mobile
|
||||
const addTabTouch = (btn, cb) => {
|
||||
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
||||
let lastHoldTime = 0;
|
||||
const addTabTouch = (btn, cb, onHold) => {
|
||||
let ts = null;
|
||||
let holdTimer = null;
|
||||
let holdFired = false;
|
||||
btn.addEventListener('touchstart', (e) => {
|
||||
holdFired = false;
|
||||
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
if (onHold) {
|
||||
holdTimer = setTimeout(() => {
|
||||
holdFired = true;
|
||||
lastHoldTime = Date.now();
|
||||
try { navigator.vibrate?.(40); } catch(err) {}
|
||||
onHold();
|
||||
}, 500);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, { passive: true });
|
||||
|
||||
btn.addEventListener('touchmove', (e) => {
|
||||
if (!ts) return;
|
||||
const dx = Math.abs(e.touches[0].clientX - ts.x);
|
||||
const dy = Math.abs(e.touches[0].clientY - ts.y);
|
||||
if (dx > 10 || dy > 10) {
|
||||
clearTimeout(holdTimer);
|
||||
ts = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
btn.addEventListener('touchend', (e) => {
|
||||
clearTimeout(holdTimer);
|
||||
if (holdFired) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
holdFired = false;
|
||||
ts = null;
|
||||
return;
|
||||
}
|
||||
if (!ts) return;
|
||||
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
||||
ts = null;
|
||||
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
||||
});
|
||||
|
||||
btn.addEventListener('touchcancel', () => {
|
||||
clearTimeout(holdTimer);
|
||||
holdFired = false;
|
||||
ts = null;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFavDefault = (tabEl) => {
|
||||
const KEY = 'f0ck_emoji_default_tab';
|
||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||
if (isDefault) {
|
||||
localStorage.removeItem(KEY);
|
||||
tabEl.classList.remove('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites unset as default tab');
|
||||
} else {
|
||||
localStorage.setItem(KEY, '__favs__');
|
||||
tabEl.classList.add('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites set as default tab');
|
||||
}
|
||||
};
|
||||
|
||||
// Build tab buttons — Favorites first
|
||||
@@ -4482,23 +4686,13 @@ class CommentSystem {
|
||||
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
|
||||
favTab.addEventListener('mousedown', e => e.preventDefault());
|
||||
favTab.addEventListener('click', () => showTab('__favs__'));
|
||||
addTabTouch(favTab, () => showTab('__favs__'));
|
||||
addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
|
||||
// Right-click on fav tab → set/unset as default
|
||||
favTab.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (navigator.maxTouchPoints > 0) return;
|
||||
const KEY = 'f0ck_emoji_default_tab';
|
||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||
if (isDefault) {
|
||||
localStorage.removeItem(KEY);
|
||||
favTab.classList.remove('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites unset as default tab');
|
||||
} else {
|
||||
localStorage.setItem(KEY, '__favs__');
|
||||
favTab.classList.add('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites set as default tab');
|
||||
}
|
||||
if (Date.now() - lastHoldTime < 1000) return;
|
||||
toggleFavDefault(favTab);
|
||||
});
|
||||
// Mark as default if already set
|
||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||
@@ -4659,3 +4853,44 @@ document.addEventListener('f0ck:contentLoaded', () => {
|
||||
|
||||
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
|
||||
// Looking at f0ck.js, it seems to just replace innerHTML.
|
||||
|
||||
(function initCommentHeightObserver() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const height = entry.contentRect ? entry.contentRect.height : entry.target.offsetHeight;
|
||||
if (height > 90) {
|
||||
entry.target.classList.add('tall-comment');
|
||||
} else {
|
||||
entry.target.classList.remove('tall-comment');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const observeAll = (root = document) => {
|
||||
root.querySelectorAll('.comment').forEach(el => observer.observe(el));
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => observeAll());
|
||||
} else {
|
||||
observeAll();
|
||||
}
|
||||
|
||||
if (window.MutationObserver) {
|
||||
const mutObs = new MutationObserver(mutations => {
|
||||
for (const m of mutations) {
|
||||
m.addedNodes.forEach(node => {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.classList && node.classList.contains('comment')) observer.observe(node);
|
||||
if (node.querySelectorAll) node.querySelectorAll('.comment').forEach(el => observer.observe(el));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
mutObs.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
|
||||
dmGridArea = document.createElement('div');
|
||||
dmGridArea.className = 'emoji-picker-grid';
|
||||
|
||||
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
||||
let lastDmHoldTime = 0;
|
||||
const addDmTabTouch = (btn, cb, onHold) => {
|
||||
let ts = null;
|
||||
let holdTimer = null;
|
||||
let holdFired = false;
|
||||
btn.addEventListener('touchstart', (e) => {
|
||||
holdFired = false;
|
||||
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
if (onHold) {
|
||||
holdTimer = setTimeout(() => {
|
||||
holdFired = true;
|
||||
lastDmHoldTime = Date.now();
|
||||
try { navigator.vibrate?.(40); } catch(err) {}
|
||||
onHold();
|
||||
}, 500);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, { passive: true });
|
||||
|
||||
btn.addEventListener('touchmove', (e) => {
|
||||
if (!ts) return;
|
||||
const dx = Math.abs(e.touches[0].clientX - ts.x);
|
||||
const dy = Math.abs(e.touches[0].clientY - ts.y);
|
||||
if (dx > 10 || dy > 10) {
|
||||
clearTimeout(holdTimer);
|
||||
ts = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
btn.addEventListener('touchend', (e) => {
|
||||
clearTimeout(holdTimer);
|
||||
if (holdFired) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
holdFired = false;
|
||||
ts = null;
|
||||
return;
|
||||
}
|
||||
if (!ts) return;
|
||||
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
||||
ts = null;
|
||||
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
||||
});
|
||||
|
||||
btn.addEventListener('touchcancel', () => {
|
||||
clearTimeout(holdTimer);
|
||||
holdFired = false;
|
||||
ts = null;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDmFavDefault = (tabEl) => {
|
||||
const KEY = 'f0ck_emoji_default_tab';
|
||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||
if (isDefault) {
|
||||
localStorage.removeItem(KEY);
|
||||
tabEl.classList.remove('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites unset as default tab');
|
||||
} else {
|
||||
localStorage.setItem(KEY, '__favs__');
|
||||
tabEl.classList.add('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites set as default tab');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Favorites tab ──
|
||||
const favsTab = document.createElement('button');
|
||||
favsTab.className = 'ep-tab ep-tab-favs';
|
||||
@@ -1985,22 +2052,13 @@ if (window.__dmLoaded) {
|
||||
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
|
||||
favsTab.addEventListener('mousedown', e => e.preventDefault());
|
||||
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
|
||||
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
|
||||
// Right-click on fav tab → set/unset as default
|
||||
favsTab.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (navigator.maxTouchPoints > 0) return;
|
||||
const KEY = 'f0ck_emoji_default_tab';
|
||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||
if (isDefault) {
|
||||
localStorage.removeItem(KEY);
|
||||
favsTab.classList.remove('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites unset as default tab');
|
||||
} else {
|
||||
localStorage.setItem(KEY, '__favs__');
|
||||
favsTab.classList.add('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites set as default tab');
|
||||
}
|
||||
if (Date.now() - lastDmHoldTime < 1000) return;
|
||||
toggleDmFavDefault(favsTab);
|
||||
});
|
||||
// Mark as default if already set
|
||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
|
||||
}
|
||||
tab.addEventListener('mousedown', e => e.preventDefault());
|
||||
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
|
||||
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
|
||||
dmTabBar.appendChild(tab);
|
||||
});
|
||||
|
||||
|
||||
@@ -1000,9 +1000,10 @@
|
||||
e.stopPropagation();
|
||||
const itemId = slide.dataset.id;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1157,10 +1158,14 @@
|
||||
if (nowFaved) flashFav(slide);
|
||||
}
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/togglefav', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `postid=${id}`
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
// Sync count to server truth (handles race conditions)
|
||||
@@ -1582,9 +1587,10 @@
|
||||
e.stopPropagation();
|
||||
const itemId = slide.dataset.id;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1850,11 +1856,12 @@
|
||||
else if (item.external_board === 'gif') rating = 'nsfw';
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'x-csrf-token': window.scrollerCsrf || ''
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: item.external_media_url || item.dest,
|
||||
@@ -1966,9 +1973,10 @@
|
||||
showShareToast(info.toast);
|
||||
}
|
||||
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||
method: 'PUT',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -2119,7 +2127,12 @@
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
if (raw.includes('>') || raw.includes('<') || raw.includes('&')) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = raw;
|
||||
raw = txt.value;
|
||||
}
|
||||
const lines = raw.split('\n');
|
||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
@@ -2787,11 +2800,16 @@
|
||||
if (!content || !commentsItemId || commentsPosting) return;
|
||||
commentsPosting = true; commentSendBtn.disabled = true;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
||||
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
||||
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
|
||||
const resp = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: postBody
|
||||
});
|
||||
const data = await resp.json();
|
||||
@@ -2899,10 +2917,14 @@
|
||||
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
||||
closeSugg();
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `tagname=${encodeURIComponent(tag)}`
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
@@ -3658,7 +3680,11 @@
|
||||
if (sMarkAll) {
|
||||
sMarkAll.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/notifications/read', { method: 'POST' });
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
await fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
updateScrollerNotifBadge(0);
|
||||
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
||||
updateScrollerTabBadges(sCachedNotifs);
|
||||
@@ -3673,7 +3699,12 @@
|
||||
if (!item) return;
|
||||
const nid = item.dataset.id;
|
||||
if (nid && item.classList.contains('unread')) {
|
||||
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/notifications/${nid}/read`, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
}).catch(() => {});
|
||||
item.classList.remove('unread');
|
||||
// Update cache
|
||||
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
||||
|
||||
@@ -137,10 +137,13 @@
|
||||
const statusDiv = document.getElementById('avatar-upload-status');
|
||||
const preview = document.getElementById('avatar-preview');
|
||||
|
||||
const showStatus = (msg, type) => {
|
||||
const showStatus = (msg, type = 'info') => {
|
||||
if (msg && window.flashMessage) {
|
||||
window.flashMessage(msg, 2500, type);
|
||||
}
|
||||
if (statusDiv) {
|
||||
statusDiv.textContent = msg;
|
||||
statusDiv.className = 'avatar-status ' + type;
|
||||
statusDiv.className = 'avatar-status ' + (type || '');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -177,6 +180,7 @@
|
||||
filenameSpan.textContent = file.name;
|
||||
uploadBtn.disabled = false;
|
||||
showStatus('', '');
|
||||
uploadBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1119,6 +1123,97 @@
|
||||
});
|
||||
}
|
||||
|
||||
const favsPrivateToggle = document.getElementById('favorites_private_toggle');
|
||||
if (favsPrivateToggle) {
|
||||
favsPrivateToggle.addEventListener('change', async () => {
|
||||
const favorites_private = favsPrivateToggle.checked;
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/favorites_private', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||
},
|
||||
body: new URLSearchParams({ favorites_private })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showStatus('Favorites privacy preference updated!', 'success');
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.favorites_private = favorites_private;
|
||||
}
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
favsPrivateToggle.checked = !favorites_private; // Revert
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update Favorites Privacy error:', err);
|
||||
alert('Connection error');
|
||||
favsPrivateToggle.checked = !favorites_private; // Revert
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hideFavBadgeToggle = document.getElementById('hide_fav_badge_toggle');
|
||||
if (hideFavBadgeToggle) {
|
||||
hideFavBadgeToggle.addEventListener('change', async function() {
|
||||
const hide_fav_badge = hideFavBadgeToggle.checked;
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/hide_fav_badge', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||
},
|
||||
body: new URLSearchParams({ hide_fav_badge })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showStatus('Hide favorite badge preference updated!', 'success');
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.hide_fav_badge = hide_fav_badge;
|
||||
}
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
hideFavBadgeToggle.checked = !hide_fav_badge;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update Hide Fav Badge error:', err);
|
||||
alert('Connection error');
|
||||
hideFavBadgeToggle.checked = !hide_fav_badge;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select');
|
||||
if (defaultUploadVisSelect) {
|
||||
defaultUploadVisSelect.addEventListener('change', async function() {
|
||||
const vis = parseInt(defaultUploadVisSelect.value, 10);
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/default_upload_visibility', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||
},
|
||||
body: new URLSearchParams({ default_upload_visibility: vis })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showStatus('Default upload visibility updated!', 'success');
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.default_upload_visibility = vis;
|
||||
}
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update Default Upload Visibility error:', err);
|
||||
alert('Connection error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New Dual Column Layout Toggle
|
||||
const layoutToggle = document.getElementById('use_new_layout_toggle');
|
||||
if (layoutToggle) {
|
||||
@@ -1583,8 +1678,11 @@
|
||||
|
||||
// Two-way sync: swatch → text input
|
||||
if (colorPicker && colorHex) {
|
||||
const colorBadge = document.getElementById('color-picker-badge');
|
||||
|
||||
colorPicker.addEventListener('input', () => {
|
||||
colorHex.value = colorPicker.value;
|
||||
if (colorBadge) colorBadge.style.background = colorPicker.value;
|
||||
const liveBox = document.getElementById('live-profile-preview-box');
|
||||
if (liveBox) {
|
||||
liveBox.style.setProperty('--author-accent', colorPicker.value);
|
||||
@@ -1592,6 +1690,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
colorPicker.addEventListener('change', () => {
|
||||
if (colorBadge) colorBadge.style.background = colorPicker.value;
|
||||
if (saveColorBtn) saveColorBtn.click();
|
||||
});
|
||||
|
||||
// Text input → swatch (validate on each keystroke)
|
||||
colorHex.addEventListener('input', () => {
|
||||
const val = colorHex.value.trim();
|
||||
@@ -1761,7 +1864,9 @@
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await applyFontLive(font);
|
||||
showStatus('Website font updated!', 'success');
|
||||
if (window.flashMessage) {
|
||||
window.flashMessage('Website font updated!', 2500, 'success');
|
||||
}
|
||||
} else {
|
||||
alert(data.msg || 'Error saving font preference');
|
||||
}
|
||||
@@ -1777,6 +1882,35 @@
|
||||
const saveDescriptionBtn = document.getElementById('btn-save-description');
|
||||
const descriptionStatus = document.getElementById('description-status');
|
||||
|
||||
const liveDesc = document.getElementById('live-preview-description');
|
||||
const descControls = document.getElementById('desc-edit-controls');
|
||||
|
||||
if (liveDesc && descriptionTextarea) {
|
||||
liveDesc.addEventListener('focus', () => {
|
||||
if (descControls) descControls.style.display = 'flex';
|
||||
});
|
||||
|
||||
liveDesc.addEventListener('input', () => {
|
||||
descriptionTextarea.value = liveDesc.innerText.trim();
|
||||
});
|
||||
|
||||
liveDesc.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
liveDesc.blur();
|
||||
if (saveDescriptionBtn) saveDescriptionBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptionTextarea) {
|
||||
descriptionTextarea.addEventListener('input', () => {
|
||||
if (liveDesc && document.activeElement !== liveDesc) {
|
||||
liveDesc.textContent = descriptionTextarea.value || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (saveDescriptionBtn && descriptionTextarea) {
|
||||
saveDescriptionBtn.addEventListener('click', async () => {
|
||||
const description = descriptionTextarea.value;
|
||||
@@ -1857,10 +1991,13 @@
|
||||
const emailStatus = document.getElementById('email-status');
|
||||
const displayEmail = document.getElementById('display-email');
|
||||
|
||||
const showAccountStatus = (el, msg, type) => {
|
||||
const showAccountStatus = (el, msg, type = 'info') => {
|
||||
if (msg && window.flashMessage) {
|
||||
window.flashMessage(msg, 2500, type);
|
||||
}
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.className = 'avatar-status ' + type;
|
||||
el.className = 'avatar-status ' + (type || '');
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { el.textContent = ''; el.className = 'avatar-status'; }, 5000);
|
||||
}
|
||||
@@ -1946,11 +2083,36 @@
|
||||
const displayNameBtn = document.getElementById('btn-update-display-name');
|
||||
const displayNameInput = document.getElementById('display_name_input');
|
||||
const displayNameStatus = document.getElementById('display-name-status');
|
||||
const liveUsernameEl = document.getElementById('live-preview-username');
|
||||
|
||||
if (liveUsernameEl && displayNameInput && displayNameBtn) {
|
||||
liveUsernameEl.addEventListener('input', () => {
|
||||
displayNameInput.value = liveUsernameEl.textContent.trim();
|
||||
});
|
||||
|
||||
liveUsernameEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
liveUsernameEl.blur();
|
||||
}
|
||||
});
|
||||
|
||||
liveUsernameEl.addEventListener('blur', () => {
|
||||
const val = liveUsernameEl.textContent.trim();
|
||||
if (!val) {
|
||||
liveUsernameEl.textContent = window.f0ckSession?.user || 'Username';
|
||||
displayNameInput.value = window.f0ckSession?.user || '';
|
||||
} else {
|
||||
displayNameInput.value = val;
|
||||
}
|
||||
displayNameBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (displayNameBtn && displayNameInput) {
|
||||
displayNameInput.addEventListener('input', () => {
|
||||
const liveName = document.getElementById('live-preview-username');
|
||||
if (liveName) {
|
||||
liveName.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||
if (liveUsernameEl && document.activeElement !== liveUsernameEl) {
|
||||
liveUsernameEl.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const playSidebarEmojiVideos = (container) => {
|
||||
@@ -153,12 +153,6 @@
|
||||
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
||||
if (!content) return '';
|
||||
|
||||
// Truncate extremely long comments before any processing to keep the sidebar
|
||||
// fast and the DOM lean, regardless of markdown / regex complexity.
|
||||
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
|
||||
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
return escapeHtml(content)
|
||||
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||
@@ -268,9 +262,10 @@
|
||||
// Manual greentext handling — apply emoji if the user preference allows it
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||
const rendered = quoteEmojis
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
}
|
||||
|
||||
@@ -484,8 +479,9 @@
|
||||
};
|
||||
|
||||
const renderActivityItem = (c) => {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
const rawContent = c.content || c.body || '';
|
||||
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
|
||||
|
||||
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
||||
|
||||
@@ -502,7 +498,7 @@
|
||||
}
|
||||
|
||||
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
|
||||
|
||||
// Build avatar URL — same priority as the rest of the app
|
||||
let avatarSrc = '/a/default.png';
|
||||
@@ -547,14 +543,27 @@
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
||||
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
let bannerFile = c.banner_file;
|
||||
let bannerPos = c.banner_position;
|
||||
let bannerSz = c.banner_size;
|
||||
if (!bannerFile && window.f0ckSession && window.f0ckSession.id && (c.user_id === window.f0ckSession.id || c.username === window.f0ckSession.user)) {
|
||||
bannerFile = window.f0ckSession.banner_file;
|
||||
bannerPos = window.f0ckSession.banner_position;
|
||||
bannerSz = window.f0ckSession.banner_size;
|
||||
}
|
||||
const bannerStyle = (bannerEnabled && bannerFile && bannerFile !== 'null')
|
||||
? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="comment" id="sc${c.id}">
|
||||
<div class="comment" id="sc${c.id}" ${bannerStyle}>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
|
||||
@@ -168,11 +168,48 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
|
||||
if (row) { row.classList.add('uut-job--fading'); setTimeout(() => row.remove(), 300); }
|
||||
setTimeout(() => { _active.delete(jobId); _sync(); }, 350);
|
||||
|
||||
// Toast flash notification
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
const msg = (window.f0ckI18n && window.f0ckI18n.notif_upload_success) || 'Your background upload is finished!';
|
||||
window.flashMessage(msg, 3500, 'success');
|
||||
}
|
||||
|
||||
// Refresh notification bell count and items
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
|
||||
window.NotificationSystemInstance.pollDebounced();
|
||||
}
|
||||
|
||||
const itemId = notif.item_id;
|
||||
if (itemId && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
const grid = document.querySelector('.posts');
|
||||
if (grid && !grid.querySelector(`a[href$="/${itemId}"]`)) {
|
||||
fetch(`/api/v2/item/${itemId}`)
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res && res.success && res.rows) {
|
||||
const item = res.rows;
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: item.id,
|
||||
dest: item.dest,
|
||||
mime: item.mime,
|
||||
username: item.username,
|
||||
display_name: item.display_name || null,
|
||||
tag_id: item.rating_tag_id || item.tag_id || 0,
|
||||
is_oc: !!item.is_oc,
|
||||
slug: item.slug,
|
||||
visibility: item.visibility || 0
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[UrlTracker] Failed to fetch item for live injection:', err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Failure: update the row to show the error, keep until user closes
|
||||
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
|
||||
if (row) {
|
||||
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
||||
if (row) {
|
||||
const stageEl = row.querySelector('.uut-stage');
|
||||
if (stageEl) {
|
||||
if (notif.item_id) {
|
||||
@@ -188,6 +225,13 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
if (barFill) { barFill.style.width = '100%'; barFill.style.background = '#ff5050'; barFill.style.backgroundImage = 'none'; barFill.style.animation = 'none'; }
|
||||
}
|
||||
_sync(); // update badge (count stays 0 for resolved jobs)
|
||||
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('✕ ' + errMsg, 4500, 'error');
|
||||
}
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
|
||||
window.NotificationSystemInstance.pollDebounced();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -908,7 +952,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
lines.forEach(url => {
|
||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
|
||||
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'url', url, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
}
|
||||
});
|
||||
urlInput.value = '';
|
||||
@@ -931,7 +975,7 @@ window.initUploadForm = (selector) => {
|
||||
const val = urlInput.value.trim();
|
||||
if (!val || !/^https?:\/\//i.test(val)) return;
|
||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) {
|
||||
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'url', url: val, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
}
|
||||
urlInput.value = '';
|
||||
if (urlBadge) urlBadge.style.display = 'none';
|
||||
@@ -1177,7 +1221,7 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
||||
if (isShitpost) {
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
} else {
|
||||
selectedFiles.push(file); // Legacy single file mode uses raw File
|
||||
}
|
||||
@@ -1490,6 +1534,8 @@ window.initUploadForm = (selector) => {
|
||||
infoRow.className = 'file-meta-row-small';
|
||||
|
||||
let ratingSwitch = '';
|
||||
let visibilitySwitch = '';
|
||||
let expirySwitch = '';
|
||||
let tagsUI = '';
|
||||
let ocUI = '';
|
||||
let commentUI = '';
|
||||
@@ -1517,6 +1563,56 @@ window.initUploadForm = (selector) => {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const hasVisSection = !!form.querySelector('.global-visibility-section');
|
||||
if (hasVisSection) {
|
||||
const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0';
|
||||
const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis;
|
||||
item.visibility = visValue;
|
||||
visibilitySwitch = `
|
||||
<div class="item-visibility-container item-rating-container" style="margin-top: 4px;">
|
||||
<label class="item-rating-option">
|
||||
<input type="radio" name="visibility_${index}" value="0" ${visValue === '0' || visValue === 0 ? 'checked' : ''}>
|
||||
<span class="item-rating-label sfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-globe"></i> Public</span>
|
||||
</label>
|
||||
<label class="item-rating-option">
|
||||
<input type="radio" name="visibility_${index}" value="1" ${visValue === '1' || visValue === 1 ? 'checked' : ''}>
|
||||
<span class="item-rating-label nsfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-link"></i> Unlisted</span>
|
||||
</label>
|
||||
<label class="item-rating-option">
|
||||
<input type="radio" name="visibility_${index}" value="2" ${visValue === '2' || visValue === 2 ? 'checked' : ''}>
|
||||
<span class="item-rating-label nsfl" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-lock"></i> Private</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
visibilitySwitch = '';
|
||||
}
|
||||
|
||||
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||
const hasExpirySection = !!globalExpiryEl || window.f0ckEnableExpiringUploads !== false;
|
||||
if (hasExpirySection && globalExpiryEl) {
|
||||
const globalExpiry = globalExpiryEl.value || 'permanent';
|
||||
const expiryValue = (item.expiry !== undefined && item.expiry !== '') ? item.expiry : globalExpiry;
|
||||
item.expiry = expiryValue;
|
||||
|
||||
expirySwitch = `
|
||||
<div class="item-expiry-container" style="margin-top: 4px;">
|
||||
<select class="item-expiry-select" style="width: 100%; padding: 4px 8px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 4px; font-size: 0.85em; cursor: pointer;">
|
||||
<option value="permanent" ${expiryValue === 'permanent' ? 'selected' : ''}>Permanent (Never expires)</option>
|
||||
<option value="30minutes" ${expiryValue === '30minutes' ? 'selected' : ''}>30 Minutes</option>
|
||||
<option value="1hour" ${expiryValue === '1hour' ? 'selected' : ''}>1 Hour</option>
|
||||
<option value="24hours" ${expiryValue === '24hours' ? 'selected' : ''}>24 Hours</option>
|
||||
<option value="1week" ${expiryValue === '1week' ? 'selected' : ''}>1 Week</option>
|
||||
<option value="1month" ${expiryValue === '1month' ? 'selected' : ''}>1 Month</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
expirySwitch = '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
|
||||
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
|
||||
tagsUI = `
|
||||
@@ -1560,19 +1656,37 @@ window.initUploadForm = (selector) => {
|
||||
</div>
|
||||
${titleUI}
|
||||
${ratingSwitch}
|
||||
${visibilitySwitch}
|
||||
${expirySwitch}
|
||||
${tagsUI}
|
||||
${commentUI}
|
||||
`;
|
||||
|
||||
if (isShitpost) {
|
||||
// Handle Rating
|
||||
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
|
||||
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
|
||||
radio.onchange = () => {
|
||||
item.rating = radio.value;
|
||||
updateSubmitButton();
|
||||
};
|
||||
});
|
||||
|
||||
// Handle Visibility
|
||||
infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => {
|
||||
radio.onchange = () => {
|
||||
item.visibility = radio.value;
|
||||
};
|
||||
});
|
||||
|
||||
// Handle Expiry
|
||||
const expirySelect = infoRow.querySelector('.item-expiry-select');
|
||||
if (expirySelect) {
|
||||
expirySelect.onchange = () => {
|
||||
item.expiry = expirySelect.value;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Handle Comment
|
||||
const commentInput = infoRow.querySelector('.item-comment-input');
|
||||
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
|
||||
@@ -2436,6 +2550,11 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
|
||||
const visibilityVal = globalVisEl ? globalVisEl.value : '0';
|
||||
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||
const expiryVal = globalExpiryEl ? globalExpiryEl.value : 'permanent';
|
||||
|
||||
const resp = await fetch('/api/v2/upload-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2445,7 +2564,9 @@ window.initUploadForm = (selector) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
rating: globalRatingEl.value,
|
||||
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
|
||||
visibility: visibilityVal,
|
||||
expiry: expiryVal,
|
||||
tags: tags.join(','),
|
||||
comment: comment,
|
||||
is_oc: isOc,
|
||||
@@ -2552,9 +2673,13 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const item = selectedFiles[i];
|
||||
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
|
||||
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||
const isUrlItem = isShitpost && item.type === 'url';
|
||||
const file = !isUrlItem ? (isShitpost ? item.file : item) : null;
|
||||
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
|
||||
const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0');
|
||||
const fileExpiry = isShitpost ? (item.expiry || globalExpiryEl?.value || 'permanent') : (globalExpiryEl?.value || 'permanent');
|
||||
const fileTags = isShitpost ? item.tags : tags;
|
||||
const fileComment = isShitpost ? item.comment : comment;
|
||||
const fileTitle = isShitpost ? (item.title || '') : titleVal;
|
||||
@@ -2571,6 +2696,8 @@ window.initUploadForm = (selector) => {
|
||||
formData.append('file', file);
|
||||
}
|
||||
formData.append('rating', fileRating);
|
||||
formData.append('visibility', fileVisibility);
|
||||
formData.append('expiry', fileExpiry);
|
||||
formData.append('tags', fileTags.join(','));
|
||||
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
|
||||
if (isShitpost) formData.append('is_shitpost', 'true');
|
||||
@@ -2622,6 +2749,8 @@ window.initUploadForm = (selector) => {
|
||||
xhr.send(JSON.stringify({
|
||||
url: item.url,
|
||||
rating: fileRating,
|
||||
visibility: fileVisibility,
|
||||
expiry: fileExpiry,
|
||||
tags: fileTags.join(','),
|
||||
is_oc: (isShitpost ? item.is_oc : isOc),
|
||||
comment: fileComment,
|
||||
@@ -2717,40 +2846,15 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: inject the new item into the grid (deduplication is handled inside handleNewItem)
|
||||
const injectNewItem = () => {
|
||||
if (lastData && !lastData.manual_approval && !lastData.pending &&
|
||||
lastData.itemid && lastData.dest && lastData.mime &&
|
||||
window.NotificationSystemInstance &&
|
||||
typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: lastData.itemid,
|
||||
dest: lastData.dest,
|
||||
mime: lastData.mime,
|
||||
username: lastData.username || window.f0ckSession?.user || '',
|
||||
display_name: lastData.display_name || window.f0ckSession?.display_name || null,
|
||||
tag_id: lastData.tag_id ?? 0,
|
||||
is_oc: !!lastData.is_oc
|
||||
});
|
||||
// URL background uploads do not redirect; standard file uploads redirect back to main page ('/')
|
||||
const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (isBgUrlUpload) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Skip redirect if every item was a background URL job
|
||||
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (!allPending) {
|
||||
// Inject now if the grid is already in the DOM (upload modal open on main page)
|
||||
injectNewItem();
|
||||
// Navigate to main page, then inject again after the grid has loaded.
|
||||
// Awaiting loadPageAjax ensures the .posts grid DOM is present before the
|
||||
// handleNewItem call — this covers the item-page drag-and-upload scenario
|
||||
// where no grid exists until after navigation completes.
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
await window.loadPageAjax('/', true, { bypassCache: true });
|
||||
injectNewItem();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreBtn();
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
const favoEl = document.querySelector("#a_favo");
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -169,6 +175,9 @@
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
|
||||
@@ -306,7 +306,12 @@ if (!window.UserCommentSystem) {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||
const rendered = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit
|
||||
@@ -405,11 +410,16 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
|
||||
renderComment(c) {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
const timeAgo = this.timeAgo(c.created_at);
|
||||
const fullDate = new Date(c.created_at).toISOString();
|
||||
const content = this.renderCommentContent(c.content, c.item_id);
|
||||
const content = this.renderCommentContent(c.content, itemKey);
|
||||
|
||||
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
const bannerStyle = (c.banner_file && c.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
@@ -450,7 +460,7 @@ if (!window.UserCommentSystem) {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
529
scripts/import.mjs
Normal file
529
scripts/import.mjs
Normal file
@@ -0,0 +1,529 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import db from "../src/inc/sql.mjs";
|
||||
import lib from "../src/inc/lib.mjs";
|
||||
import cfg from "../src/inc/config.mjs";
|
||||
import queue from "../src/inc/queue.mjs";
|
||||
import f0cklib from "../src/inc/routeinc/f0cklib.mjs";
|
||||
import { getBypassDuplicateCheck } from "../src/inc/settings.mjs";
|
||||
|
||||
// Helper for parsing CLI flags
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
user: null,
|
||||
rating: null,
|
||||
tags: [],
|
||||
dir: cfg.paths.import || path.resolve("f0ckm-data/import"),
|
||||
keep: false,
|
||||
bypass: null, // null means use config default, true = force bypass, false = force check
|
||||
is_oc: false,
|
||||
title: null,
|
||||
help: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else if (arg === '--user' || arg === '-u') {
|
||||
options.user = args[++i];
|
||||
} else if (arg === '--rating' || arg === '-r') {
|
||||
options.rating = args[++i]?.toLowerCase();
|
||||
} else if (arg === '--tags' || arg === '-t') {
|
||||
const rawTags = args[++i];
|
||||
if (rawTags) {
|
||||
options.tags = rawTags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (arg === '--dir' || arg === '-d') {
|
||||
options.dir = path.resolve(args[++i]);
|
||||
} else if (arg === '--keep' || arg === '-k') {
|
||||
options.keep = true;
|
||||
} else if (arg === '--bypass' || arg === '-b') {
|
||||
options.bypass = true;
|
||||
} else if (arg === '--check') {
|
||||
options.bypass = false;
|
||||
} else if (arg === '--oc') {
|
||||
options.is_oc = true;
|
||||
} else if (arg === '--title') {
|
||||
options.title = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
f0ckm Bulk Import Script
|
||||
========================
|
||||
Imports all media files in f0ckm-data/import (or custom directory) into f0ckm.
|
||||
|
||||
Usage:
|
||||
node scripts/import.mjs [options]
|
||||
|
||||
Options:
|
||||
--user, -u <username> User to attribute uploads to (defaults to first admin)
|
||||
--rating, -r <sfw|nsfw|nsfl|untagged> Default rating for imported items
|
||||
--tags, -t <tag1,tag2> Global tags to assign to imported items
|
||||
--dir, -d <path> Directory to scan (default: f0ckm-data/import)
|
||||
--keep, -k Keep source files in import dir after import (default: delete)
|
||||
--bypass, -b Force bypass of duplicate checksum/pHash checks
|
||||
--check Force duplicate checking even if config enables bypass
|
||||
--oc Mark all imported files as Original Content (OC)
|
||||
--title <title> Set title for imported files
|
||||
--help, -h Show this help screen
|
||||
|
||||
Folder & Sidecar JSON Features:
|
||||
- Rating Subfolders: Files in 'import/sfw/', 'import/nsfw/', or 'import/nsfl/' automatically receive that rating.
|
||||
- Sidecar Metadata: Place a file.jpg.json or file.json alongside file.jpg to supply metadata:
|
||||
{ "title": "My Post", "rating": "sfw", "tags": ["funny", "cat"], "comment": "First comment", "is_oc": true }
|
||||
`);
|
||||
}
|
||||
|
||||
// Derive archive MIME types from cfg.mimes
|
||||
const ARCHIVE_MIMES = new Set(
|
||||
Object.entries(cfg.mimes || {})
|
||||
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
|
||||
.map(([mime]) => mime)
|
||||
);
|
||||
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||
|
||||
// Recursively collect all target files in directory (follows symlinked dirs and files)
|
||||
async function collectFiles(dir, visitedDirs = new Set()) {
|
||||
const results = [];
|
||||
|
||||
let canonicalDir;
|
||||
try {
|
||||
canonicalDir = await fs.promises.realpath(dir);
|
||||
} catch (_) {
|
||||
canonicalDir = dir;
|
||||
}
|
||||
|
||||
if (visitedDirs.has(canonicalDir)) {
|
||||
return results; // Avoid infinite loops on circular symlink references
|
||||
}
|
||||
visitedDirs.add(canonicalDir);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(dir);
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: unable to read directory '${dir}':`, e.message);
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const name of entries) {
|
||||
if (name.startsWith('.')) continue; // skip hidden files & .gitkeep
|
||||
const fullPath = path.join(dir, name);
|
||||
|
||||
try {
|
||||
// fs.promises.stat follows symlinks to inspect the underlying file/dir
|
||||
const st = await fs.promises.stat(fullPath);
|
||||
|
||||
if (st.isDirectory()) {
|
||||
const subResults = await collectFiles(fullPath, visitedDirs);
|
||||
results.push(...subResults);
|
||||
} else if (st.isFile()) {
|
||||
if (name.endsWith('.json')) continue; // skip sidecar metadata files
|
||||
results.push(fullPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[IMPORT] Warning: unable to stat '${fullPath}' (broken symlink?):`, err.message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Read optional sidecar JSON metadata
|
||||
async function readSidecarMetadata(filePath) {
|
||||
const candidates = [
|
||||
`${filePath}.json`,
|
||||
path.join(path.dirname(filePath), `${path.parse(filePath).name}.json`)
|
||||
];
|
||||
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(cand)) {
|
||||
const raw = await fs.promises.readFile(cand, 'utf-8');
|
||||
return { meta: JSON.parse(raw), sidecarPath: cand };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: failed to parse sidecar JSON at ${cand}:`, e.message);
|
||||
}
|
||||
}
|
||||
return { meta: {}, sidecarPath: null };
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`--- f0ckm Bulk Import Starting ---`);
|
||||
console.log(`Scan Directory: ${opts.dir}`);
|
||||
|
||||
if (!fs.existsSync(opts.dir)) {
|
||||
console.error(`[IMPORT ERROR] Directory does not exist: ${opts.dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. Resolve Target User
|
||||
let targetUser = null;
|
||||
if (opts.user) {
|
||||
const userRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE LOWER(login) = ${opts.user.toLowerCase()} OR LOWER("user") = ${opts.user.toLowerCase()}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (userRows.length > 0) {
|
||||
targetUser = userRows[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] Specified user '${opts.user}' not found in database.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const adminRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE admin = true
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (adminRows.length > 0) {
|
||||
targetUser = adminRows[0];
|
||||
} else {
|
||||
const anyUser = await db`SELECT id, "user", login, admin, is_moderator FROM "user" ORDER BY id ASC LIMIT 1`;
|
||||
if (anyUser.length > 0) {
|
||||
targetUser = anyUser[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] No users found in database. Please run npm run create-admin first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Attributing uploads to user: ${targetUser.user} (ID: ${targetUser.id})`);
|
||||
|
||||
// Ensure target directories exist
|
||||
await fs.promises.mkdir(cfg.paths.b, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.t, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.ca, { recursive: true });
|
||||
|
||||
// 2. Discover files
|
||||
const fileList = await collectFiles(opts.dir);
|
||||
|
||||
if (fileList.length === 0) {
|
||||
console.log(`[IMPORT] No files found in ${opts.dir}. Place files in '${opts.dir}' and re-run.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${fileList.length} file(s) to process.\n`);
|
||||
|
||||
const allowedCats = Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.map(c => c.toLowerCase()) : null;
|
||||
const allowedMimes = allowedCats
|
||||
? Object.keys(cfg.mimes).filter(m => allowedCats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)))
|
||||
: Object.keys(cfg.mimes);
|
||||
|
||||
let successCount = 0;
|
||||
let skippedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 3. Process each file sequentially
|
||||
for (let index = 0; index < fileList.length; index++) {
|
||||
const filePath = fileList[index];
|
||||
const baseName = path.basename(filePath);
|
||||
const relPath = path.relative(opts.dir, filePath);
|
||||
const prefix = `[${index + 1}/${fileList.length}]`;
|
||||
|
||||
try {
|
||||
// Check sidecar JSON metadata
|
||||
const { meta: sidecarMeta, sidecarPath } = await readSidecarMetadata(filePath);
|
||||
|
||||
// Determine Rating: Sidecar -> CLI option -> Folder name (import/sfw/ etc.) -> Default
|
||||
let effectiveRating = sidecarMeta.rating || opts.rating;
|
||||
if (!effectiveRating) {
|
||||
const parentDirName = path.basename(path.dirname(filePath)).toLowerCase();
|
||||
if (['sfw', 'nsfw', 'nsfl', 'untagged'].includes(parentDirName)) {
|
||||
effectiveRating = parentDirName;
|
||||
}
|
||||
}
|
||||
if (effectiveRating === 'untagged') effectiveRating = null;
|
||||
if (!effectiveRating && !cfg.websrv.shitpost_mode) {
|
||||
effectiveRating = 'sfw'; // Default fallback if rating required
|
||||
}
|
||||
|
||||
// Determine Title & Comments & OC
|
||||
const effectiveTitle = sidecarMeta.title || opts.title || null;
|
||||
const effectiveComment = sidecarMeta.comment || null;
|
||||
const effectiveIsOc = (sidecarMeta.is_oc !== undefined) ? !!sidecarMeta.is_oc : opts.is_oc;
|
||||
const effectiveVisibility = (typeof sidecarMeta.visibility === 'number') ? sidecarMeta.visibility : 0;
|
||||
|
||||
// Collect tags
|
||||
let mergedTags = [...opts.tags];
|
||||
if (Array.isArray(sidecarMeta.tags)) {
|
||||
mergedTags.push(...sidecarMeta.tags);
|
||||
} else if (typeof sidecarMeta.tags === 'string') {
|
||||
mergedTags.push(...sidecarMeta.tags.split(',').map(t => t.trim()));
|
||||
}
|
||||
mergedTags = mergedTags.filter(t => t && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
|
||||
// Verify file stats & MIME
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size === 0) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} (0 bytes)`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect actual MIME via file command (with -L to follow symlinks)
|
||||
let actualMime = (await queue.spawn('file', ['-L', '--mime-type', '-b', filePath])).stdout.trim();
|
||||
|
||||
if ((actualMime === 'application/octet-stream' || !actualMime) && baseName.toLowerCase().endsWith('.swf')) {
|
||||
actualMime = 'application/x-shockwave-flash';
|
||||
}
|
||||
|
||||
if (!allowedMimes.includes(actualMime)) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Unsupported MIME type: '${actualMime}'`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase A: SHA-256, PHash, Dimensions, Audio-MP4 probe
|
||||
const needsPHash = !isArchiveMime(actualMime);
|
||||
const needsDims = !isArchiveMime(actualMime);
|
||||
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
|
||||
|
||||
const [checksum, phash, dimResult, audioMp4Result] = await Promise.all([
|
||||
queue.spawn('sha256sum', [filePath]).then(r => r.stdout.trim().split(' ')[0]),
|
||||
needsPHash ? queue.generatePHash(filePath).catch(e => { console.error(`[IMPORT] PHash error:`, e.message); return null; }) : Promise.resolve(null),
|
||||
needsDims ? (async () => {
|
||||
try {
|
||||
if (actualMime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { quiet: true, ignoreExitCode: true });
|
||||
const match = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath], { quiet: true, ignoreExitCode: true });
|
||||
const dimParts = probeOut.trim().split(',');
|
||||
if (dimParts.length >= 2) {
|
||||
const w = parseInt(dimParts[0], 10);
|
||||
const h = parseInt(dimParts[1], 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null),
|
||||
needsAudioMP4 ? (async () => {
|
||||
const ext = baseName.split('.').pop().toLowerCase();
|
||||
if (['m4a', 'aac'].includes(ext)) return 'audio/mp4';
|
||||
try {
|
||||
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath]);
|
||||
if (!probe.stdout.trim()) return 'audio/mp4';
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (audioMp4Result) {
|
||||
actualMime = audioMp4Result;
|
||||
}
|
||||
|
||||
// Phase B: Duplicate check
|
||||
const bypassDupes = (opts.bypass !== null) ? opts.bypass : (getBypassDuplicateCheck() || cfg.websrv.bypass_duplicate_check === true);
|
||||
if (!bypassDupes) {
|
||||
const [repostBySum, repostByPhash] = await Promise.all([
|
||||
queue.checkrepostsum(checksum),
|
||||
phash ? queue.checkrepostphash(phash) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (repostBySum) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Duplicate file (matches existing Item #${repostBySum})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (repostByPhash) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Visual duplicate (matches existing Item #${repostByPhash})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate target filename & UUID
|
||||
const uuid = await queue.genuuid();
|
||||
const ext = cfg.mimes[actualMime] || 'bin';
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const destPath = path.join(cfg.paths.b, filename);
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
// Copy file to public/b
|
||||
await fs.promises.copyFile(filePath, destPath);
|
||||
|
||||
const insertChecksum = bypassDupes ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
|
||||
// Insert into items table
|
||||
await db`
|
||||
INSERT INTO items ${db({
|
||||
src: '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: stat.size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: targetUser.user,
|
||||
userchannel: 'import_script',
|
||||
usernetwork: 'local',
|
||||
stamp: nowStamp,
|
||||
active: true,
|
||||
is_oc: effectiveIsOc,
|
||||
original_filename: baseName,
|
||||
title: effectiveTitle,
|
||||
width: dimResult?.width ?? null,
|
||||
height: dimResult?.height ?? null,
|
||||
visibility: effectiveVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
|
||||
`;
|
||||
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
// Subscribe uploader to comments
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${targetUser.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
} catch (_) {}
|
||||
|
||||
// Generate thumbnails
|
||||
try {
|
||||
await queue.genThumbnail(filename, actualMime, itemid, '', false, 512);
|
||||
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${prefix} [WARN] Thumbnail generation warning for #${itemid}:`, err.message);
|
||||
}
|
||||
|
||||
// Blurred thumbnail
|
||||
await queue.genBlurredThumbnail(itemid, false);
|
||||
|
||||
// Assign Rating Tag
|
||||
if (effectiveRating) {
|
||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: targetUser.id })}
|
||||
`;
|
||||
}
|
||||
|
||||
// Assign Custom Tags
|
||||
for (const tagName of mergedTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
const tagId = tagRow[0].id;
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
// Auto OC tags
|
||||
if (effectiveIsOc) {
|
||||
for (const tagname of ['oc', 'original content']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto Flash tags
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
for (const tagname of ['Flash', 'SWF']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional first comment
|
||||
if (effectiveComment && effectiveComment.trim().length > 0) {
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: targetUser.id,
|
||||
content: effectiveComment.trim()
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
// PostgreSQL Live Notification
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
username: targetUser.user,
|
||||
display_name: targetUser.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!effectiveIsOc
|
||||
})})`;
|
||||
} catch (_) {}
|
||||
|
||||
// Clear cache
|
||||
f0cklib.clearCountCache();
|
||||
|
||||
// Cleanup imported source file unless --keep was passed
|
||||
if (!opts.keep) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
if (sidecarPath) {
|
||||
await fs.promises.unlink(sidecarPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${prefix} [SUCCESS] Imported '${relPath}' -> Item #${itemid} (Rating: ${effectiveRating || 'untagged'}, MIME: ${actualMime})`);
|
||||
successCount++;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`${prefix} [ERROR] Failed to import '${relPath}':`, err.message);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`Import Complete!`);
|
||||
console.log(`Total Scanned : ${fileList.length}`);
|
||||
console.log(`Successful : ${successCount}`);
|
||||
console.log(`Skipped : ${skippedCount}`);
|
||||
console.log(`Failed : ${failedCount}`);
|
||||
console.log(`========================================`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runImport().catch(err => {
|
||||
console.error(`[IMPORT FATAL ERROR]`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -190,15 +190,20 @@ export const handleBannerUpload = async (req, res) => {
|
||||
update user_options
|
||||
set banner_file = ${finalFilename},
|
||||
banner_position = ${parts.banner_position || 'center'},
|
||||
banner_size = ${parts.banner_size || 'cover'}
|
||||
banner_size = ${parts.banner_size || 'cover'},
|
||||
banner_repeat = ${parts.banner_repeat || 'repeat'}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
banner_file: finalFilename,
|
||||
banner_position: parts.banner_position || 'center',
|
||||
banner_size: parts.banner_size || 'cover'
|
||||
banner_size: parts.banner_size || 'cover',
|
||||
banner_repeat: parts.banner_repeat || 'repeat'
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
} catch (dbErr) {
|
||||
@@ -273,6 +278,9 @@ export const handleBannerDelete = async (req, res) => {
|
||||
set banner_file = null
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
|
||||
@@ -12,10 +12,23 @@ config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.en
|
||||
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
|
||||
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const isDocker = process.env.DB_HOST === 'f0ckm-db' || config.sql.host === 'f0ckm-db' || process.env.CONTAINER === 'true';
|
||||
if (process.env.NODE_ENV === 'production' || isDocker) {
|
||||
config.main.development = false;
|
||||
}
|
||||
|
||||
if (config.main.development) {
|
||||
if (!process.env.DB_HOST && (config.sql.host === 'f0ckm-db' || !config.sql.host)) {
|
||||
config.sql.host = 'localhost';
|
||||
}
|
||||
if (!process.env.DB_PORT && config.sql.port === 5432) {
|
||||
config.sql.port = 5454;
|
||||
}
|
||||
if (!process.env.STORAGE_DIR) {
|
||||
process.env.STORAGE_DIR = 'f0ckm-data';
|
||||
}
|
||||
}
|
||||
|
||||
// Set timezone from config if not already set via environment variable
|
||||
if (!process.env.TZ && config.main.timezone) {
|
||||
process.env.TZ = config.main.timezone;
|
||||
@@ -53,11 +66,12 @@ config.paths = {
|
||||
koepfe: resolvePath('public/s/koepfe'),
|
||||
fonts: resolvePath('public/s/fonts'),
|
||||
memes: resolvePath('public/memes'),
|
||||
e: resolvePath('e'),
|
||||
e: resolvePath('public/e'),
|
||||
pending: resolvePath('pending'),
|
||||
deleted: resolvePath('deleted'),
|
||||
logs: resolvePath('logs'),
|
||||
tmp: resolvePath('tmp')
|
||||
tmp: resolvePath('tmp'),
|
||||
import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'))
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -38,6 +38,16 @@ export default new class {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
generateSlug(length = 11) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
let slug = '';
|
||||
const bytes = crypto.randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
slug += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
@@ -53,6 +63,29 @@ export default new class {
|
||||
const timeStr = t(unitKey, { n: interval });
|
||||
return t('timeago.ago', { t: timeStr });
|
||||
};
|
||||
expiresIn(expiresAt) {
|
||||
if (!expiresAt) return null;
|
||||
const expiresNum = parseInt(expiresAt, 10);
|
||||
if (isNaN(expiresNum)) return null;
|
||||
const now = ~~(Date.now() / 1000);
|
||||
const diff = expiresNum - now;
|
||||
if (diff <= 0) return "expiring now";
|
||||
|
||||
if (diff < 60) return `in ${diff}s`;
|
||||
if (diff < 3600) {
|
||||
const mins = Math.floor(diff / 60);
|
||||
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (diff < 86400) {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
const mins = Math.floor((diff % 3600) / 60);
|
||||
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
}
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest("hex");
|
||||
};
|
||||
@@ -62,21 +95,22 @@ export default new class {
|
||||
getMode(mode) {
|
||||
let tmp;
|
||||
mode = Number(mode);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
switch (mode) {
|
||||
case 1: // nsfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
|
||||
break;
|
||||
case 2: // untagged
|
||||
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
|
||||
tmp = `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
|
||||
break;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
break;
|
||||
case 4: // nsfl
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${nsflId})` : "1 = 0";
|
||||
break;
|
||||
default: // sfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
tmp = `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`;
|
||||
break;
|
||||
}
|
||||
return tmp;
|
||||
@@ -107,7 +141,8 @@ export default new class {
|
||||
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
|
||||
}
|
||||
if (filtered.includes('untagged')) {
|
||||
parts.push('not exists (select 1 from tags_assign where item_id = items.id)');
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
parts.push(`not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return '(' + parts.join(' OR ') + ')';
|
||||
@@ -167,27 +202,28 @@ export default new class {
|
||||
const tagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const untagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
|
||||
where not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const sfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const nsfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total;
|
||||
const nsfl = cfg.enable_nsfl ? +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true and is_deleted = false
|
||||
`)[0].total : 0;
|
||||
const deleted = +(await db`
|
||||
select count(*) as total
|
||||
@@ -246,18 +282,22 @@ export default new class {
|
||||
|
||||
return false;
|
||||
};
|
||||
async getTags(itemid) {
|
||||
async getTags(itemid, session = null) {
|
||||
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
|
||||
const tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join user_options uo on uo.user_id = "user".id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
tags[t].badge = this.getBadge(tags[t]);
|
||||
if (!hasSession) {
|
||||
delete tags[t].user;
|
||||
delete tags[t].display_name;
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
@@ -460,4 +500,15 @@ export default new class {
|
||||
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
||||
return `Internal Error. Reference: ${errId}`;
|
||||
}
|
||||
|
||||
isOnionRequest(req) {
|
||||
if (!req || !req.headers) return false;
|
||||
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
|
||||
if (!rawHost) return false;
|
||||
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
|
||||
const firstHost = hostStr.split(',')[0].trim();
|
||||
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
|
||||
return hostNoPort.endsWith('.onion');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -204,3 +204,46 @@ export async function moveToDeleted(dest, deletedId) {
|
||||
await fs.unlink(srcPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically scan for and purge expired uploads (expires_at <= now).
|
||||
* Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
|
||||
* and sets is_deleted = true, is_purged = true, active = false in DB.
|
||||
*/
|
||||
export async function purgeExpiredUploads() {
|
||||
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const now = ~~(Date.now() / 1000);
|
||||
const expiredItems = await db`
|
||||
SELECT id, dest, mime
|
||||
FROM items
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at <= ${now}
|
||||
AND is_purged = false
|
||||
`;
|
||||
if (expiredItems.length > 0) {
|
||||
console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
|
||||
for (const item of expiredItems) {
|
||||
try {
|
||||
if (item.dest) {
|
||||
await safeDeleteMediaFile(item.dest, item.id);
|
||||
}
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
|
||||
if (item.mime && item.mime.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
|
||||
}
|
||||
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
|
||||
} catch (e) {
|
||||
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,12 @@
|
||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
||||
"favorites_private": "Private Favoriten",
|
||||
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
|
||||
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
|
||||
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
|
||||
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
|
||||
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
||||
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
@@ -535,6 +541,7 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
||||
"no_uploads": "Keine Uploads gefunden",
|
||||
"no_favs": "Keine Favoriten",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
@@ -794,6 +801,12 @@
|
||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||
"slot_refreshed": "Slot erneuert",
|
||||
"admin_desc": "Du bist Admin, leg los."
|
||||
"admin_desc": "Du bist Admin, leg los.",
|
||||
"visibility": {
|
||||
"public": "Öffentlich",
|
||||
"unlisted": "Nicht gelistet",
|
||||
"private": "Privat",
|
||||
"change_visibility": "Sichtbarkeit ändern"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,12 @@
|
||||
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
|
||||
"disable_swiping": "Disable Swiping",
|
||||
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
||||
"favorites_private": "Private Favorites",
|
||||
"favorites_private_hint": "Only you and administrators can view your favorites list.",
|
||||
"hide_fav_badge": "Hide Favorite Badge Avatar",
|
||||
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
|
||||
"default_upload_visibility": "Default Upload Visibility",
|
||||
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
|
||||
"image_expand_on_click": "Expand images inline on click",
|
||||
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
||||
"enable_bg_blur": "Enable Background blur",
|
||||
@@ -539,6 +545,7 @@
|
||||
"subscribe_uploads_btn": "Subscribe user to uploads",
|
||||
"no_uploads": "no uploads found",
|
||||
"no_favs": "no favorites",
|
||||
"private_favorites": "private favorites",
|
||||
"back_to_profile": "Back to Profile",
|
||||
"ban_modal_title": "Ban User",
|
||||
"ban_modal_reason": "Reason:",
|
||||
@@ -796,6 +803,12 @@
|
||||
"delete_confirm": "Delete this invite token?",
|
||||
"slot_refreshes_on": "slot refreshes on {date}",
|
||||
"slot_refreshed": "slot refreshed",
|
||||
"admin_desc": "You are an admin, go ahead."
|
||||
"admin_desc": "You are an admin, go ahead.",
|
||||
"visibility": {
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"private": "Private",
|
||||
"change_visibility": "Change Visibility"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,10 @@
|
||||
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
|
||||
"disable_swiping": "Swipen uitschakelen",
|
||||
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
||||
"favorites_private": "Privé favorieten",
|
||||
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
|
||||
"hide_fav_badge": "Favoriet-badge avatar verbergen",
|
||||
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
|
||||
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
||||
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
||||
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
||||
@@ -535,6 +539,7 @@
|
||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||
"no_uploads": "geen uploads gevonden",
|
||||
"no_favs": "geen favorieten",
|
||||
"private_favorites": "privé favorieten",
|
||||
"back_to_profile": "Terug naar Profiel",
|
||||
"ban_modal_title": "Gebruiker Bannen",
|
||||
"ban_modal_reason": "Reden:",
|
||||
|
||||
@@ -165,6 +165,10 @@
|
||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
||||
"favorites_private": "Private Favoriten",
|
||||
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
|
||||
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
|
||||
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
||||
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
@@ -536,6 +540,7 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||
"no_uploads": "keine Aufladierungen gefunden",
|
||||
"no_favs": "keine Favoriten",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
|
||||
@@ -1,12 +1,69 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import { updateHallsCache } from "../halls_cache.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
import fs from "fs";
|
||||
import url from "url";
|
||||
|
||||
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null;
|
||||
const getGlobalfilter = () => {
|
||||
if (!cfg.nsfp?.length) return null;
|
||||
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
|
||||
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(" or ") : null;
|
||||
};
|
||||
|
||||
const computeBaseMode = (mode, ratings, session) => {
|
||||
const effMode = Number(mode ?? 0);
|
||||
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
|
||||
let baseMode;
|
||||
if (effMode === 2) {
|
||||
if (ratingsArr && ratingsArr.includes('untagged') && ratingsArr.length > 1) {
|
||||
baseMode = lib.getMultiRatingMode(ratingsArr);
|
||||
} else {
|
||||
baseMode = lib.getMode(2);
|
||||
}
|
||||
} else if (effMode === 3) {
|
||||
baseMode = (ratingsArr && ratingsArr.length > 1) ? lib.getMultiRatingMode(ratingsArr) : lib.getMode(3);
|
||||
} else {
|
||||
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
baseMode = multiRatingSQL ?? lib.getMode(effMode);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if ((effMode === 0 || effMode === 3 || mode === undefined || mode === null) && (!ratingsArr || ratingsArr.length <= 1)) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged) {
|
||||
if (effMode === 2 || (ratingsArr && ratingsArr.length === 1 && ratingsArr[0] === 'untagged')) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
}
|
||||
return baseMode;
|
||||
};
|
||||
|
||||
|
||||
const resolveNumericItemId = async (itemIdOrSlug) => {
|
||||
if (!itemIdOrSlug) return null;
|
||||
if (typeof itemIdOrSlug === 'number') return itemIdOrSlug;
|
||||
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
|
||||
try {
|
||||
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
|
||||
return rows[0]?.id || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
|
||||
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
||||
@@ -18,7 +75,7 @@ const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === '
|
||||
const COUNT_CACHE_TTL_MS = 90_000;
|
||||
const countCache = new Map(); // key → { total, expiresAt }
|
||||
|
||||
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
|
||||
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger, sort, timeframe, oc, pinned, hasComments }) {
|
||||
return JSON.stringify([
|
||||
modequery,
|
||||
tag ?? '',
|
||||
@@ -31,7 +88,12 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
|
||||
newerThan ?? '',
|
||||
minXd,
|
||||
userHallObj?.id ?? '',
|
||||
tagger ?? ''
|
||||
tagger ?? '',
|
||||
sort ?? '',
|
||||
timeframe ?? '',
|
||||
oc ? 1 : 0,
|
||||
pinned ? 1 : 0,
|
||||
hasComments ? 1 : 0
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -136,8 +198,60 @@ const xdScoreMeta = (score) => {
|
||||
return { tier: 5, label: 'xDDDDD+' };
|
||||
};
|
||||
|
||||
async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) {
|
||||
if (!rawUser) return { isPrivate: false, isAllowed: true };
|
||||
const decodedUser = decodeURI(rawUser);
|
||||
const targetUserRows = await db`
|
||||
select u.id, u."user", u.admin, uo.favorites_private
|
||||
from "user" u
|
||||
left join user_options uo on uo.user_id = u.id
|
||||
where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser}
|
||||
limit 1
|
||||
`;
|
||||
if (!targetUserRows.length || !targetUserRows[0].favorites_private) {
|
||||
return { isPrivate: false, isAllowed: true };
|
||||
}
|
||||
|
||||
const targetUser = targetUserRows[0];
|
||||
|
||||
let reqUserId = user_id;
|
||||
let reqIsAdmin = is_admin;
|
||||
|
||||
if (typeof session === 'object' && session !== null) {
|
||||
if (reqUserId === undefined) reqUserId = session.id;
|
||||
if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin;
|
||||
}
|
||||
|
||||
let isAllowed = false;
|
||||
if (reqUserId) {
|
||||
if (+reqUserId === +targetUser.id) {
|
||||
isAllowed = true;
|
||||
} else if (reqIsAdmin === true) {
|
||||
isAllowed = true;
|
||||
} else if (reqIsAdmin === undefined) {
|
||||
const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`;
|
||||
if (reqUserRows.length > 0 && reqUserRows[0].admin) {
|
||||
isAllowed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isPrivate: true, isAllowed };
|
||||
}
|
||||
|
||||
export default {
|
||||
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
|
||||
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger, sort, timeframe, oc, pinned, hasComments } = {}) => {
|
||||
if (fav && rawUser) {
|
||||
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||
if (isPrivate && !isAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
is_private: true,
|
||||
message: "private favorites"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
|
||||
// --- title: prefix — search items.title instead of the tags table ---
|
||||
@@ -187,15 +301,44 @@ export default {
|
||||
// xD filter: use materialized items.xd_score column (kept live by trigger) for fast indexed lookup
|
||||
const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``;
|
||||
|
||||
// Advanced filters
|
||||
const ocFilter = (oc === true || oc === '1' || oc === 'true') ? db`and items.is_oc = true` : db``;
|
||||
const pinnedFilter = (pinned === true || pinned === '1' || pinned === 'true') ? db`and items.is_pinned = true` : db``;
|
||||
const hasCommentsFilter = (hasComments === true || hasComments === '1' || hasComments === 'true')
|
||||
? db`and exists (select 1 from comments where item_id = items.id and is_deleted = false)`
|
||||
: db``;
|
||||
|
||||
let timeframeFilter = db``;
|
||||
if (timeframe && timeframe !== 'all') {
|
||||
let seconds = 0;
|
||||
if (timeframe === 'day') seconds = 86400;
|
||||
else if (timeframe === 'week') seconds = 86400 * 7;
|
||||
else if (timeframe === 'month') seconds = 86400 * 30;
|
||||
else if (timeframe === 'year') seconds = 86400 * 365;
|
||||
if (seconds > 0) {
|
||||
const minStamp = Math.floor(Date.now() / 1000) - seconds;
|
||||
timeframeFilter = db`and items.stamp >= ${minStamp}`;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSort = sort || (random ? 'random' : 'newest');
|
||||
let orderSQL = db`items.is_pinned desc, items.id desc`;
|
||||
if (effectiveSort === 'oldest') {
|
||||
orderSQL = db`items.is_pinned desc, items.id asc`;
|
||||
} else if (effectiveSort === 'popular' || effectiveSort === 'top' || effectiveSort === 'xd') {
|
||||
orderSQL = db`items.is_pinned desc, items.xd_score desc, items.id desc`;
|
||||
} else if (effectiveSort === 'comments') {
|
||||
orderSQL = db`items.is_pinned desc, (select count(*) from comments where item_id = items.id and is_deleted = false) desc, items.id desc`;
|
||||
} else if (effectiveSort === 'random') {
|
||||
orderSQL = db`random()`;
|
||||
}
|
||||
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
|
||||
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger, sort: effectiveSort, timeframe, oc, pinned, hasComments };
|
||||
const modequery = computeBaseMode(mode, ratings, session);
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -250,7 +393,14 @@ export default {
|
||||
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
||||
}
|
||||
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
||||
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(items.visibility, 0) = 0`);
|
||||
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger, sort: effectiveSort, timeframe, oc, pinned, hasComments });
|
||||
let total = getCachedCount(cacheKey);
|
||||
|
||||
if (total === null) {
|
||||
@@ -261,6 +411,7 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -272,6 +423,10 @@ export default {
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||
${xdFilter}
|
||||
${ocFilter}
|
||||
${pinnedFilter}
|
||||
${hasCommentsFilter}
|
||||
${timeframeFilter}
|
||||
`;
|
||||
total = Number(totalRows[0].total);
|
||||
if (total > 0) setCachedCount(cacheKey, total);
|
||||
@@ -303,6 +458,7 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -314,8 +470,12 @@ export default {
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||
${xdFilter}
|
||||
${ocFilter}
|
||||
${pinnedFilter}
|
||||
${hasCommentsFilter}
|
||||
${timeframeFilter}
|
||||
${fav ? db`group by items.id, items.is_pinned` : db``}
|
||||
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
|
||||
order by ${orderSQL}
|
||||
offset ${newerThan ? 0 : offset}
|
||||
limit ${eps}
|
||||
`;
|
||||
@@ -333,6 +493,8 @@ export default {
|
||||
const rows = (await db`
|
||||
select
|
||||
items.id,
|
||||
items.slug,
|
||||
items.visibility,
|
||||
items.mime,
|
||||
items.dest,
|
||||
items.username as username,
|
||||
@@ -428,7 +590,18 @@ export default {
|
||||
view_mode: fav ? 'favs' : 'uploads'
|
||||
};
|
||||
},
|
||||
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
|
||||
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
|
||||
if (fav && rawUser) {
|
||||
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||
if (isPrivate && !isAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
is_private: true,
|
||||
message: "private favorites"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
|
||||
// --- title: prefix — search items.title instead of the tags table ---
|
||||
@@ -458,7 +631,17 @@ export default {
|
||||
if (uhData.length) userHallObj = uhData[0];
|
||||
}
|
||||
const mime = (rawMime ?? "");
|
||||
const itemid = rawItemid ? +rawItemid : null;
|
||||
const rawIdOrSlug = rawItemid ?? null;
|
||||
if (rawIdOrSlug === null || rawIdOrSlug === undefined || rawIdOrSlug === '') {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
|
||||
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
|
||||
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
|
||||
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
@@ -472,18 +655,16 @@ export default {
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||
|
||||
const effMode = Number(mode ?? 0);
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
||||
|
||||
if (itemid === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const itemModeQuery = session
|
||||
? computeBaseMode(mode, ratings, session)
|
||||
: (cfg.websrv.public_nsfw
|
||||
? `not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`
|
||||
: `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`
|
||||
);
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -522,9 +703,18 @@ export default {
|
||||
|
||||
// Helper to construct shared filter conditions
|
||||
const buildConditions = () => {
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(items.visibility, 0) = 0`);
|
||||
|
||||
return db`
|
||||
${db.unsafe(modequery)}
|
||||
${db.unsafe(itemModeQuery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
|
||||
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${hallFilter}
|
||||
@@ -538,11 +728,9 @@ export default {
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
|
||||
|
||||
// 1. Fetch the main item
|
||||
// We only apply the active check and global NSFW filter (for guests) here.
|
||||
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
|
||||
const items = await db`
|
||||
select distinct on (items.id)
|
||||
items.*,
|
||||
@@ -566,15 +754,48 @@ export default {
|
||||
left join "user_options" uo on uo.user_id = author_u.id
|
||||
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
||||
where
|
||||
items.id = ${itemid} and
|
||||
${itemLookup} and
|
||||
items.active = true
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const actitem = items[0];
|
||||
|
||||
if (actitem && user_id) {
|
||||
if (!actitem) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
|
||||
const itemid = actitem.id;
|
||||
|
||||
// Check visibility permissions:
|
||||
const isOwnerOrAdmin = session && (
|
||||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
|
||||
session.admin || session.is_moderator
|
||||
);
|
||||
|
||||
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
|
||||
// Block numeric enumeration unless viewer is owner/admin
|
||||
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
|
||||
// If item is Private (visibility === 2):
|
||||
// Direct link only allowed for owner/admin
|
||||
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
is_private: true,
|
||||
message: "403 - private upload"
|
||||
};
|
||||
}
|
||||
|
||||
if (user_id) {
|
||||
db`
|
||||
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
||||
values (${user_id}, ${itemid}, 1, now())
|
||||
@@ -583,15 +804,12 @@ export default {
|
||||
last_viewed = now()
|
||||
`.catch(e => console.error('Failed to track view:', e));
|
||||
}
|
||||
|
||||
if (!actitem) {
|
||||
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
||||
if (!session && getGlobalfilter()) {
|
||||
const unfilteredItem = await db`
|
||||
select id from items where id = ${itemid} and active = true limit 1
|
||||
// Guest global filter check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
|
||||
if (!session && getGlobalfilter() && (actitem.visibility || 0) === 0) {
|
||||
const filteredItem = await db`
|
||||
select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
|
||||
`;
|
||||
if (unfilteredItem[0]) {
|
||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
||||
if (filteredItem.length > 0) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -607,22 +825,17 @@ export default {
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible."
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
||||
// Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes
|
||||
|
||||
// Determine the effective mode for optimization check (similar to Random)
|
||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
||||
const useTagsDriver = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
|
||||
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
||||
return db`
|
||||
select items.id
|
||||
select items.id, items.slug
|
||||
from items
|
||||
left join tags_assign on tags_assign.item_id = items.id
|
||||
left join tags on tags.id = tags_assign.tag_id
|
||||
@@ -633,7 +846,7 @@ export default {
|
||||
where
|
||||
${buildConditions()}
|
||||
${whereClause}
|
||||
group by items.id
|
||||
group by items.id, items.slug
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`;
|
||||
@@ -648,7 +861,7 @@ export default {
|
||||
const checkFilter = !session && nsfpIds.length > 0;
|
||||
|
||||
const query = db`
|
||||
SELECT ta.item_id as id
|
||||
SELECT ta.item_id as id, items.slug
|
||||
FROM tags_assign ta
|
||||
INNER JOIN items ON items.id = ta.item_id
|
||||
${checkFilter
|
||||
@@ -657,6 +870,7 @@ export default {
|
||||
}
|
||||
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
@@ -683,7 +897,7 @@ export default {
|
||||
// Cheat array should include current item and neighbors, sorted
|
||||
const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b);
|
||||
|
||||
const tags = await lib.getTags(itemid);
|
||||
const tags = await lib.getTags(itemid, session);
|
||||
const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`;
|
||||
const userHallsForItem = user_id
|
||||
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
|
||||
@@ -705,7 +919,7 @@ export default {
|
||||
link.suffix = '';
|
||||
}
|
||||
const favorites = await db`
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
@@ -718,24 +932,24 @@ export default {
|
||||
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
||||
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
||||
const repostRows = await db`
|
||||
SELECT id, username, stamp FROM items
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
WHERE active = true
|
||||
AND id != ${itemid}
|
||||
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
} else if (actitem.checksum) {
|
||||
// Even without bypass, check if other bypass-entries exist with this same hash
|
||||
const baseChecksum = actitem.checksum;
|
||||
const repostRows = await db`
|
||||
SELECT id, username, stamp FROM items
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
WHERE active = true
|
||||
AND id != ${itemid}
|
||||
AND checksum LIKE ${baseChecksum + '_bypass_%'}
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
}
|
||||
|
||||
// Also find visually-similar items via phash, merging with checksum results
|
||||
@@ -745,7 +959,7 @@ export default {
|
||||
const existingIds = new Set(repostItems.map(r => r.id));
|
||||
for (const pm of phashMatches) {
|
||||
if (!existingIds.has(pm.id)) {
|
||||
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||
existingIds.add(pm.id);
|
||||
}
|
||||
}
|
||||
@@ -768,19 +982,14 @@ export default {
|
||||
const isNsfw = tags.some(t => t.id == 2);
|
||||
const isSfw = tags.some(t => t.id == 1);
|
||||
const isTagged = tags.length > 0;
|
||||
// Guest rating restriction check for public uploads (visibility === 0)
|
||||
// Direct links to public unrated/untagged posts are allowed for guests.
|
||||
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
|
||||
let guestBlocked = false;
|
||||
if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
|
||||
else if (isNsfl) guestBlocked = true;
|
||||
|
||||
// Mode-mismatch visibility check:
|
||||
// Only enforce for members (session users) with an explicit mode preference.
|
||||
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
|
||||
const userMode = Number(mode ?? 0);
|
||||
if (userMode !== 3) {
|
||||
let modeBlocked = false;
|
||||
if (userMode === 0 && (isNsfw || isNsfl)) modeBlocked = true; // SFW mode, item is NSFW or NSFL
|
||||
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
|
||||
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
|
||||
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
||||
|
||||
if (modeBlocked) {
|
||||
if (guestBlocked) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -797,6 +1006,39 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// Mode-mismatch visibility check:
|
||||
// Only enforce for members (session users) with an explicit mode preference.
|
||||
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
|
||||
const userMode = Number(mode ?? 0);
|
||||
if (userMode !== 3) {
|
||||
let modeBlocked = false;
|
||||
if (userMode === 0 && (isNsfw || isNsfl)) modeBlocked = true; // SFW mode, item is NSFW or NSFL
|
||||
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
|
||||
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
|
||||
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
||||
|
||||
if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible.",
|
||||
item: {
|
||||
id: itemid,
|
||||
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`,
|
||||
og_url: hallSlug
|
||||
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
|
||||
: `https://${cfg.main.url.domain}/${itemid}`,
|
||||
og_description: `Content not visible in current mode`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (getEnableItemSlugs() && !actitem.slug) {
|
||||
actitem.slug = lib.generateSlug(11);
|
||||
db`UPDATE items SET slug = ${actitem.slug} WHERE id = ${actitem.id} AND (slug IS NULL OR slug = '')`.catch(e => console.error('[AUTO_SLUG] Failed DB update:', e.message));
|
||||
}
|
||||
|
||||
const data = {
|
||||
success: true,
|
||||
user: {
|
||||
@@ -808,6 +1050,8 @@ export default {
|
||||
},
|
||||
item: {
|
||||
id: actitem.id,
|
||||
slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
|
||||
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
|
||||
username: actitem.username,
|
||||
author_id: actitem.author_id,
|
||||
author_color: actitem.author_color,
|
||||
@@ -866,17 +1110,21 @@ export default {
|
||||
is_oc: actitem.is_oc || false,
|
||||
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
|
||||
reposts: repostItems,
|
||||
show_repost_row: !!((session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (actitem.checksum?.includes('_bypass_') || (repostItems && repostItems.length > 0))),
|
||||
width: actitem.width || null,
|
||||
height: actitem.height || null,
|
||||
original_filename: actitem.original_filename || null
|
||||
original_filename: actitem.original_filename || null,
|
||||
expires_at: actitem.expires_at || null,
|
||||
expires_in: lib.expiresIn(actitem.expires_at)
|
||||
|
||||
},
|
||||
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
||||
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
|
||||
pagination: {
|
||||
end: endItem[0]?.id || itemid,
|
||||
start: startItem[0]?.id || itemid,
|
||||
next: nextItem[0]?.id || null,
|
||||
prev: prevItem[0]?.id || null,
|
||||
page: actitem.id,
|
||||
end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
|
||||
start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
|
||||
next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
|
||||
prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
|
||||
page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
|
||||
cheat: cheat
|
||||
},
|
||||
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
|
||||
@@ -884,7 +1132,14 @@ export default {
|
||||
tmp
|
||||
};
|
||||
return data;
|
||||
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
||||
},
|
||||
getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
||||
if (fav && rawUser) {
|
||||
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||
if (isPrivate && !isAllowed) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
const hall = rawHall || null;
|
||||
|
||||
@@ -924,9 +1179,9 @@ export default {
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
const modequery = computeBaseMode(mode, ratings, session);
|
||||
|
||||
let item;
|
||||
|
||||
@@ -938,6 +1193,7 @@ export default {
|
||||
WHERE
|
||||
${db.unsafe(modequery)}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
||||
AND items.title IS NOT NULL
|
||||
${mimeSQL}
|
||||
@@ -960,6 +1216,7 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and "user".user ilike ${user}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
group by items.id
|
||||
@@ -999,6 +1256,7 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${tagFilter}
|
||||
${user ? db`and items.username ilike ${user}` : db``}
|
||||
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
|
||||
@@ -1021,6 +1279,7 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and h.slug = ${hall}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
@@ -1037,6 +1296,7 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and uha.hall_id = ${userHallId}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
order by random()
|
||||
@@ -1045,9 +1305,9 @@ export default {
|
||||
} else {
|
||||
// Uniform random logic for global requests (no user/tag/hall)
|
||||
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
|
||||
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
// tagId optimisation only applies for single native modes (not multi-rating)
|
||||
const tagId = !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||
const globalModeQuery = modequery;
|
||||
// tagId optimisation only applies for single native modes for logged-in users (not multi-rating or guest mode)
|
||||
const tagId = session && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
|
||||
: null;
|
||||
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
||||
@@ -1063,6 +1323,7 @@ export default {
|
||||
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
|
||||
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
|
||||
WHERE items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||
@@ -1088,7 +1349,8 @@ export default {
|
||||
};
|
||||
},
|
||||
getComments: async (itemId, sort = 'new', process = true) => {
|
||||
if (!itemId) return [];
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!numericId) return [];
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
const comments = await db`
|
||||
@@ -1096,12 +1358,12 @@ export default {
|
||||
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
|
||||
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE c.item_id = ${itemId} AND c.is_deleted = false
|
||||
WHERE c.item_id = ${numericId} AND c.is_deleted = false
|
||||
ORDER BY COALESCE(c.is_pinned, false) DESC,
|
||||
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
|
||||
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
||||
@@ -1215,7 +1477,7 @@ export default {
|
||||
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -1244,11 +1506,12 @@ export default {
|
||||
}
|
||||
},
|
||||
getSubscriptionStatus: async (userId, itemId) => {
|
||||
if (!userId || !itemId) return false;
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return false;
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${itemId} AND is_subscribed = true`;
|
||||
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${itemId} in ${Date.now() - tStart}ms`);
|
||||
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${numericId} AND is_subscribed = true`;
|
||||
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${numericId} in ${Date.now() - tStart}ms`);
|
||||
return sub.length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -1256,9 +1519,10 @@ export default {
|
||||
},
|
||||
processMentions,
|
||||
markNotificationsRead: async (userId, itemId) => {
|
||||
if (!userId || !itemId) return;
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return;
|
||||
try {
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${itemId} AND is_read = false`;
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${numericId} AND is_read = false`;
|
||||
} catch (e) {
|
||||
console.error('[F0CKLIB] Error marking notifications as read:', e);
|
||||
}
|
||||
@@ -1575,8 +1839,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
computeBaseMode,
|
||||
getGlobalfilter,
|
||||
processMentions,
|
||||
processEmbeds,
|
||||
computeXdScore,
|
||||
xdScoreMeta,
|
||||
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
|
||||
clearCountCache: () => countCache.clear()
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -282,6 +282,10 @@ export default (router, tpl) => {
|
||||
totals: await lib.countf0cks(),
|
||||
session: req.session,
|
||||
manual_approval: getManualApproval(),
|
||||
min_tags: getMinTags(),
|
||||
trusted_uploads: getTrustedUploads(),
|
||||
registration_open: getRegistrationOpen(),
|
||||
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
|
||||
log_user_ips: getLogUserIps(),
|
||||
hash_user_ips: getHashUserIps(),
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
@@ -319,7 +323,7 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
||||
router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
||||
const userId = +req.params.userId;
|
||||
const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
|
||||
if (user.length === 0) return res.reply({ code: 404, body: 'User not found' });
|
||||
@@ -651,6 +655,77 @@ export default (router, tpl) => {
|
||||
return res.writeHead(302, { "Location": "/admin" }).end();
|
||||
});
|
||||
|
||||
// Config Manager API GET
|
||||
router.get(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
|
||||
const origin = req.headers?.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
const isDev = cfg.main?.development === true;
|
||||
if (!isDev && (!req.session || !req.session.admin)) {
|
||||
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
const json = JSON.parse(raw);
|
||||
if (res.json) return res.json({ success: true, config: json });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, config: json }));
|
||||
} catch (err) {
|
||||
if (res.json) return res.json({ success: false, msg: err.message });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// Config Manager API POST
|
||||
router.post(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
|
||||
const origin = req.headers?.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
const isDev = cfg.main?.development === true;
|
||||
if (!isDev && (!req.session || !req.session.admin)) {
|
||||
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||
}
|
||||
|
||||
try {
|
||||
let updatedConfig = req.post?.config || req.json?.config || req.post;
|
||||
if (typeof updatedConfig === 'string') {
|
||||
updatedConfig = JSON.parse(updatedConfig);
|
||||
}
|
||||
if (!updatedConfig || typeof updatedConfig !== 'object') {
|
||||
throw new Error('Invalid configuration payload');
|
||||
}
|
||||
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
|
||||
// Write formatted JSON to config.json on disk
|
||||
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
// Mutate in-memory cfg object so changes apply immediately
|
||||
Object.assign(cfg, updatedConfig);
|
||||
|
||||
if (getEnableItemSlugs()) {
|
||||
ensureAllItemsHaveSlugs();
|
||||
}
|
||||
|
||||
// Audit log entry (if session available)
|
||||
if (req.session?.id) {
|
||||
await audit.log(req.session.id, 'update_config_file', 'system', 0, { updatedKeys: Object.keys(updatedConfig) });
|
||||
}
|
||||
|
||||
const response = { success: true, message: 'Configuration saved to config.json' };
|
||||
if (res.json) return res.json(response);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] Config Save Error:', err);
|
||||
const response = { success: false, msg: err.message };
|
||||
if (res.json) return res.json(response, 400);
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
|
||||
}
|
||||
});
|
||||
|
||||
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
|
||||
if (!getEnableCleanup()) {
|
||||
return res.redirect("/admin");
|
||||
@@ -661,6 +736,7 @@ export default (router, tpl) => {
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
cleanup_start_date: getCleanupStartDate(),
|
||||
cleanup_end_date: getCleanupEndDate(),
|
||||
cleanup_include_engaged: getCleanupIncludeEngaged(),
|
||||
totals: await lib.countf0cks(),
|
||||
tmp: null
|
||||
};
|
||||
@@ -674,15 +750,18 @@ export default (router, tpl) => {
|
||||
try {
|
||||
const cleanup_start_date = req.post.cleanup_start_date || '';
|
||||
const cleanup_end_date = req.post.cleanup_end_date || '';
|
||||
const cleanup_include_engaged = req.post.cleanup_include_engaged === 'true' || req.post.cleanup_include_engaged === 'on' || req.post.cleanup_include_engaged === '1';
|
||||
|
||||
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_include_engaged', ${cleanup_include_engaged ? 'true' : 'false'}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
|
||||
setCleanupStartDate(cleanup_start_date);
|
||||
setCleanupEndDate(cleanup_end_date);
|
||||
setCleanupIncludeEngaged(cleanup_include_engaged);
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() });
|
||||
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), cleanup_include_engaged: getCleanupIncludeEngaged() });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
@@ -701,17 +780,23 @@ export default (router, tpl) => {
|
||||
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
// Ensure settings are synced from DB before execution
|
||||
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`;
|
||||
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date', 'cleanup_include_engaged')`;
|
||||
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
|
||||
|
||||
const isEnabled = settingsMap['enable_cleanup'] === 'true';
|
||||
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
|
||||
const startDate = settingsMap['cleanup_start_date'] || '';
|
||||
const endDate = settingsMap['cleanup_end_date'] || '';
|
||||
|
||||
const reqEngaged = req.post?.include_engaged;
|
||||
const includeEngaged = reqEngaged !== undefined
|
||||
? (reqEngaged === 'true' || reqEngaged === true || reqEngaged === 'on' || reqEngaged === '1')
|
||||
: (settingsMap['cleanup_include_engaged'] === 'true' || getCleanupIncludeEngaged());
|
||||
|
||||
// Update memory state
|
||||
setEnableCleanup(isEnabled);
|
||||
setCleanupStartDate(startDate);
|
||||
setCleanupEndDate(endDate);
|
||||
setCleanupIncludeEngaged(includeEngaged);
|
||||
|
||||
if (!isEnabled) {
|
||||
throw new Error('Cleanup is disabled in settings.');
|
||||
@@ -721,7 +806,7 @@ export default (router, tpl) => {
|
||||
throw new Error('Please select both a Start Date and an End Date.');
|
||||
}
|
||||
|
||||
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`);
|
||||
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate} (includeEngaged: ${includeEngaged})...`);
|
||||
|
||||
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
|
||||
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
|
||||
@@ -737,7 +822,7 @@ export default (router, tpl) => {
|
||||
AND i.is_pinned = false
|
||||
AND i.stamp >= ${start_stamp}
|
||||
AND i.stamp <= ${end_stamp}
|
||||
AND (
|
||||
${includeEngaged ? db`` : db`AND (
|
||||
-- Case 1: Active posts with no engagement (ignoring automatic subscriptions)
|
||||
(i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id))
|
||||
OR
|
||||
@@ -746,7 +831,7 @@ export default (router, tpl) => {
|
||||
OR
|
||||
-- Case 3: Pending posts (not yet approved) that are old enough
|
||||
(i.active = false AND i.is_deleted = false)
|
||||
)
|
||||
)`}
|
||||
`;
|
||||
|
||||
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
|
||||
@@ -764,12 +849,16 @@ export default (router, tpl) => {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
await db`DELETE FROM tags_assign WHERE item_id = ${item.id}`;
|
||||
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining orphan tag assignments for deleted or inactive items
|
||||
await db`DELETE FROM tags_assign WHERE item_id IN (SELECT id FROM items WHERE is_deleted = true OR active = false)`;
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
@@ -1428,8 +1517,8 @@ export default (router, tpl) => {
|
||||
const userId = newUser[0].id;
|
||||
|
||||
await db`
|
||||
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
||||
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
||||
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
|
||||
`;
|
||||
|
||||
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
|
||||
|
||||
@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
|
||||
import { createI18n } from "../i18n.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
||||
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
@@ -32,15 +32,16 @@ export default (router, tpl) => {
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
session: req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
@@ -170,7 +171,8 @@ export default (router, tpl) => {
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid
|
||||
id: itemid,
|
||||
slug: data.item?.slug || null
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -194,7 +196,7 @@ export default (router, tpl) => {
|
||||
|
||||
|
||||
// Infinite scroll endpoint for index thumbnails
|
||||
router.get(/\/ajax\/items/, async (req, res) => {
|
||||
router.get(/^\/ajax\/items/, async (req, res) => {
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
@@ -205,9 +207,16 @@ export default (router, tpl) => {
|
||||
|
||||
const page = parseInt(query.page) || 1;
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsRaw = query.ratings || req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const sortParam = query.sort || req.cookies.sort || 'newest';
|
||||
const timeframeParam = query.timeframe || req.cookies.timeframe || 'all';
|
||||
const ocParam = query.oc === '1' || query.oc === 'true';
|
||||
const pinnedParam = query.pinned === '1' || query.pinned === 'true';
|
||||
const hasCommentsParam = query.has_comments === '1' || query.has_comments === 'true' || query.hasComments === 'true';
|
||||
const layoutParam = query.layout || req.cookies.layout || 'grid';
|
||||
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: page,
|
||||
tag: query.tag || null,
|
||||
@@ -217,10 +226,16 @@ export default (router, tpl) => {
|
||||
userHallOwner: query.userHallOwner || null,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
sort: sortParam,
|
||||
timeframe: timeframeParam,
|
||||
oc: ocParam,
|
||||
pinned: pinnedParam,
|
||||
hasComments: hasCommentsParam,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
session: req.session,
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
fav: query.fav === 'true',
|
||||
random: isRandom,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
@@ -231,6 +246,19 @@ export default (router, tpl) => {
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
if (data.is_private) {
|
||||
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
is_private: true,
|
||||
html: privateHtml,
|
||||
hasMore: false
|
||||
})
|
||||
});
|
||||
}
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -244,7 +272,8 @@ export default (router, tpl) => {
|
||||
// Render just the thumbnail items
|
||||
const itemsHtml = tpl.render('snippets/items-grid', {
|
||||
items: data.items,
|
||||
link: data.link
|
||||
link: data.link,
|
||||
layout: layoutParam
|
||||
}, req);
|
||||
|
||||
// Render pagination
|
||||
@@ -267,6 +296,27 @@ export default (router, tpl) => {
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false
|
||||
}, req);
|
||||
|
||||
// Render filter bar
|
||||
const activeRatings = ratingsArr || ['sfw', 'nsfw', 'untagged'];
|
||||
const activeMimes = (query.mime || req.cookies.mime || '').split(',').filter(Boolean);
|
||||
|
||||
const filterBarHtml = tpl.render('snippets/filter-bar', {
|
||||
layout: layoutParam,
|
||||
sort: sortParam,
|
||||
timeframe: timeframeParam,
|
||||
oc: ocParam ? '1' : '0',
|
||||
pinned: pinnedParam ? '1' : '0',
|
||||
hasComments: hasCommentsParam ? '1' : '0',
|
||||
rating_sfw: activeRatings.includes('sfw'),
|
||||
rating_nsfw: activeRatings.includes('nsfw'),
|
||||
rating_nsfl: activeRatings.includes('nsfl'),
|
||||
rating_untagged: activeRatings.includes('untagged'),
|
||||
mime_image: activeMimes.includes('image'),
|
||||
mime_video: activeMimes.includes('video'),
|
||||
mime_audio: activeMimes.includes('audio'),
|
||||
mime_flash: activeMimes.includes('flash')
|
||||
}, req);
|
||||
|
||||
const hasMore = data.pagination.next !== null;
|
||||
|
||||
return res.reply({
|
||||
@@ -280,6 +330,7 @@ export default (router, tpl) => {
|
||||
success: true,
|
||||
html: itemsHtml,
|
||||
titleHtml: titleHtml,
|
||||
filterBarHtml: filterBarHtml,
|
||||
pagination: paginationHtml,
|
||||
hasMore: hasMore,
|
||||
nextPage: data.pagination.next,
|
||||
|
||||
@@ -2,15 +2,22 @@ import { promises as fs } from "fs";
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import search from '../../routeinc/search.mjs';
|
||||
import path from "path";
|
||||
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||
import audit from '../../audit.mjs';
|
||||
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
import { purgeExpiredUploads } from '../../lib_delete.mjs';
|
||||
import { calculateExpiresAt } from './upload.mjs';
|
||||
|
||||
const allowedMimes = ["audio", "image", "video", "%"];
|
||||
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const getGlobalfilter = () => {
|
||||
if (!cfg.nsfp?.length) return null;
|
||||
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
|
||||
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
};
|
||||
const metaCache = new Map();
|
||||
const MAX_META_CACHE = 2000;
|
||||
|
||||
@@ -594,10 +601,12 @@ export default router => {
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || []
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
});
|
||||
|
||||
if (!data.itemid) {
|
||||
if (!data || !data.itemid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
items: []
|
||||
@@ -636,6 +645,7 @@ export default router => {
|
||||
items: {
|
||||
...safeItem,
|
||||
id: item.id,
|
||||
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
||||
dest: relativeDest,
|
||||
url: directUrl,
|
||||
direct_url: directUrl
|
||||
@@ -700,10 +710,17 @@ export default router => {
|
||||
};
|
||||
|
||||
const excludedTags = req.session?.excluded_tags || [];
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (sessionUser
|
||||
? db`and (coalesce("items".visibility, 0) = 0 or lower("items".username) = ${sessionUser})`
|
||||
: db`and coalesce("items".visibility, 0) = 0`);
|
||||
|
||||
const newest = (await db`select max(id) as id from "items"`)[0].id;
|
||||
const oldest = (await db`select min(id) as id from "items"`)[0].id;
|
||||
const modequery = lib.getMode(opt.mode);
|
||||
const newest = (await db`select max(id) as id from "items"`)[0]?.id || 0;
|
||||
const oldest = (await db`select min(id) as id from "items"`)[0]?.id || 0;
|
||||
const modequery = f0cklib.computeBaseMode(opt.mode, null, req.session);
|
||||
|
||||
const rows = (await db`
|
||||
select "items".id, "items".mime, coalesce("tags_assign".tag_id, 0) as tag_id
|
||||
@@ -712,6 +729,7 @@ export default router => {
|
||||
where
|
||||
${db.unsafe(modequery)} and
|
||||
active = true
|
||||
${visibilityFilter}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = "items".id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
${opt.older
|
||||
? db`and id <= ${opt.older}`
|
||||
@@ -727,34 +745,25 @@ export default router => {
|
||||
`).sort((a, b) => b.id - a.id);
|
||||
|
||||
return res.json({
|
||||
atEnd: rows[0].id === newest,
|
||||
atStart: rows[rows.length - 1].id === oldest,
|
||||
atEnd: rows.length > 0 && rows[0].id === newest,
|
||||
atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
|
||||
success: true,
|
||||
items: rows
|
||||
}, 200);
|
||||
});
|
||||
|
||||
group.get(/\/item\/(?<id>[0-9]+)$/, async (req, res) => {
|
||||
const id = +req.params.id;
|
||||
group.get(/\/item\/(?<id>[a-zA-Z0-9_-]{11}|\d+)$/, async (req, res) => {
|
||||
const rawIdOrSlug = req.params.id;
|
||||
const isNumeric = /^\d+$/.test(rawIdOrSlug);
|
||||
const id = isNumeric ? +rawIdOrSlug : (await db`SELECT id FROM items WHERE slug = ${rawIdOrSlug} LIMIT 1`)[0]?.id;
|
||||
if (!id) {
|
||||
return res.json({ success: false, msg: 'no items found' });
|
||||
}
|
||||
|
||||
const item = await db`
|
||||
select *
|
||||
from "items"
|
||||
where id = ${+id} and active = true
|
||||
limit 1
|
||||
`;
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${+id} and active = true
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${+id} and active = true
|
||||
order by id desc
|
||||
where id = ${id} and active = true
|
||||
limit 1
|
||||
`;
|
||||
|
||||
@@ -765,12 +774,74 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
const actitem = item[0];
|
||||
const session = req.session;
|
||||
const isOwnerOrAdmin = session && (
|
||||
session.admin ||
|
||||
session.is_moderator ||
|
||||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase())
|
||||
);
|
||||
|
||||
// Exclude unlisted (1) and private (2) items from API responses for non-owners/non-admins
|
||||
if ((actitem.visibility || 0) > 0 && !isOwnerOrAdmin) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'no items found'
|
||||
});
|
||||
}
|
||||
|
||||
const effMode = req.query.mode !== undefined ? +req.query.mode : (req.mode ?? 3);
|
||||
const ratingsRaw = req.cookies?.ratings || req.query.ratings;
|
||||
const ratingsArr = (effMode === 3 || effMode === 2) ? null : (ratingsRaw ? (Array.isArray(ratingsRaw) ? ratingsRaw : decodeURIComponent(ratingsRaw).split(/[|,]/)).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
|
||||
let guestTagFilter = db``;
|
||||
if (!session) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`;
|
||||
} else {
|
||||
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`;
|
||||
}
|
||||
}
|
||||
|
||||
let modeCondition = db``;
|
||||
if (effMode === 2) {
|
||||
modeCondition = db`and not exists (select 1 from tags_assign where item_id = items.id)`;
|
||||
} else if (effMode === 0) {
|
||||
modeCondition = db`and (id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))`;
|
||||
} else if (effMode === 1) {
|
||||
modeCondition = db`and id in (select item_id from tags_assign where tag_id = 2)`;
|
||||
} else if (effMode === 4) {
|
||||
modeCondition = db`and id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`;
|
||||
} else if (ratingsArr && ratingsArr.length > 0) {
|
||||
modeCondition = db`and ${db.unsafe(lib.getMultiRatingMode(ratingsArr))}`;
|
||||
}
|
||||
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(visibility, 0) = 0 or lower(username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(visibility, 0) = 0`);
|
||||
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const rows = {
|
||||
...item[0],
|
||||
...{
|
||||
...actitem,
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
}
|
||||
};
|
||||
|
||||
return res.json({
|
||||
@@ -1038,7 +1109,24 @@ export default router => {
|
||||
});
|
||||
|
||||
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
|
||||
const postid = +req.post.postid;
|
||||
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
|
||||
if (rawPostid === undefined || rawPostid === null) {
|
||||
return res.json({ success: false, msg: 'Missing postid' }, 400);
|
||||
}
|
||||
|
||||
// Support both numeric item ID and string slug
|
||||
const isNumeric = /^\d+$/.test(String(rawPostid));
|
||||
const itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!itemRow.length) {
|
||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
}
|
||||
|
||||
const postid = itemRow[0].id;
|
||||
|
||||
// Check if already faved by this user — compare as numbers to avoid type mismatch
|
||||
const existing = await db`
|
||||
@@ -1066,7 +1154,7 @@ export default router => {
|
||||
}
|
||||
|
||||
const favs = await db`
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
@@ -1168,6 +1256,121 @@ export default router => {
|
||||
});
|
||||
});
|
||||
|
||||
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.enable_private_uploads === false) {
|
||||
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
|
||||
}
|
||||
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
|
||||
const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10);
|
||||
if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) {
|
||||
return res.json({ success: false, msg: 'Invalid parameters' }, 400);
|
||||
}
|
||||
|
||||
const isNumeric = /^\d+$/.test(String(postid));
|
||||
const item = await db`
|
||||
SELECT id, slug, username, visibility
|
||||
FROM items
|
||||
WHERE ${isNumeric ? db`id = ${+postid}` : db`slug = ${String(postid)}`} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (item.length === 0) {
|
||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
}
|
||||
|
||||
const isOwner = item[0].username === req.session.user;
|
||||
const isAdmin = req.session.admin || req.session.is_moderator;
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`;
|
||||
|
||||
f0cklib.clearCountCache();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
itemid: item[0].id,
|
||||
slug: item[0].slug,
|
||||
visibility: visibility
|
||||
});
|
||||
});
|
||||
|
||||
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
|
||||
const itemid = +req.params.id;
|
||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||
|
||||
const rows = await db`
|
||||
SELECT id, dest, mime, username
|
||||
FROM items
|
||||
WHERE id = ${itemid} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
|
||||
const item = rows[0];
|
||||
const isOwner = item.username === req.session.user;
|
||||
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||
|
||||
const ok = await queue.genThumbnail(item.dest, item.mime, item.id, '', false);
|
||||
await queue.genBlurredThumbnail(item.id, false);
|
||||
|
||||
if (ok) {
|
||||
db.notify('rethumb', JSON.stringify({ item_id: item.id })).catch(() => {});
|
||||
audit.log(req.session.id, 'rethumb_item', 'item', item.id, {}).catch(() => {});
|
||||
return res.json({ success: true, itemid: item.id });
|
||||
} else {
|
||||
return res.json({ success: false, msg: 'Thumbnail regeneration failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
|
||||
}
|
||||
|
||||
const itemid = +req.params.id;
|
||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||
|
||||
const rows = await db`
|
||||
SELECT id, username, expires_at
|
||||
FROM items
|
||||
WHERE id = ${itemid} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
|
||||
const item = rows[0];
|
||||
const isOwner = item.username === req.session.user;
|
||||
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||
|
||||
const reqExpiry = req.body?.expiry ?? req.body?.expires_at ?? req.post?.expiry ?? req.post?.expires_at;
|
||||
const nowStamp = Math.floor(Date.now() / 1000);
|
||||
const targetExpiresAt = calculateExpiresAt(reqExpiry, nowStamp);
|
||||
|
||||
await db`UPDATE items SET expires_at = ${targetExpiresAt} WHERE id = ${item.id}`;
|
||||
audit.log(req.session.id, 'set_item_expiry', 'item', item.id, { expires_at: targetExpiresAt }).catch(() => {});
|
||||
|
||||
if (targetExpiresAt && targetExpiresAt <= nowStamp) {
|
||||
await purgeExpiredUploads().catch(err => {
|
||||
console.error('[API ITEM EXPIRY] Purge failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const expires_in = targetExpiresAt ? lib.expiresIn(targetExpiresAt) : null;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
itemid: item.id,
|
||||
expires_at: targetExpiresAt,
|
||||
expires_in: expires_in,
|
||||
purged: !!(targetExpiresAt && targetExpiresAt <= nowStamp)
|
||||
});
|
||||
});
|
||||
|
||||
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
|
||||
const itemid = +req.params.id;
|
||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||
|
||||
@@ -315,6 +315,68 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update Favorites Privacy preference
|
||||
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
|
||||
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set favorites_private = ${favorites_private}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.favorites_private = favorites_private;
|
||||
return res.json({ success: true, favorites_private }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Favorites Privacy pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Hide Fav Badge preference
|
||||
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
|
||||
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set hide_fav_badge = ${hide_fav_badge}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
|
||||
return res.json({ success: true, hide_fav_badge }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Hide Fav Badge pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Default Upload Visibility preference
|
||||
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
|
||||
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
|
||||
}
|
||||
const vis = parseInt(req.post.default_upload_visibility, 10);
|
||||
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
|
||||
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set default_upload_visibility = ${vis}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.default_upload_visibility = vis;
|
||||
return res.json({ success: true, default_upload_visibility: vis }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Default Upload Visibility pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Username Color preference
|
||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||
const { color } = req.post;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* f0ckm API v2 — JSON endpoints for the new SvelteKit frontend.
|
||||
*
|
||||
* These routes live alongside the existing flummpress routes and are
|
||||
* purely additive — nothing in the old app is changed or removed.
|
||||
*
|
||||
* Mounted at: /api/v2/
|
||||
*/
|
||||
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
|
||||
const json = (res, body, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
export default (router, _tpl) => {
|
||||
|
||||
// ── GET /api/v2/session ───────────────────────────────────────────────────
|
||||
router.get('/api/v2/session', async (req, res) => {
|
||||
if (!req.session) {
|
||||
return json(res, { loggedIn: false, user: null });
|
||||
}
|
||||
return json(res, {
|
||||
loggedIn: true,
|
||||
user: {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
login: req.session.login,
|
||||
admin: !!req.session.admin,
|
||||
is_moderator: !!req.session.is_moderator,
|
||||
mode: req.session.mode ?? 3,
|
||||
theme: req.session.theme ?? 'f0ck',
|
||||
avatar: req.session.avatar ?? null,
|
||||
avatar_file: req.session.avatar_file ?? null,
|
||||
username_color: req.session.username_color ?? null,
|
||||
display_name: req.session.display_name ?? null,
|
||||
use_new_layout: req.session.use_new_layout ?? true,
|
||||
excluded_tags: req.session.excluded_tags ?? [],
|
||||
min_xd_score: req.session.min_xd_score ?? 0,
|
||||
uploads_remaining: req.session.uploads_remaining,
|
||||
pending_count: req.session.pending_count,
|
||||
language: req.session.language ?? null,
|
||||
disable_autoplay: !!req.session.disable_autoplay,
|
||||
disable_swiping: !!req.session.disable_swiping,
|
||||
csrf_token: req.session.csrf_token ?? ''
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /api/v2/items ─────────────────────────────────────────────────────
|
||||
router.get('/api/v2/items', async (req, res) => {
|
||||
const q = req.url.qs ?? {};
|
||||
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
|
||||
const ratingsArr = ratingsRaw
|
||||
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||
: null;
|
||||
|
||||
const mode = q.mode !== undefined ? +q.mode : req.mode;
|
||||
|
||||
try {
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: q.page ? +q.page : 1,
|
||||
mode,
|
||||
ratings: ratingsArr,
|
||||
mime: q.mime ?? (req.cookies?.mime ?? null),
|
||||
tag: q.tag ?? null,
|
||||
hall: q.hall ?? null,
|
||||
user: q.user ?? null,
|
||||
userHall: q.userHall ?? null,
|
||||
userHallOwner: q.userHallOwner ?? null,
|
||||
fav: q.fav === 'true',
|
||||
random: q.random === '1' || req.cookies?.random_mode === '1',
|
||||
strict: q.strict === '1' || q.strict === 'true' || !!req.session?.strict_mode,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id ?? null,
|
||||
exclude: req.session?.excluded_tags ?? [],
|
||||
minXdScore: req.session?.min_xd_score ?? 0,
|
||||
newer: q.newer ?? null,
|
||||
tagger: q.tagger ?? null
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return json(res, { success: false, items: [], pagination: null, total: 0, link: null });
|
||||
}
|
||||
|
||||
return json(res, {
|
||||
success: true,
|
||||
items: data.items,
|
||||
pagination: data.pagination,
|
||||
total: data.total,
|
||||
link: data.link
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[API v2] /items error:', e);
|
||||
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/v2/items/:id ─────────────────────────────────────────────────
|
||||
router.get(/^\/api\/v2\/items\/(?<itemid>\d+)$/, async (req, res) => {
|
||||
const itemid = req.params.itemid;
|
||||
const q = req.url.qs ?? {};
|
||||
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
|
||||
const ratingsArr = ratingsRaw
|
||||
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||
: null;
|
||||
|
||||
const mode = q.mode !== undefined ? +q.mode : req.mode;
|
||||
|
||||
try {
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid,
|
||||
mode,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id ?? null,
|
||||
user: q.user ?? null,
|
||||
tag: q.tag ?? null,
|
||||
hall: q.hall ?? null,
|
||||
userHall: q.userHall ?? null,
|
||||
userHallOwner: q.userHallOwner ?? null,
|
||||
mime: q.mime ?? (req.cookies?.mime ?? null),
|
||||
fav: q.fav === 'true',
|
||||
random: q.random === '1',
|
||||
strict: q.strict === '1' || !!req.session?.strict_mode,
|
||||
exclude: req.session?.excluded_tags ?? [],
|
||||
lang: req.lang ?? 'en'
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return json(res, { success: false, item: null, message: data.message }, 404);
|
||||
}
|
||||
|
||||
// Precompute hall display data (mirrors existing index route logic)
|
||||
if (data.item?.halls?.length) {
|
||||
const currentHallSlug = data.tmp?.hall
|
||||
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||
: null;
|
||||
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
|
||||
// Subscription status
|
||||
const isSubscribed = req.session
|
||||
? await f0cklib.getSubscriptionStatus(req.session.id, itemid).catch(() => false)
|
||||
: false;
|
||||
|
||||
// xD Score
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
const commentsForScore = await f0cklib.getComments(itemid, 'old', false);
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
} else {
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
|
||||
// Notify read
|
||||
if (req.session?.id) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
|
||||
return json(res, {
|
||||
success: true,
|
||||
item: data.item,
|
||||
pagination: data.pagination,
|
||||
link: data.link,
|
||||
isSubscribed
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[API v2] /items/:id error:', e);
|
||||
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/v2/random ────────────────────────────────────────────────────
|
||||
router.get('/api/v2/random', async (req, res) => {
|
||||
const ratingsRaw = req.cookies?.ratings ?? null;
|
||||
const ratingsArr = ratingsRaw
|
||||
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||
: null;
|
||||
|
||||
try {
|
||||
const data = await f0cklib.getRandom({
|
||||
mode: req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
mime: req.cookies?.mime ?? null
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return json(res, { success: false, message: data.message }, 404);
|
||||
}
|
||||
|
||||
const url = encodeURI(`${data.link.main}${data.link.path ?? ''}${data.itemid}${data.link.suffix ?? ''}`);
|
||||
return json(res, { success: true, id: data.itemid, url });
|
||||
} catch (e) {
|
||||
console.error('[API v2] /random error:', e);
|
||||
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user