Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1324512ebf |
@@ -1,9 +1,9 @@
|
||||
POSTGRES_USER=f0ckm
|
||||
POSTGRES_DB=f0ckm
|
||||
POSTGRES_PASSWORD=f0ckm
|
||||
# --- Nginx & Tor Profiles (Optional) ---
|
||||
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
|
||||
# COMPOSE_PROFILES=tor
|
||||
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
||||
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
||||
# COMPOSE_PROFILES=f0ckm-nginx
|
||||
#
|
||||
# VIRTUAL_HOST=yourdomain.com
|
||||
# LETSENCRYPT_HOST=yourdomain.com
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,4 +18,3 @@ public/tag_cache
|
||||
config.json
|
||||
bundle.css
|
||||
public/s/fonts/impact.woff
|
||||
f0ck-svelte/*
|
||||
20
README.md
20
README.md
@@ -78,23 +78,3 @@ 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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
client_max_body_size 10000M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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,7 +5,6 @@
|
||||
"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,
|
||||
@@ -29,11 +28,6 @@
|
||||
],
|
||||
"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",
|
||||
@@ -67,7 +61,6 @@
|
||||
"show_koepfe": false,
|
||||
"hide_sidebar_default": true,
|
||||
"koepfe": [],
|
||||
"enable_tor_hs": true,
|
||||
"enable_global_chat": true,
|
||||
"enable_danmaku": true,
|
||||
"private_messages": true,
|
||||
@@ -129,7 +122,6 @@
|
||||
"user_alternative_infobox": false,
|
||||
"user_banner_enabled": true,
|
||||
"user_alternative_steuerung": false,
|
||||
"expose_repost_links_to_guests": false,
|
||||
"enable_swf": false,
|
||||
"swf_thumb": "/s/img/swf.png",
|
||||
"enable_archive": true,
|
||||
@@ -140,8 +132,6 @@
|
||||
"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,7 +31,6 @@ 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/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
|
||||
environment:
|
||||
@@ -50,14 +49,9 @@ services:
|
||||
tor:
|
||||
image: dockurr/tor
|
||||
container_name: tor
|
||||
profiles:
|
||||
- tor
|
||||
ports:
|
||||
- "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
|
||||
- "9050:9050"
|
||||
- "8118:8118"
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: always
|
||||
@@ -103,17 +97,19 @@ services:
|
||||
f0ckm-nginx:
|
||||
image: nginxproxy/nginx-proxy:latest
|
||||
container_name: f0ckm-nginx
|
||||
network_mode: "host"
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- vhost:/etc/nginx/vhost.d:rw
|
||||
- html:/usr/share/nginx/html:rw
|
||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: unless-stopped
|
||||
|
||||
acme-companion:
|
||||
|
||||
23
f0ck-svelte/.gitignore
vendored
Normal file
23
f0ck-svelte/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
f0ck-svelte/.npmrc
Normal file
1
f0ck-svelte/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
3
f0ck-svelte/.vscode/extensions.json
vendored
Normal file
3
f0ck-svelte/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
42
f0ck-svelte/README.md
Normal file
42
f0ck-svelte/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.16.3 create --template minimal --types ts --install npm .
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
2486
f0ck-svelte/package-lock.json
generated
Normal file
2486
f0ck-svelte/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
f0ck-svelte/package.json
Normal file
28
f0ck-svelte/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "f0ck-svelte",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"daisyui": "^5.6.18",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
77
f0ck-svelte/src/app.css
Normal file
77
f0ck-svelte/src/app.css
Normal file
@@ -0,0 +1,77 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes: f0ck --default, light;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* f0ck dark theme — AMOLED-inspired */
|
||||
--color-base-100: oklch(10% 0.02 265);
|
||||
--color-base-200: oklch(13% 0.02 265);
|
||||
--color-base-300: oklch(16% 0.02 265);
|
||||
--color-base-content: oklch(92% 0.01 265);
|
||||
|
||||
--color-primary: oklch(55% 0.22 265);
|
||||
--color-primary-content: oklch(98% 0.01 265);
|
||||
|
||||
--color-secondary: oklch(60% 0.18 300);
|
||||
--color-secondary-content: oklch(98% 0.01 265);
|
||||
|
||||
--color-accent: oklch(72% 0.15 290);
|
||||
--color-accent-content: oklch(10% 0.02 265);
|
||||
|
||||
--color-neutral: oklch(20% 0.02 265);
|
||||
--color-neutral-content: oklch(85% 0.01 265);
|
||||
|
||||
--color-info: oklch(70% 0.15 220);
|
||||
--color-success: oklch(70% 0.15 160);
|
||||
--color-warning: oklch(78% 0.18 80);
|
||||
--color-error: oklch(63% 0.22 25);
|
||||
|
||||
/* Rating colors */
|
||||
--color-sfw: oklch(70% 0.15 160);
|
||||
--color-nsfw: oklch(65% 0.20 25);
|
||||
--color-nsfl: oklch(60% 0.22 0);
|
||||
}
|
||||
|
||||
/* ── Global resets & base styles ───────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: oklch(30% 0.02 265); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: oklch(40% 0.02 265); }
|
||||
|
||||
/* ── Rating badge utility classes ─────────────────────────────────────── */
|
||||
.badge-sfw { background: oklch(70% 0.15 160 / 20%); color: oklch(70% 0.15 160); border-color: oklch(70% 0.15 160 / 40%); }
|
||||
.badge-nsfw { background: oklch(65% 0.20 25 / 20%); color: oklch(65% 0.20 25); border-color: oklch(65% 0.20 25 / 40%); }
|
||||
.badge-nsfl { background: oklch(55% 0.22 0 / 20%); color: oklch(55% 0.22 0); border-color: oklch(55% 0.22 0 / 40%); }
|
||||
.badge-unt { background: oklch(55% 0.02 265 / 20%); color: oklch(65% 0.02 265); border-color: oklch(55% 0.02 265 / 40%); }
|
||||
|
||||
/* ── Grid thumb skeleton pulse ─────────────────────────────────────────── */
|
||||
@keyframes thumb-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
.thumb-skeleton { animation: thumb-pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
/* ── Fade-in for navigations ───────────────────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.page-enter { animation: fade-in 0.15s ease-out; }
|
||||
21
f0ck-svelte/src/app.d.ts
vendored
Normal file
21
f0ck-svelte/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
import type { SessionUser } from '$lib/types/session';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
interface Locals {
|
||||
session: SessionUser | null;
|
||||
}
|
||||
interface PageData {
|
||||
session: SessionUser | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
f0ck-svelte/src/app.html
Normal file
12
f0ck-svelte/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
41
f0ck-svelte/src/hooks.server.ts
Normal file
41
f0ck-svelte/src/hooks.server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Server-side hook: read session from the existing f0ckm backend
|
||||
* by forwarding the session cookie on every SSR request.
|
||||
*
|
||||
* This keeps auth as a single source of truth in the backend.
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Forward cookies to the backend session endpoint
|
||||
const cookieHeader = event.request.headers.get('cookie') ?? '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiBase(event)}/api/v2/session`, {
|
||||
headers: { cookie: cookieHeader }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
event.locals.session = data.loggedIn ? data.user : null;
|
||||
} else {
|
||||
event.locals.session = null;
|
||||
}
|
||||
} catch {
|
||||
// Backend not reachable (dev without backend running)
|
||||
event.locals.session = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the API base URL for server-side fetches.
|
||||
* In production the SvelteKit server and the API backend run on separate ports.
|
||||
* The VITE_API_URL env var (or API_URL for SSR) sets the backend address.
|
||||
*/
|
||||
function getApiBase(event: Parameters<Handle>[0]['event']): string {
|
||||
// SSR: use internal backend address (avoids going through Nginx proxy)
|
||||
const internal = process.env.API_URL ?? 'http://localhost:1337';
|
||||
return internal;
|
||||
}
|
||||
132
f0ck-svelte/src/lib/api/actions.ts
Normal file
132
f0ck-svelte/src/lib/api/actions.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
import { csrfFetch } from '$lib/utils/csrfFetch';
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CommentsResponse {
|
||||
success: boolean;
|
||||
comments: Comment[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
itemId: number,
|
||||
fetchFn = fetch
|
||||
): Promise<CommentsResponse> {
|
||||
const res = await fetchFn(`/api/comments/${itemId}`);
|
||||
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function postComment(payload: {
|
||||
itemId: number;
|
||||
content: string;
|
||||
replyTo?: number | null;
|
||||
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
item_id: String(payload.itemId),
|
||||
content: payload.content,
|
||||
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
|
||||
});
|
||||
const res = await csrfFetch('/api/comments', { method: 'POST', body });
|
||||
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
commentId: number
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
// Backend uses POST /api/comments/:id/delete (not DELETE method)
|
||||
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FavResponse {
|
||||
success: boolean;
|
||||
itemid: number;
|
||||
favs: Array<{
|
||||
user: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function toggleFav(itemId: number): Promise<FavResponse> {
|
||||
const body = new URLSearchParams({ postid: String(itemId) });
|
||||
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
|
||||
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagsResponse {
|
||||
success: boolean;
|
||||
tags: import('$lib/types/item').Tag[];
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||
const body = new URLSearchParams({ tagname });
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
|
||||
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CycleRatingResponse {
|
||||
success: boolean;
|
||||
rating_tag_id: number;
|
||||
rating_label: 'SFW' | 'NSFW' | 'NSFL';
|
||||
rating_class: 'sfw' | 'nsfw' | 'nsfl';
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
|
||||
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
|
||||
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) throw new Error('suggestTags failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Item management (mod/admin) ───────────────────────────────────────────────
|
||||
|
||||
export async function deleteItem(
|
||||
itemId: number,
|
||||
reason = ''
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
const body = new URLSearchParams({ postid: String(itemId), reason });
|
||||
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
|
||||
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||
// Backend reads id from query string: req.url.qs.id
|
||||
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
29
f0ck-svelte/src/lib/api/auth.ts
Normal file
29
f0ck-svelte/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { SessionResponse } from '$lib/types/session';
|
||||
|
||||
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
|
||||
const res = await fetchFn('/api/v2/session');
|
||||
if (!res.ok) return { loggedIn: false, user: null };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
kmsi = false
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'include'
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch('/logout', { method: 'POST', credentials: 'include' });
|
||||
}
|
||||
96
f0ck-svelte/src/lib/api/items.ts
Normal file
96
f0ck-svelte/src/lib/api/items.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
|
||||
|
||||
export interface GetItemsParams {
|
||||
page?: number;
|
||||
mode?: number;
|
||||
ratings?: string;
|
||||
mime?: string;
|
||||
tag?: string;
|
||||
hall?: string;
|
||||
user?: string;
|
||||
fav?: boolean;
|
||||
random?: boolean;
|
||||
strict?: boolean;
|
||||
min_xd?: number;
|
||||
newer?: number;
|
||||
tagger?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of grid items.
|
||||
* Called from +page.ts (server-side) and from the client for infinite-scroll.
|
||||
*/
|
||||
export async function getItems(
|
||||
params: GetItemsParams,
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<ItemsResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||
if (params.ratings) qs.set('ratings', params.ratings);
|
||||
if (params.mime) qs.set('mime', params.mime);
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.hall) qs.set('hall', params.hall);
|
||||
if (params.user) qs.set('user', params.user);
|
||||
if (params.fav) qs.set('fav', 'true');
|
||||
if (params.random) qs.set('random', '1');
|
||||
if (params.strict) qs.set('strict', '1');
|
||||
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
|
||||
if (params.newer) qs.set('newer', String(params.newer));
|
||||
if (params.tagger) qs.set('tagger', params.tagger);
|
||||
|
||||
const res = await fetchFn(`/api/v2/items?${qs}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface GetItemParams {
|
||||
mode?: number;
|
||||
ratings?: string;
|
||||
tag?: string;
|
||||
hall?: string;
|
||||
user?: string;
|
||||
fav?: boolean;
|
||||
random?: boolean;
|
||||
strict?: boolean;
|
||||
mime?: string;
|
||||
userHall?: string;
|
||||
userHallOwner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single item with its neighbours and metadata.
|
||||
*/
|
||||
export async function getItem(
|
||||
id: number | string,
|
||||
params: GetItemParams = {},
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<ItemResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||
if (params.ratings) qs.set('ratings', params.ratings);
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.hall) qs.set('hall', params.hall);
|
||||
if (params.user) qs.set('user', params.user);
|
||||
if (params.fav) qs.set('fav', 'true');
|
||||
if (params.random) qs.set('random', '1');
|
||||
if (params.strict) qs.set('strict', '1');
|
||||
if (params.mime) qs.set('mime', params.mime);
|
||||
if (params.userHall) qs.set('userHall', params.userHall);
|
||||
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
|
||||
|
||||
const suffix = qs.toString() ? `?${qs}` : '';
|
||||
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random item ID (optionally within a context).
|
||||
* Returns the JSON response; caller should navigate to response.url.
|
||||
*/
|
||||
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
|
||||
const res = await fetchFn('/api/v2/random');
|
||||
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
1
f0ck-svelte/src/lib/assets/favicon.svg
Normal file
1
f0ck-svelte/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { getComments, postComment, deleteComment } from '$lib/api/actions';
|
||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { timeAgo, formatDate } from '$lib/utils/timeago';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
|
||||
interface Props {
|
||||
itemId: number;
|
||||
}
|
||||
let { itemId }: Props = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<Comment[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let text = $state('');
|
||||
let replyTo = $state<Comment | null>(null);
|
||||
let submitting = $state(false);
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await getComments(itemId);
|
||||
comments = data.comments ?? [];
|
||||
} catch {
|
||||
error = 'Failed to load comments.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run on mount and when itemId changes
|
||||
$effect(() => { load(); });
|
||||
|
||||
// ── Live updates via SSE ──────────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type !== 'comments') return;
|
||||
const d = event.data as { item_id: number; type?: string };
|
||||
if (d.item_id !== itemId) return;
|
||||
// Reload the full comment list on any comment activity for this item
|
||||
load();
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
// ── Post comment ──────────────────────────────────────────────────────────
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await postComment({
|
||||
itemId,
|
||||
content: text.trim(),
|
||||
replyTo: replyTo?.id ?? null
|
||||
});
|
||||
if (res.success) {
|
||||
text = '';
|
||||
replyTo = null;
|
||||
await load();
|
||||
flash('Comment posted', 'success');
|
||||
} else {
|
||||
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete comment ────────────────────────────────────────────────────────
|
||||
async function handleDelete(comment: Comment) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
try {
|
||||
const res = await deleteComment(comment.id);
|
||||
if (res.success) {
|
||||
await load();
|
||||
flash('Deleted', 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to delete', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function canDelete(comment: Comment) {
|
||||
return $session && ($isModOrAdmin || $session.user === comment.username);
|
||||
}
|
||||
|
||||
function avatarSrc(comment: Comment): string {
|
||||
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
|
||||
if (comment.avatar) return `/t/${comment.avatar}.webp`;
|
||||
return '/a/default.png';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
|
||||
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
|
||||
</span>
|
||||
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
|
||||
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment form -->
|
||||
{#if $session}
|
||||
<form onsubmit={submit} class="flex flex-col gap-2">
|
||||
{#if replyTo}
|
||||
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
|
||||
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
|
||||
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
|
||||
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
placeholder="Write a comment…"
|
||||
rows="2"
|
||||
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
|
||||
disabled={submitting}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm self-end"
|
||||
disabled={submitting || !text.trim()}
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="text-xs opacity-40">
|
||||
<a href="/login" class="link link-primary">Log in</a> to comment.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Comments list -->
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-4">
|
||||
<span class="loading loading-dots loading-sm opacity-40"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-xs text-error opacity-70">{error}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each comments as comment (comment.id)}
|
||||
<li
|
||||
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
|
||||
id="comment-{comment.id}"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
|
||||
<div class="avatar">
|
||||
<div class="w-7 rounded-full border border-base-300">
|
||||
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-1.5 flex-wrap">
|
||||
<a
|
||||
href="/user/{comment.username?.toLowerCase()}"
|
||||
class="font-semibold text-xs hover:text-primary transition-colors"
|
||||
style={comment.username_color ? `color: ${comment.username_color}` : ''}
|
||||
>
|
||||
{comment.display_name || comment.username}
|
||||
</a>
|
||||
<time
|
||||
datetime={comment.stamp_full}
|
||||
title={formatDate(comment.stamp_full)}
|
||||
class="text-xs opacity-40"
|
||||
>
|
||||
{comment.stamp}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if comment.is_deleted}
|
||||
<p class="text-xs italic opacity-50">— deleted —</p>
|
||||
{:else}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
|
||||
{@html comment.content}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !comment.is_deleted}
|
||||
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if $session}
|
||||
<button
|
||||
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
|
||||
class="btn btn-ghost btn-xs gap-1 text-xs"
|
||||
>
|
||||
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
|
||||
</button>
|
||||
{/if}
|
||||
{#if canDelete(comment)}
|
||||
<button
|
||||
onclick={() => handleDelete(comment)}
|
||||
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
83
f0ck-svelte/src/lib/components/item/ItemCard.svelte
Normal file
83
f0ck-svelte/src/lib/components/item/ItemCard.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import type { ItemLink } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
item: GridItem;
|
||||
link: ItemLink;
|
||||
}
|
||||
let { item, link }: Props = $props();
|
||||
|
||||
// Build the item URL respecting the current filter context
|
||||
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
|
||||
const thumbUrl = $derived(`/t/${item.id}.webp`);
|
||||
|
||||
// Rating badge
|
||||
const ratingClass = $derived(
|
||||
item.tag === 'NSFL' ? 'badge-nsfl' :
|
||||
item.tag === 'NSFW' ? 'badge-nsfw' :
|
||||
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={itemUrl}
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="
|
||||
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
|
||||
hover:border-primary/50 transition-all duration-200
|
||||
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
|
||||
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
|
||||
"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-video w-full overflow-hidden bg-base-300">
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt="Item {item.id}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Overlay on hover -->
|
||||
<div class="
|
||||
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||
flex flex-col justify-end p-2 gap-1
|
||||
">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if item.is_oc}
|
||||
<span class="badge badge-xs badge-accent">OC</span>
|
||||
{/if}
|
||||
{#if item.is_pinned}
|
||||
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
|
||||
{/if}
|
||||
{#if item.xd_tier > 0}
|
||||
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if item.display_name || item.username}
|
||||
<span class="text-xs text-base-content/60 truncate">
|
||||
{item.display_name || item.username}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rating badge (always visible) -->
|
||||
{#if item.tag}
|
||||
<div class="absolute top-1.5 right-1.5">
|
||||
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notification dot -->
|
||||
{#if item.has_notification}
|
||||
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
{/if}
|
||||
</a>
|
||||
503
f0ck-svelte/src/lib/components/item/ItemView.svelte
Normal file
503
f0ck-svelte/src/lib/components/item/ItemView.svelte
Normal file
@@ -0,0 +1,503 @@
|
||||
<script lang="ts">
|
||||
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
|
||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
|
||||
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
|
||||
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
|
||||
import TagList from '$lib/components/item/TagList.svelte';
|
||||
import Comments from '$lib/components/item/Comments.svelte';
|
||||
import { mimeCategory } from '$lib/utils/mimeHelpers';
|
||||
import { formatDate } from '$lib/utils/timeago';
|
||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||
import { goto, preloadData } from '$app/navigation';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { broadcastPin } from '$lib/stores/pin';
|
||||
import {
|
||||
toggleFav, addTag, deleteTag,
|
||||
deleteItem, pinItem, unpinItem, cycleRating
|
||||
} from '$lib/api/actions';
|
||||
|
||||
interface Props {
|
||||
item: FullItem;
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
isSubscribed?: boolean;
|
||||
}
|
||||
let { item, pagination, link, isSubscribed = false }: Props = $props();
|
||||
|
||||
// ── Reactive local state (mutable copies updated after actions) ───────────
|
||||
let favorites = $state(item.favorites);
|
||||
let tags = $state(item.tags);
|
||||
let isPinned = $state(item.is_pinned);
|
||||
let isOc = $state(item.is_oc);
|
||||
let isSfw = $state(item.is_sfw);
|
||||
let isNsfw = $state(item.is_nsfw);
|
||||
let isNsfl = $state(item.is_nsfl);
|
||||
|
||||
// Sync when item prop changes (navigation)
|
||||
$effect(() => {
|
||||
favorites = item.favorites;
|
||||
tags = item.tags;
|
||||
isPinned = item.is_pinned;
|
||||
isOc = item.is_oc;
|
||||
isSfw = item.is_sfw;
|
||||
isNsfw = item.is_nsfw;
|
||||
isNsfl = item.is_nsfl;
|
||||
});
|
||||
|
||||
// ── Live SSE updates ──────────────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'favorites') {
|
||||
const d = event.data as { item_id: number; favs: FavoriteUser[] };
|
||||
if (d.item_id === item.id) favorites = d.favs;
|
||||
} else if (event.type === 'tags') {
|
||||
const d = event.data as { item_id: number; tags: Tag[] };
|
||||
if (d.item_id === item.id) {
|
||||
tags = d.tags;
|
||||
// Re-derive rating state from the fresh tag list
|
||||
const SFW_ID = 1, NSFW_ID = 2;
|
||||
const ids = d.tags.map(t => t.id);
|
||||
isSfw = ids.includes(SFW_ID);
|
||||
isNsfw = ids.includes(NSFW_ID);
|
||||
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
|
||||
}
|
||||
} else if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
if (d.id === item.id) goto('/');
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
const cat = $derived(mimeCategory(item.mime));
|
||||
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
|
||||
|
||||
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
|
||||
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
|
||||
|
||||
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
|
||||
|
||||
// Rating derived from local mutable state (so cycle updates instantly)
|
||||
const ratingClass = $derived(
|
||||
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
|
||||
);
|
||||
const ratingLabel = $derived(
|
||||
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
|
||||
);
|
||||
|
||||
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
|
||||
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
|
||||
|
||||
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
|
||||
let ratingCycling = $state(false);
|
||||
async function handleCycleRating() {
|
||||
if (!isMod) return;
|
||||
ratingCycling = true;
|
||||
try {
|
||||
const res = await cycleRating(item.id);
|
||||
if (res.success) {
|
||||
isSfw = res.rating_class === 'sfw';
|
||||
isNsfw = res.rating_class === 'nsfw';
|
||||
isNsfl = res.rating_class === 'nsfl';
|
||||
flash(`Rating → ${res.rating_label}`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to cycle rating', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
ratingCycling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard navigation ───────────────────────────────────────────────────
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
|
||||
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
|
||||
}
|
||||
|
||||
// ── Tag input state ───────────────────────────────────────────────────────
|
||||
let tagInput = $state('');
|
||||
let tagAdding = $state(false);
|
||||
let showTagInput = $state(false);
|
||||
let suggestions = $state<string[]>([]);
|
||||
|
||||
async function submitTag(e: Event) {
|
||||
e.preventDefault();
|
||||
const name = tagInput.trim();
|
||||
if (!name) return;
|
||||
tagAdding = true;
|
||||
try {
|
||||
const res = await addTag(item.id, name);
|
||||
if (res.success) {
|
||||
tags = res.tags;
|
||||
tagInput = '';
|
||||
showTagInput = false;
|
||||
flash(`Tag "${name}" added`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to add tag', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
tagAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTag(tag: Tag) {
|
||||
try {
|
||||
const res = await deleteTag(item.id, tag.tag);
|
||||
if (res.success) {
|
||||
tags = res.tags;
|
||||
flash(`Tag "${tag.tag}" removed`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to remove tag', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favorite ──────────────────────────────────────────────────────────────
|
||||
let favLoading = $state(false);
|
||||
async function handleFav() {
|
||||
if (!$session) { flash('Log in to favorite', 'warning'); return; }
|
||||
favLoading = true;
|
||||
try {
|
||||
const res = await toggleFav(item.id);
|
||||
if (res.success) favorites = res.favs;
|
||||
} catch {
|
||||
flash('Failed to update favorite', 'error');
|
||||
} finally {
|
||||
favLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function handleDelete() {
|
||||
const reason = prompt('Reason for deletion (optional):') ?? '';
|
||||
if (reason === null) return; // cancelled
|
||||
try {
|
||||
const res = await deleteItem(item.id, reason);
|
||||
if (res.success) {
|
||||
flash('Item deleted', 'success');
|
||||
goto('/');
|
||||
} else {
|
||||
flash(res.msg ?? 'Delete failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pin ───────────────────────────────────────────────────────────────────
|
||||
async function handlePin() {
|
||||
try {
|
||||
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
|
||||
if (res.success) {
|
||||
isPinned = res.pinned;
|
||||
broadcastPin(item.id, res.pinned); // patch grid card live
|
||||
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
|
||||
} else {
|
||||
flash('Pin failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
|
||||
|
||||
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
|
||||
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
|
||||
|
||||
<!-- Tags card -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3 gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
|
||||
{#if $session}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => showTagInput = !showTagInput}
|
||||
aria-label="Add tag"
|
||||
>
|
||||
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tag input -->
|
||||
{#if showTagInput}
|
||||
<form onsubmit={submitTag} class="flex gap-1">
|
||||
<input
|
||||
bind:value={tagInput}
|
||||
type="text"
|
||||
placeholder="tag name…"
|
||||
class="input input-xs input-bordered flex-1"
|
||||
disabled={tagAdding}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-xs btn-primary"
|
||||
disabled={tagAdding || !tagInput.trim()}
|
||||
>
|
||||
{#if tagAdding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
OK
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if tags.length > 0}
|
||||
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
|
||||
{:else}
|
||||
<p class="text-xs opacity-40">No tags yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- xD Score -->
|
||||
{#if item.xd_tier > 0}
|
||||
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
|
||||
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
|
||||
{item.xd_label}
|
||||
<span class="opacity-70 text-xs">{item.xd_score}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3">
|
||||
<Comments itemId={item.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
|
||||
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
|
||||
|
||||
{#if item.title}
|
||||
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Media + prev/next arrows -->
|
||||
<div class="relative group">
|
||||
<!-- Prev -->
|
||||
<a
|
||||
href={prevUrl ?? '#'}
|
||||
aria-disabled={!prevUrl}
|
||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
{!prevUrl ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="rounded-lg overflow-hidden bg-base-300">
|
||||
{#if cat === 'youtube'}
|
||||
<YouTubeEmbed videoId={ytId} />
|
||||
{:else if cat === 'video'}
|
||||
<VideoPlayer src={item.dest} mime={item.mime} />
|
||||
{:else if cat === 'audio'}
|
||||
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
|
||||
{:else if cat === 'image'}
|
||||
<ImageViewer src={item.dest} alt="Item {item.id}" />
|
||||
{:else if cat === 'pdf'}
|
||||
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center p-12 gap-4">
|
||||
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
|
||||
<a href={item.dest} download class="btn btn-primary gap-2">
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next -->
|
||||
<a
|
||||
href={nextUrl ?? '#'}
|
||||
aria-disabled={!nextUrl}
|
||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
{!nextUrl ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Metadata / action bar -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3 gap-2">
|
||||
|
||||
<!-- Prev / Random / Next -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
|
||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
|
||||
</a>
|
||||
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
|
||||
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
|
||||
</a>
|
||||
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
|
||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- Item metadata row -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
|
||||
#{item.id}
|
||||
</a>
|
||||
{#if isMod}
|
||||
<button
|
||||
onclick={handleCycleRating}
|
||||
disabled={ratingCycling}
|
||||
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
|
||||
title="Cycle rating: {ratingLabel} → {isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
|
||||
aria-label="Cycle rating"
|
||||
>
|
||||
{#if ratingCycling}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
|
||||
{/if}
|
||||
{ratingLabel}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
|
||||
{/if}
|
||||
<a href="/user/{item.username.toLowerCase()}"
|
||||
class="font-medium hover:text-primary transition-colors"
|
||||
style={item.author_color ? `color: ${item.author_color}` : ''}>
|
||||
{item.author_display_name || item.username}
|
||||
</a>
|
||||
{#if isOc}
|
||||
<span class="badge badge-sm badge-accent">OC</span>
|
||||
{/if}
|
||||
{#if isPinned}
|
||||
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
|
||||
{/if}
|
||||
{#if item.primaryHall}
|
||||
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
|
||||
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
|
||||
{item.primaryHall.name}
|
||||
</a>
|
||||
{/if}
|
||||
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
|
||||
{item.timestamp.timeago}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center flex-wrap gap-1 pt-1">
|
||||
<!-- Favorite -->
|
||||
<button
|
||||
onclick={handleFav}
|
||||
disabled={favLoading || !$session}
|
||||
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
|
||||
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
|
||||
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
|
||||
>
|
||||
{#if favLoading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
|
||||
{/if}
|
||||
{#if favorites.length > 0}
|
||||
<span class="text-xs">{favorites.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Subscribe (stub) -->
|
||||
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
|
||||
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Link copy -->
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Copy link"
|
||||
aria-label="Copy link"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(location.href);
|
||||
flash('Link copied!', 'success');
|
||||
}}
|
||||
>
|
||||
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mod / owner actions -->
|
||||
{#if canManage}
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<!-- Pin/Unpin -->
|
||||
<button
|
||||
onclick={handlePin}
|
||||
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
|
||||
title="{isPinned ? 'Unpin' : 'Pin'}"
|
||||
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
|
||||
>
|
||||
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorites avatars -->
|
||||
{#if favorites.length > 0}
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
{#each favorites as fav (fav.user)}
|
||||
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
|
||||
<div class="avatar">
|
||||
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
|
||||
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
|
||||
{#if fav.avatar_file}
|
||||
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
|
||||
{:else if fav.avatar}
|
||||
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
|
||||
{:else}
|
||||
<img src="/a/default.png" alt={fav.user} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
37
f0ck-svelte/src/lib/components/item/TagList.svelte
Normal file
37
f0ck-svelte/src/lib/components/item/TagList.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
canRemove?: boolean;
|
||||
onRemove?: (tag: Tag) => void;
|
||||
}
|
||||
let { tags, canRemove = false, onRemove }: Props = $props();
|
||||
|
||||
// Map badge class names from the existing tag badge values
|
||||
const badgeClass = (badge: string) => {
|
||||
if (badge === 'badge-sfw') return 'badge-sfw';
|
||||
if (badge === 'badge-nsfw') return 'badge-nsfw';
|
||||
if (badge === 'badge-nsfl') return 'badge-nsfl';
|
||||
return 'badge-neutral';
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each tags as tag (tag.id)}
|
||||
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
|
||||
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
|
||||
{tag.tag}
|
||||
</a>
|
||||
{#if canRemove && onRemove}
|
||||
<button
|
||||
class="hover:text-error ml-0.5 transition-colors"
|
||||
onclick={() => onRemove?.(tag)}
|
||||
aria-label="Remove tag {tag.tag}"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-[10px]"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
100
f0ck-svelte/src/lib/components/layout/Navbar.svelte
Normal file
100
f0ck-svelte/src/lib/components/layout/Navbar.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { session, loggedIn } from '$lib/stores/session';
|
||||
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getRandom } from '$lib/api/items';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
async function goRandom() {
|
||||
try {
|
||||
const res = await getRandom();
|
||||
if (res.success) goto(res.url);
|
||||
} catch {
|
||||
flash('Could not fetch a random item', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
|
||||
// OR just use them in the template with $ prefix — both work, but $ in templates
|
||||
// is fine for Svelte writable stores even in runes mode.
|
||||
</script>
|
||||
|
||||
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
|
||||
<!-- Logo / Home -->
|
||||
<div class="navbar-start gap-2">
|
||||
<a href="/" class="flex items-center gap-2 group">
|
||||
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
|
||||
f0ckm
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: actions -->
|
||||
<div class="navbar-center gap-1 hidden sm:flex">
|
||||
<!-- Random button -->
|
||||
<button
|
||||
class="btn btn-ghost btn-sm gap-1"
|
||||
onclick={goRandom}
|
||||
title="Random item"
|
||||
aria-label="Random item"
|
||||
>
|
||||
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
|
||||
<span class="hidden md:inline">Random</span>
|
||||
</button>
|
||||
|
||||
<!-- Rating filter badge -->
|
||||
<div class="dropdown dropdown-bottom">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
|
||||
aria-label="Content filter"
|
||||
>
|
||||
{$filterLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: user area -->
|
||||
<div class="navbar-end gap-2">
|
||||
{#if $loggedIn}
|
||||
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
|
||||
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
|
||||
<span class="hidden sm:inline">Upload</span>
|
||||
</a>
|
||||
<!-- Avatar / user menu -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
|
||||
{#if $session?.avatar_file}
|
||||
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
|
||||
{:else if $session?.avatar}
|
||||
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
|
||||
{:else}
|
||||
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
|
||||
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
|
||||
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
|
||||
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
|
||||
{#if $session?.admin || $session?.is_moderator}
|
||||
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
|
||||
{/if}
|
||||
<li class="divider my-1"></li>
|
||||
<li>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="text-error w-full text-left">
|
||||
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
|
||||
<a href="/register" class="btn btn-primary btn-sm">Register</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
24
f0ck-svelte/src/lib/components/media/AudioPlayer.svelte
Normal file
24
f0ck-svelte/src/lib/components/media/AudioPlayer.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
mime: string;
|
||||
coverart?: string;
|
||||
}
|
||||
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
|
||||
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
|
||||
>
|
||||
<!-- Dark overlay over coverart -->
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<audio
|
||||
{src}
|
||||
loop
|
||||
controls
|
||||
preload="auto"
|
||||
class="relative z-10 w-full max-w-md"
|
||||
></audio>
|
||||
</div>
|
||||
58
f0ck-svelte/src/lib/components/media/ImageViewer.svelte
Normal file
58
f0ck-svelte/src/lib/components/media/ImageViewer.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
}
|
||||
let { src, alt = 'Image' }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let imgEl = $state<HTMLImageElement | undefined>();
|
||||
|
||||
function toggleExpand() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
// ESC to close modal
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded) expanded = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
{src}
|
||||
{alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onclick={toggleExpand}
|
||||
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
|
||||
/>
|
||||
<button
|
||||
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
|
||||
onclick={toggleExpand}
|
||||
aria-label="Expand"
|
||||
>
|
||||
<i class="fa-solid fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen modal -->
|
||||
{#if expanded}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
|
||||
onclick={toggleExpand}
|
||||
>
|
||||
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
|
||||
<button
|
||||
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
|
||||
onclick={toggleExpand}
|
||||
aria-label="Close"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
36
f0ck-svelte/src/lib/components/media/VideoPlayer.svelte
Normal file
36
f0ck-svelte/src/lib/components/media/VideoPlayer.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
mime: string;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
let { src, mime, autoplay = false }: Props = $props();
|
||||
|
||||
let videoEl = $state<HTMLVideoElement | undefined>();
|
||||
|
||||
// Keyboard shortcut: space to toggle play/pause
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.target !== document.body) return;
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
else videoEl?.pause();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
{src}
|
||||
{autoplay}
|
||||
loop
|
||||
playsinline
|
||||
controls
|
||||
class="max-h-[80dvh] w-full object-contain"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
18
f0ck-svelte/src/lib/components/media/YouTubeEmbed.svelte
Normal file
18
f0ck-svelte/src/lib/components/media/YouTubeEmbed.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
videoId: string; // e.g. "dQw4w9WgXcQ"
|
||||
}
|
||||
let { videoId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
|
||||
<iframe
|
||||
class="absolute inset-0 w-full h-full"
|
||||
src="https://www.youtube.com/embed/{videoId}"
|
||||
title="YouTube video"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
42
f0ck-svelte/src/lib/components/ui/FlashMessages.svelte
Normal file
42
f0ck-svelte/src/lib/components/ui/FlashMessages.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
|
||||
|
||||
const iconMap: Record<FlashType, string> = {
|
||||
info: 'fa-solid fa-circle-info',
|
||||
success: 'fa-solid fa-circle-check',
|
||||
warning: 'fa-solid fa-triangle-exclamation',
|
||||
error: 'fa-solid fa-circle-xmark'
|
||||
};
|
||||
|
||||
const alertMap: Record<FlashType, string> = {
|
||||
info: 'alert-info',
|
||||
success: 'alert-success',
|
||||
warning: 'alert-warning',
|
||||
error: 'alert-error'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
||||
{#each $flashes as msg (msg.id)}
|
||||
<div
|
||||
role="alert"
|
||||
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
|
||||
animate-[slide-up_0.2s_ease-out]"
|
||||
>
|
||||
<i class="{iconMap[msg.type]}"></i>
|
||||
<span class="flex-1 text-sm">{msg.text}</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => dismiss(msg.id)}
|
||||
aria-label="Dismiss"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
62
f0ck-svelte/src/lib/components/ui/Pagination.svelte
Normal file
62
f0ck-svelte/src/lib/components/ui/Pagination.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Pagination, ItemLink } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
}
|
||||
let { pagination, link }: Props = $props();
|
||||
|
||||
const base = $derived(link.main + link.path);
|
||||
const suffix = $derived(link.suffix ?? '');
|
||||
</script>
|
||||
|
||||
{#if pagination.end > 1}
|
||||
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
|
||||
<!-- Prev -->
|
||||
<a
|
||||
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
|
||||
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
|
||||
aria-disabled={!pagination.prev}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
<!-- First page -->
|
||||
{#if pagination.cheat[0] > 1}
|
||||
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
|
||||
{#if pagination.cheat[0] > 2}
|
||||
<span class="px-1 opacity-40">…</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Page range -->
|
||||
{#each pagination.cheat as p}
|
||||
<a
|
||||
href="{base}{p}{suffix}"
|
||||
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Last page -->
|
||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
|
||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
|
||||
<span class="px-1 opacity-40">…</span>
|
||||
{/if}
|
||||
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
|
||||
{/if}
|
||||
|
||||
<!-- Next -->
|
||||
<a
|
||||
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
|
||||
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
|
||||
aria-disabled={!pagination.next}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
1
f0ck-svelte/src/lib/index.ts
Normal file
1
f0ck-svelte/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
28
f0ck-svelte/src/lib/stores/flash.ts
Normal file
28
f0ck-svelte/src/lib/stores/flash.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── Flash message type ────────────────────────────────────────────────────────
|
||||
export type FlashType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface FlashMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: FlashType;
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
export const flashes = writable<FlashMessage[]>([]);
|
||||
|
||||
/**
|
||||
* Show a flash message. Auto-dismisses after `duration` ms.
|
||||
* Returns the message ID so callers can dismiss early if needed.
|
||||
*/
|
||||
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
|
||||
const id = ++_nextId;
|
||||
flashes.update((q) => [...q, { id, text, type }]);
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismiss(id: number): void {
|
||||
flashes.update((q) => q.filter((m) => m.id !== id));
|
||||
}
|
||||
20
f0ck-svelte/src/lib/stores/pin.ts
Normal file
20
f0ck-svelte/src/lib/stores/pin.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Lightweight broadcast store for pin/unpin events.
|
||||
* ItemView fires it after a successful pin action;
|
||||
* the main grid subscribes to patch the item card immediately.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface PinEvent {
|
||||
itemId: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
// Null when idle, set to an event when pin/unpin succeeds
|
||||
export const pinEvent = writable<PinEvent | null>(null);
|
||||
|
||||
export function broadcastPin(itemId: number, pinned: boolean) {
|
||||
pinEvent.set({ itemId, pinned });
|
||||
// Reset so subsequent identical events still trigger subscribers
|
||||
setTimeout(() => pinEvent.set(null), 0);
|
||||
}
|
||||
50
f0ck-svelte/src/lib/stores/preferences.ts
Normal file
50
f0ck-svelte/src/lib/stores/preferences.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
|
||||
// ── Content mode ─────────────────────────────────────────────────────────────
|
||||
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
|
||||
export const activeMode = writable<number>(3);
|
||||
|
||||
// ── Multi-rating filter ───────────────────────────────────────────────────────
|
||||
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
|
||||
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
|
||||
export const activeRatings = writable<Rating[]>([]);
|
||||
|
||||
// ── MIME filter ───────────────────────────────────────────────────────────────
|
||||
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
|
||||
export const activeMimes = writable<MimeFilter[]>([]);
|
||||
|
||||
// ── Derived: ratings query param string for API calls ─────────────────────────
|
||||
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
|
||||
|
||||
// ── Derived: readable label for the filter badge ─────────────────────────────
|
||||
export const filterLabel = derived(
|
||||
[activeMode, activeRatings],
|
||||
([$mode, $ratings]) => {
|
||||
if ($ratings.length > 0) {
|
||||
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
|
||||
return $ratings.map((r) => abbr[r]).join('+');
|
||||
}
|
||||
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
|
||||
return labels[$mode] ?? 'ALL';
|
||||
}
|
||||
);
|
||||
|
||||
// ── Persistence helpers ───────────────────────────────────────────────────────
|
||||
// Call these after receiving the session from the server to hydrate from cookie/session.
|
||||
export function initPreferences(opts: { mode: number; ratings?: string }) {
|
||||
activeMode.set(opts.mode ?? 3);
|
||||
if (opts.ratings) {
|
||||
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
|
||||
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
|
||||
);
|
||||
activeRatings.set(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a mode number → server mode number (for API calls when using multi-rating). */
|
||||
export function getServerMode(): number {
|
||||
const ratings = get(activeRatings);
|
||||
if (ratings.length === 0) return get(activeMode);
|
||||
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
|
||||
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
|
||||
}
|
||||
11
f0ck-svelte/src/lib/stores/session.ts
Normal file
11
f0ck-svelte/src/lib/stores/session.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { SessionUser } from '$lib/types/session';
|
||||
|
||||
/** The current authenticated user, or null if not logged in. */
|
||||
export const session = writable<SessionUser | null>(null);
|
||||
|
||||
/** Convenience derived — true when logged in. */
|
||||
export const loggedIn = derived(session, ($s) => $s !== null);
|
||||
|
||||
/** True when the user has admin or moderator role. */
|
||||
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));
|
||||
126
f0ck-svelte/src/lib/stores/sse.ts
Normal file
126
f0ck-svelte/src/lib/stores/sse.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* SSE store — connects to the backend's /api/notifications/stream endpoint.
|
||||
*
|
||||
* Emits typed events that components subscribe to with `onSSE()`.
|
||||
* Auto-reconnects with exponential backoff on disconnect.
|
||||
* Client-only: never runs during SSR.
|
||||
*/
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// ── Event types broadcast by the backend ─────────────────────────────────────
|
||||
export interface SSEComment {
|
||||
type: 'comment' | 'delete';
|
||||
id?: number;
|
||||
item_id: number;
|
||||
comment_id?: number;
|
||||
body?: string;
|
||||
username?: string;
|
||||
display_name?: string | null;
|
||||
avatar?: string | null;
|
||||
avatar_file?: string | null;
|
||||
username_color?: string | null;
|
||||
created_at?: string;
|
||||
parent_id?: number | null;
|
||||
xd_score?: number | null;
|
||||
}
|
||||
|
||||
export interface SSEFavorites {
|
||||
item_id: number;
|
||||
favs: Array<{
|
||||
user: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SSETags {
|
||||
item_id: number;
|
||||
fresh: boolean;
|
||||
tags: unknown[];
|
||||
}
|
||||
|
||||
export interface SSENewItem {
|
||||
id: number;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
export interface SSEDeleteItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
|
||||
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Connection state ──────────────────────────────────────────────────────────
|
||||
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
export const sseState = writable<SSEState>('disconnected');
|
||||
|
||||
// ── Listener registry ─────────────────────────────────────────────────────────
|
||||
type SSEListener = (event: SSEEvent) => void;
|
||||
const listeners = new Set<SSEListener>();
|
||||
|
||||
/** Subscribe to SSE events. Returns an unsubscribe function. */
|
||||
export function onSSE(fn: SSEListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// ── Internal connection state ─────────────────────────────────────────────────
|
||||
let es: EventSource | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryDelay = 2000;
|
||||
const MAX_RETRY = 30_000;
|
||||
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
|
||||
|
||||
function dispatch(raw: SSEEvent) {
|
||||
for (const fn of listeners) {
|
||||
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connect, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
|
||||
}
|
||||
|
||||
export function connect() {
|
||||
if (!browser) return;
|
||||
if (es) { es.close(); es = null; }
|
||||
|
||||
sseState.set('connecting');
|
||||
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
|
||||
|
||||
es.onopen = () => {
|
||||
sseState.set('connected');
|
||||
retryDelay = 2000; // reset backoff on successful connect
|
||||
};
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as SSEEvent;
|
||||
dispatch(data);
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
sseState.set('error');
|
||||
es?.close();
|
||||
es = null;
|
||||
scheduleRetry();
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
es?.close();
|
||||
es = null;
|
||||
sseState.set('disconnected');
|
||||
}
|
||||
81
f0ck-svelte/src/lib/stores/ws.ts
Normal file
81
f0ck-svelte/src/lib/stores/ws.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── WebSocket message types ───────────────────────────────────────────────────
|
||||
export type WSMessage =
|
||||
| { type: 'pong' }
|
||||
| { type: 'notification'; count: number; item?: unknown }
|
||||
| { type: 'comment:new'; itemId: number; comment: unknown }
|
||||
| { type: 'comment:delete'; itemId: number; commentId: number }
|
||||
| { type: 'item:tag_added'; itemId: number; tag: unknown }
|
||||
| { type: 'item:favorited'; itemId: number; user: string }
|
||||
| { type: 'global:activity'; payload: unknown };
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
export const wsConnected = writable(false);
|
||||
|
||||
let _socket: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _reconnectDelay = 1000;
|
||||
|
||||
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
|
||||
|
||||
/** Connect to the WebSocket server. Call once from the root layout. */
|
||||
export function wsConnect(): void {
|
||||
if (typeof window === 'undefined') return; // SSR guard
|
||||
if (_socket?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_socket = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
|
||||
_socket.onopen = () => {
|
||||
wsConnected.set(true);
|
||||
_reconnectDelay = 1000; // reset backoff on successful connect
|
||||
_ping();
|
||||
};
|
||||
|
||||
_socket.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data) as WSMessage;
|
||||
// Dispatch to typed listeners
|
||||
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
|
||||
_listeners.get('*')?.forEach((fn) => fn(msg));
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
_socket.onclose = () => {
|
||||
wsConnected.set(false);
|
||||
_socket = null;
|
||||
// Exponential backoff reconnect
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
||||
wsConnect();
|
||||
}, _reconnectDelay);
|
||||
};
|
||||
|
||||
_socket.onerror = () => {
|
||||
_socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a typed message to the server. */
|
||||
export function wsSend(msg: object): void {
|
||||
if (_socket?.readyState === WebSocket.OPEN) {
|
||||
_socket.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to a specific message type. Returns an unsubscribe function. */
|
||||
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
|
||||
if (!_listeners.has(type)) _listeners.set(type, new Set());
|
||||
_listeners.get(type)!.add(fn);
|
||||
return () => _listeners.get(type)?.delete(fn);
|
||||
}
|
||||
|
||||
let _pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function _ping() {
|
||||
if (_pingTimer) clearInterval(_pingTimer);
|
||||
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
|
||||
}
|
||||
21
f0ck-svelte/src/lib/types/comment.ts
Normal file
21
f0ck-svelte/src/lib/types/comment.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface Comment {
|
||||
id: number;
|
||||
item_id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
content: string; // may be HTML (server-rendered)
|
||||
stamp: string; // human-readable relative time
|
||||
stamp_full: string; // ISO timestamp
|
||||
is_deleted: boolean;
|
||||
reply_to: number | null;
|
||||
poll?: unknown;
|
||||
}
|
||||
|
||||
export interface CommentsResponse {
|
||||
success: boolean;
|
||||
comments: Comment[];
|
||||
total?: number;
|
||||
}
|
||||
137
f0ck-svelte/src/lib/types/item.ts
Normal file
137
f0ck-svelte/src/lib/types/item.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
|
||||
export interface GridItem {
|
||||
id: number;
|
||||
mime: string;
|
||||
dest: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
is_pinned: boolean;
|
||||
is_oc: boolean;
|
||||
xd_score: number;
|
||||
xd_tier: number;
|
||||
xd_label: string;
|
||||
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
|
||||
tag_id: number | null;
|
||||
thumb_size: 1 | 2;
|
||||
has_notification: boolean;
|
||||
}
|
||||
|
||||
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
|
||||
export interface FullItem {
|
||||
id: number;
|
||||
mime: string;
|
||||
dest: string;
|
||||
username: string;
|
||||
author_id: number | null;
|
||||
author_color: string | null;
|
||||
author_display_name: string | null;
|
||||
author_avatar: string | null;
|
||||
author_avatar_file: string | null;
|
||||
title: string | null;
|
||||
src: { long: string; short: string };
|
||||
thumbnail: string;
|
||||
og_thumbnail: string;
|
||||
og_description: string;
|
||||
coverart: string;
|
||||
size: string;
|
||||
checksum: string | null;
|
||||
timestamp: { timeago: string; timefull: string };
|
||||
favorites: FavoriteUser[];
|
||||
tags: Tag[];
|
||||
halls: Hall[];
|
||||
user_halls: Hall[];
|
||||
is_nsfw: boolean;
|
||||
is_nsfl: boolean;
|
||||
is_sfw: boolean;
|
||||
is_pinned: boolean;
|
||||
is_comments_locked: boolean;
|
||||
is_oc: boolean;
|
||||
is_repost: boolean;
|
||||
reposts: RepostItem[];
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
original_filename: string | null;
|
||||
xd_score: number;
|
||||
xd_tier: number;
|
||||
xd_label: string;
|
||||
primaryHall: Hall | null;
|
||||
otherHalls: Hall[];
|
||||
}
|
||||
|
||||
// ── Pagination ───────────────────────────────────────────────────────────────
|
||||
export interface Pagination {
|
||||
prev: number | null;
|
||||
next: number | null;
|
||||
start: number;
|
||||
end: number;
|
||||
current: number;
|
||||
page: number;
|
||||
cheat: number[];
|
||||
location?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
// ── Link context ─────────────────────────────────────────────────────────────
|
||||
export interface ItemLink {
|
||||
main: string;
|
||||
mainDisplay?: string;
|
||||
path: string;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
// ── Tags ─────────────────────────────────────────────────────────────────────
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tag: string;
|
||||
normalized: string;
|
||||
badge: string;
|
||||
display_name: string | null;
|
||||
user: string;
|
||||
}
|
||||
|
||||
// ── Halls ────────────────────────────────────────────────────────────────────
|
||||
export interface Hall {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||
export interface FavoriteUser {
|
||||
user: string;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
// ── Reposts ──────────────────────────────────────────────────────────────────
|
||||
export interface RepostItem {
|
||||
id: number;
|
||||
username: string;
|
||||
stamp: number;
|
||||
match_type: 'checksum' | 'phash';
|
||||
}
|
||||
|
||||
// ── API response wrappers ─────────────────────────────────────────────────────
|
||||
export interface ItemsResponse {
|
||||
success: boolean;
|
||||
items: GridItem[];
|
||||
pagination: Pagination;
|
||||
total: number;
|
||||
link: ItemLink;
|
||||
}
|
||||
|
||||
export interface ItemResponse {
|
||||
success: boolean;
|
||||
item: FullItem | null;
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
isSubscribed: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RandomResponse {
|
||||
success: boolean;
|
||||
id: number;
|
||||
url: string;
|
||||
}
|
||||
27
f0ck-svelte/src/lib/types/session.ts
Normal file
27
f0ck-svelte/src/lib/types/session.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
user: string;
|
||||
login: string;
|
||||
admin: boolean;
|
||||
is_moderator: boolean;
|
||||
mode: number;
|
||||
theme: string;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
display_name: string | null;
|
||||
use_new_layout: boolean;
|
||||
excluded_tags: number[];
|
||||
min_xd_score: number;
|
||||
uploads_remaining: number | undefined;
|
||||
pending_count?: number;
|
||||
language: string | null;
|
||||
disable_autoplay: boolean;
|
||||
disable_swiping: boolean;
|
||||
csrf_token: string;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
loggedIn: boolean;
|
||||
user: SessionUser | null;
|
||||
}
|
||||
23
f0ck-svelte/src/lib/utils/csrfFetch.ts
Normal file
23
f0ck-svelte/src/lib/utils/csrfFetch.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Wrapper around fetch that automatically injects the CSRF token as a header
|
||||
* on all state-changing requests (POST, PUT, PATCH, DELETE).
|
||||
*
|
||||
* The token is read from the session store at call time so it's always fresh.
|
||||
*/
|
||||
import { get } from 'svelte/store';
|
||||
import { session } from '$lib/stores/session';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
if (SAFE_METHODS.has(method)) return fetch(input, init);
|
||||
|
||||
const token = get(session)?.csrf_token;
|
||||
if (!token) return fetch(input, init); // not logged in — let backend return 401
|
||||
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
headers.set('X-CSRF-Token', token);
|
||||
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
32
f0ck-svelte/src/lib/utils/mimeHelpers.ts
Normal file
32
f0ck-svelte/src/lib/utils/mimeHelpers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Map a MIME type to a simple category string. */
|
||||
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
|
||||
if (mime === 'video/youtube') return 'youtube';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime === 'application/pdf') return 'pdf';
|
||||
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
|
||||
if (mime.startsWith('application/')) return 'archive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Return a Font Awesome icon class for a MIME category. */
|
||||
export function mimeIcon(mime: string): string {
|
||||
const cat = mimeCategory(mime);
|
||||
const map: Record<string, string> = {
|
||||
youtube: 'fa-brands fa-youtube',
|
||||
video: 'fa-solid fa-film',
|
||||
audio: 'fa-solid fa-music',
|
||||
image: 'fa-solid fa-image',
|
||||
flash: 'fa-solid fa-bolt',
|
||||
pdf: 'fa-solid fa-file-pdf',
|
||||
archive: 'fa-solid fa-file-zipper',
|
||||
unknown: 'fa-solid fa-file'
|
||||
};
|
||||
return map[cat] ?? map.unknown;
|
||||
}
|
||||
|
||||
/** True if the MIME type represents inline-playable media. */
|
||||
export function isPlayable(mime: string): boolean {
|
||||
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
|
||||
}
|
||||
45
f0ck-svelte/src/lib/utils/timeago.ts
Normal file
45
f0ck-svelte/src/lib/utils/timeago.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
|
||||
* relative time string, e.g. "3 hours ago".
|
||||
*/
|
||||
export function timeAgo(input: number | string | Date): string {
|
||||
const date =
|
||||
input instanceof Date
|
||||
? input
|
||||
: typeof input === 'number'
|
||||
? new Date(input * 1000)
|
||||
: new Date(input);
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
|
||||
const epochs: [string, number][] = [
|
||||
['year', 365 * 24 * 3600 * 1000],
|
||||
['month', 30 * 24 * 3600 * 1000],
|
||||
['week', 7 * 24 * 3600 * 1000],
|
||||
['day', 24 * 3600 * 1000],
|
||||
['hour', 3600 * 1000],
|
||||
['minute', 60 * 1000],
|
||||
['second', 1000]
|
||||
];
|
||||
|
||||
for (const [label, ms] of epochs) {
|
||||
const n = Math.floor(diff / ms);
|
||||
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp to a readable full date string.
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
8
f0ck-svelte/src/routes/+layout.server.ts
Normal file
8
f0ck-svelte/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
/** Pass session to all pages via PageData. */
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
56
f0ck-svelte/src/routes/+layout.svelte
Normal file
56
f0ck-svelte/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/layout/Navbar.svelte';
|
||||
import FlashMessages from '$lib/components/ui/FlashMessages.svelte';
|
||||
import { session as sessionStore } from '$lib/stores/session';
|
||||
import { initPreferences } from '$lib/stores/preferences';
|
||||
import { connect as sseConnect, disconnect as sseDisconnect } from '$lib/stores/sse';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
session: import('$lib/types/session').SessionUser | null;
|
||||
};
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
// Hydrate session store from server-loaded data
|
||||
$effect(() => {
|
||||
sessionStore.set(data.session);
|
||||
});
|
||||
|
||||
// Init user preferences from session
|
||||
$effect(() => {
|
||||
if (data.session) {
|
||||
initPreferences({ mode: data.session.mode ?? 3 });
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
sseConnect();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
sseDisconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>f0ckm</title>
|
||||
<meta name="description" content="f0ckm — media sharing" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh flex flex-col" data-theme="f0ck">
|
||||
<Navbar />
|
||||
<div class="flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<!-- Global flash messages -->
|
||||
<FlashMessages />
|
||||
</div>
|
||||
30
f0ck-svelte/src/routes/+page.server.ts
Normal file
30
f0ck-svelte/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItems } from '$lib/api/items';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
const page = Number(url.searchParams.get('page') ?? 1);
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
try {
|
||||
const data = await getItems({ page, mode }, fetch);
|
||||
if (data.success) {
|
||||
return {
|
||||
items: data.items,
|
||||
pagination: data.pagination,
|
||||
total: data.total,
|
||||
link: data.link
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not yet running or API route not registered — return empty state
|
||||
// so the page renders rather than hard-erroring.
|
||||
console.warn('[/] Failed to load items:', (e as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
items: [],
|
||||
pagination: null,
|
||||
total: 0,
|
||||
link: { main: '/', path: 'p/', suffix: '' }
|
||||
};
|
||||
};
|
||||
79
f0ck-svelte/src/routes/+page.svelte
Normal file
79
f0ck-svelte/src/routes/+page.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { pinEvent } from '$lib/stores/pin';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Local mutable copy of the items list — SSE patches it live
|
||||
let items = $state<GridItem[]>(data.items);
|
||||
let total = $state(data.total);
|
||||
|
||||
// Re-sync when server data changes (e.g. navigation / invalidation)
|
||||
$effect(() => {
|
||||
items = data.items;
|
||||
total = data.total;
|
||||
});
|
||||
|
||||
// ── Live grid updates via SSE ─────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'new_item') {
|
||||
const d = event.data as GridItem & { dest: string; username: string };
|
||||
if (!items.some(i => i.id === d.id)) {
|
||||
items = [d, ...items];
|
||||
total += 1;
|
||||
}
|
||||
} else if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
const before = items.length;
|
||||
items = items.filter(i => i.id !== d.id);
|
||||
if (items.length < before) total = Math.max(0, total - 1);
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
// ── Pin store: patch the grid card is_pinned live ─────────────────────────
|
||||
const unsubPin = pinEvent.subscribe((evt) => {
|
||||
if (!evt) return;
|
||||
items = items.map(i => i.id === evt.itemId ? { ...i, is_pinned: evt.pinned } : i);
|
||||
});
|
||||
onDestroy(unsubPin);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>f0ckm — {total} items</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
||||
<!-- Grid header -->
|
||||
{#if total > 0}
|
||||
<p class="text-xs opacity-40 mb-3 font-mono">{total.toLocaleString()} items</p>
|
||||
{/if}
|
||||
|
||||
<!-- Thumbnail grid -->
|
||||
{#if items.length > 0}
|
||||
<div class="
|
||||
grid gap-1.5
|
||||
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
||||
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
||||
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
||||
">
|
||||
{#each items as item (item.id)}
|
||||
<ItemCard {item} link={data.link} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.pagination}
|
||||
<Pagination pagination={data.pagination} link={data.link} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
||||
<i class="fa-solid fa-box-open text-5xl"></i>
|
||||
<p class="text-sm">No items found.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
28
f0ck-svelte/src/routes/[itemid]/+page.server.ts
Normal file
28
f0ck-svelte/src/routes/[itemid]/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItem } from '$lib/api/items';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch, locals, url }) => {
|
||||
const id = params.itemid;
|
||||
if (!id || !/^\d+$/.test(id)) throw error(404, 'Not found');
|
||||
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
try {
|
||||
const data = await getItem(id, { mode }, fetch);
|
||||
|
||||
if (!data.success || !data.item) {
|
||||
throw error(404, 'Item not found or not visible in current mode');
|
||||
}
|
||||
|
||||
return {
|
||||
item: data.item,
|
||||
pagination: data.pagination,
|
||||
link: data.link,
|
||||
isSubscribed: data.isSubscribed
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Response) throw e;
|
||||
throw error(500, 'Failed to load item');
|
||||
}
|
||||
};
|
||||
32
f0ck-svelte/src/routes/[itemid]/+page.svelte
Normal file
32
f0ck-svelte/src/routes/[itemid]/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ItemView from '$lib/components/item/ItemView.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const ogTitle = $derived(data.item.title ?? `#${data.item.id} — f0ckm`);
|
||||
const ogImage = $derived(data.item.og_thumbnail);
|
||||
const ogDesc = $derived(data.item.og_description ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{ogTitle}</title>
|
||||
<meta name="description" content={ogDesc} />
|
||||
<!-- OG tags for embeds -->
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:description" content={ogDesc} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:type" content="article" />
|
||||
<!-- Twitter card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={ogDesc} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
</svelte:head>
|
||||
|
||||
<ItemView
|
||||
item={data.item}
|
||||
pagination={data.pagination}
|
||||
link={data.link}
|
||||
isSubscribed={data.isSubscribed}
|
||||
/>
|
||||
9
f0ck-svelte/src/routes/login/+page.server.ts
Normal file
9
f0ck-svelte/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Stub page for /login — will be built in Sprint 2.
|
||||
// For now redirect to the legacy login page so auth still works.
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Temporary: redirect to legacy backend login until we build the Svelte login form
|
||||
throw redirect(307, 'http://localhost:1337/login');
|
||||
};
|
||||
16
f0ck-svelte/src/routes/random/+page.server.ts
Normal file
16
f0ck-svelte/src/routes/random/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getRandom } from '$lib/api/items';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
try {
|
||||
const data = await getRandom(fetch);
|
||||
if (data.success && data.url) {
|
||||
throw redirect(307, data.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Response) throw e;
|
||||
}
|
||||
// Fallback: go home if no random item found
|
||||
throw redirect(307, '/');
|
||||
};
|
||||
6
f0ck-svelte/src/routes/register/+page.server.ts
Normal file
6
f0ck-svelte/src/routes/register/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
throw redirect(307, 'http://localhost:1337/register');
|
||||
};
|
||||
31
f0ck-svelte/src/routes/tag/[tag]/+page.server.ts
Normal file
31
f0ck-svelte/src/routes/tag/[tag]/+page.server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItems } from '$lib/api/items';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, params, locals }) => {
|
||||
const tag = decodeURIComponent(params.tag);
|
||||
const page = Number(url.searchParams.get('page') ?? 1);
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
try {
|
||||
const data = await getItems({ page, mode, tag }, fetch);
|
||||
if (data.success) {
|
||||
return {
|
||||
tag,
|
||||
items: data.items,
|
||||
pagination: data.pagination,
|
||||
total: data.total,
|
||||
link: data.link
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[/tag/${tag}] Failed to load items:`, (e as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
tag,
|
||||
items: [],
|
||||
pagination: null,
|
||||
total: 0,
|
||||
link: { main: `/tag/${encodeURIComponent(tag)}/`, path: 'p/', suffix: '' }
|
||||
};
|
||||
};
|
||||
76
f0ck-svelte/src/routes/tag/[tag]/+page.svelte
Normal file
76
f0ck-svelte/src/routes/tag/[tag]/+page.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let items = $state<GridItem[]>(data.items);
|
||||
let total = $state(data.total);
|
||||
|
||||
$effect(() => {
|
||||
items = data.items;
|
||||
total = data.total;
|
||||
});
|
||||
|
||||
// Live: remove items that get deleted
|
||||
// (we don't prepend new_item because it may not match this tag filter)
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
const before = items.length;
|
||||
items = items.filter(i => i.id !== d.id);
|
||||
if (items.length < before) total = Math.max(0, total - 1);
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{data.tag} — f0ckm</title>
|
||||
<meta name="description" content="Items tagged #{data.tag} on f0ckm" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
||||
|
||||
<!-- Tag header -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-tag text-primary opacity-70" aria-hidden="true"></i>
|
||||
<h1 class="text-xl font-bold">{data.tag}</h1>
|
||||
</div>
|
||||
{#if total > 0}
|
||||
<span class="badge badge-neutral font-mono text-xs">{total.toLocaleString()}</span>
|
||||
{/if}
|
||||
<a href="/" class="btn btn-ghost btn-xs ml-auto opacity-60 hover:opacity-100 gap-1">
|
||||
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
{#if items.length > 0}
|
||||
<div class="
|
||||
grid gap-1.5
|
||||
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
||||
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
||||
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
||||
">
|
||||
{#each items as item (item.id)}
|
||||
<ItemCard {item} link={data.link} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.pagination}
|
||||
<Pagination pagination={data.pagination} link={data.link} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
||||
<i class="fa-solid fa-tag text-5xl" aria-hidden="true"></i>
|
||||
<p class="text-sm">No items found for <strong>#{data.tag}</strong>.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
27
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.server.ts
Normal file
27
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItem } from '$lib/api/items';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params, locals }) => {
|
||||
const tag = decodeURIComponent(params.tag);
|
||||
const itemid = Number(params.itemid);
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
if (!itemid) throw error(404, 'Not found');
|
||||
|
||||
try {
|
||||
const data = await getItem(itemid, { mode, tag }, fetch);
|
||||
if (!data.success || !data.item) throw error(404, data.message ?? 'Item not found');
|
||||
|
||||
return {
|
||||
tag,
|
||||
item: data.item,
|
||||
pagination: data.pagination,
|
||||
link: data.link,
|
||||
isSubscribed: data.isSubscribed ?? false
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
if ((e as { status?: number })?.status) throw e;
|
||||
throw error(500, 'Failed to load item');
|
||||
}
|
||||
};
|
||||
31
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.svelte
Normal file
31
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ItemView from '$lib/components/item/ItemView.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{data.tag} — item {data.item.id} — f0ckm</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Tag breadcrumb -->
|
||||
<div class="max-w-[1600px] mx-auto px-4 pt-2">
|
||||
<div class="text-xs opacity-50 flex items-center gap-1.5">
|
||||
<a href="/" class="hover:opacity-100 transition-opacity">f0ckm</a>
|
||||
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||
<a href="/tag/{encodeURIComponent(data.tag)}" class="hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
<i class="fa-solid fa-tag text-[10px]" aria-hidden="true"></i>
|
||||
{data.tag}
|
||||
</a>
|
||||
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||
<span>#{data.item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ItemView
|
||||
item={data.item}
|
||||
pagination={data.pagination}
|
||||
link={data.link}
|
||||
isSubscribed={data.isSubscribed}
|
||||
/>
|
||||
3
f0ck-svelte/static/robots.txt
Normal file
3
f0ck-svelte/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
21
f0ck-svelte/tsconfig.json
Normal file
21
f0ck-svelte/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
47
f0ck-svelte/vite.config.ts
Normal file
47
f0ck-svelte/vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
runes: ({ filename }) =>
|
||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
adapter: adapter()
|
||||
})
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Proxy API + WebSocket to the existing f0ckm backend
|
||||
'/api': {
|
||||
target: 'http://localhost:1337',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ajax': {
|
||||
target: 'http://localhost:1337',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:1337',
|
||||
ws: true
|
||||
},
|
||||
// Static media files — use exact prefix matching to avoid
|
||||
// colliding with Vite's own /src/ module paths.
|
||||
// Each rule anchored to exactly /X/ (with trailing slash).
|
||||
'^/b/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/t/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/ca/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/a/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
// /s/ — NOTE: must NOT match /src/ — regex anchors it
|
||||
'^/s/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/memes/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
// mod actions (pin, unpin, delete)
|
||||
'^/mod/': { target: 'http://localhost:1337', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,38 +0,0 @@
|
||||
-- Add visibility and slug columns to items table
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
|
||||
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
|
||||
|
||||
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||
|
||||
-- Populate missing slugs for existing items
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
new_slug text;
|
||||
i integer;
|
||||
BEGIN
|
||||
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
|
||||
LOOP
|
||||
new_slug := '';
|
||||
FOR i IN 1..11 LOOP
|
||||
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
|
||||
END LOOP;
|
||||
BEGIN
|
||||
UPDATE items SET slug = new_slug WHERE id = r.id;
|
||||
EXIT;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
END;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Fix any items with NULL visibility (should default to 0 = public)
|
||||
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
|
||||
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;
|
||||
@@ -114,7 +114,6 @@ DROP INDEX IF EXISTS public.idx_items_username;
|
||||
DROP INDEX IF EXISTS public.idx_items_pinned_id;
|
||||
DROP INDEX IF EXISTS public.idx_items_is_purged;
|
||||
DROP INDEX IF EXISTS public.idx_items_is_deleted;
|
||||
DROP INDEX IF EXISTS public.idx_items_slug;
|
||||
DROP INDEX IF EXISTS public.idx_items_active_id;
|
||||
DROP INDEX IF EXISTS public.idx_global_chat_created_at;
|
||||
DROP INDEX IF EXISTS public.idx_discord_queue_sent;
|
||||
@@ -911,18 +910,12 @@ CREATE TABLE public.items (
|
||||
original_filename text,
|
||||
title text,
|
||||
width integer,
|
||||
height integer,
|
||||
visibility smallint DEFAULT 0 NOT NULL,
|
||||
slug character varying(16)
|
||||
height integer
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.items OWNER TO f0ckm;
|
||||
|
||||
COMMENT ON COLUMN public.items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||
COMMENT ON COLUMN public.items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm
|
||||
--
|
||||
@@ -1505,25 +1498,12 @@ CREATE TABLE public.user_options (
|
||||
receive_user_notifications boolean DEFAULT true,
|
||||
do_not_disturb boolean DEFAULT false,
|
||||
comment_display_mode integer DEFAULT 1,
|
||||
force_comment_display_mode integer DEFAULT 0,
|
||||
favorites_private boolean DEFAULT false,
|
||||
hide_fav_badge boolean DEFAULT false,
|
||||
default_upload_visibility smallint DEFAULT 0,
|
||||
banner_file character varying(255) DEFAULT NULL::character varying,
|
||||
banner_position character varying(50) DEFAULT 'center'::character varying,
|
||||
banner_size character varying(50) DEFAULT 'cover'::character varying
|
||||
force_comment_display_mode integer DEFAULT 0
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.user_options OWNER TO f0ckm;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/';
|
||||
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
|
||||
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner';
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm
|
||||
--
|
||||
@@ -2182,13 +2162,6 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat
|
||||
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_items_slug; Type: INDEX; Schema: public; Owner: f0ckm
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX idx_items_slug ON public.items USING btree (slug);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm
|
||||
--
|
||||
@@ -2971,9 +2944,4 @@ ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
|
||||
|
||||
-- User Banners
|
||||
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
||||
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
||||
|
||||
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- 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.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"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.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -1237,7 +1237,7 @@ html[theme='95'] .item-main-content .metadata {
|
||||
|
||||
.notif-count {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 0px;
|
||||
right: -5px;
|
||||
background: var(--badge-nsfw);
|
||||
color: white;
|
||||
@@ -1650,10 +1650,6 @@ body.layout-modern .global-sidebar-right {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Always show the handle on touch devices (no hover state available) */
|
||||
@media (pointer: coarse) {
|
||||
#sidebar-drag-zone {
|
||||
@@ -1988,7 +1984,7 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg));
|
||||
background: linear-gradient(to bottom, transparent, rgba(28, 27, 27, 0.8));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -2123,97 +2119,8 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
/* Mobile sidebar (max-width: 999px): sidebar fixed, capped to viewport width */
|
||||
/* Mobile Stacking for Legacy Mode (max-width: 999px) */
|
||||
@media (max-width: 999px) {
|
||||
/* Sidebar itself: cap width to viewport, always right-anchored */
|
||||
.item-sidebar-right,
|
||||
.index-sidebar-right,
|
||||
.global-sidebar-right {
|
||||
width: min(280px, 80vw) !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden .item-sidebar-right,
|
||||
body.sidebar-right-hidden .index-sidebar-right,
|
||||
body.sidebar-right-hidden .global-sidebar-right {
|
||||
right: calc(-1 * min(280px, 80vw)) !important;
|
||||
}
|
||||
|
||||
/* Mobile: pagination centered in available space (left of sidebar) */
|
||||
.pagination-container-fluid {
|
||||
right: min(280px, 80vw) !important;
|
||||
transition: right 0.3s ease-in-out !important;
|
||||
}
|
||||
|
||||
/* Sidebar hidden: pagination expands to full viewport width */
|
||||
body.sidebar-right-hidden .pagination-container-fluid {
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
#sidebar-right-toggle {
|
||||
top: 80% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 600–999px: sidebar is attached to the grid, same as desktop but with mobile width.
|
||||
Grid reserves space for the sidebar so content is never hidden behind it. */
|
||||
@media (min-width: 600px) and (max-width: 999px) {
|
||||
.index-layout-wrapper {
|
||||
padding-right: min(280px, 80vw);
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden .index-layout-wrapper {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Generic pagewrapper (non-index, non-item pages) also reserves space */
|
||||
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
|
||||
padding-right: min(280px, 80vw);
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden
|
||||
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Restore layout-modern item stacking at 600–999px.
|
||||
Its CSS grid only activates at ≥1000px; without flex-column here
|
||||
the layout falls back to broken block flow. */
|
||||
body.layout-modern .item-layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
body.layout-modern .item-layout-container .item-main-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
body.layout-modern .item-sidebar-left {
|
||||
order: 2;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Legacy layout: 2-column grid with mobile sidebar width. */
|
||||
body.layout-legacy .item-layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) min(280px, 80vw);
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.layout-legacy.sidebar-right-hidden .item-layout-container {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (≤599px): sidebar is always a pure overlay — never reserve space for it.
|
||||
Wrappers stay at full width regardless of sidebar state; no transitions needed here.
|
||||
Item view also switches to stacked layout here (not at 999px). */
|
||||
@media (max-width: 599px) {
|
||||
/* Item view: stack content above comments/sidebar-left */
|
||||
.item-layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2223,33 +2130,43 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
|
||||
.item-layout-container .item-main-content {
|
||||
order: 1;
|
||||
/* Main content (video) on top */
|
||||
/* padding: 10px; */
|
||||
}
|
||||
|
||||
.item-sidebar-left {
|
||||
order: 2;
|
||||
/* Comments/Tags on bottom */
|
||||
/* padding: 10px; */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.index-layout-wrapper,
|
||||
.item-layout-container,
|
||||
.pagewrapper,
|
||||
body.sidebar-right-hidden .index-layout-wrapper,
|
||||
body.sidebar-right-hidden .item-layout-container,
|
||||
body.sidebar-right-hidden .pagewrapper {
|
||||
padding-right: 5px !important;
|
||||
transition: none !important;
|
||||
/* Mobile Overrides if necessary — most styles now universal */
|
||||
.item-sidebar-right,
|
||||
.index-sidebar-right,
|
||||
.global-sidebar-right {
|
||||
width: 280px !important;
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden .item-sidebar-right,
|
||||
body.sidebar-right-hidden .index-sidebar-right,
|
||||
body.sidebar-right-hidden .global-sidebar-right {
|
||||
right: -280px !important;
|
||||
}
|
||||
|
||||
/* Mobile: sidebar is an overlay, so pagination always spans full viewport */
|
||||
.pagination-container-fluid,
|
||||
body.sidebar-right-hidden .pagination-container-fluid {
|
||||
right: 0 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
#sidebar-right-toggle {
|
||||
top: 80% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.item-layout-container .item-main-content>._204863,
|
||||
.item-layout-container .item-main-content>.item_title,
|
||||
.item-layout-container .item-main-content>.content,
|
||||
@@ -3041,8 +2958,7 @@ body.layout-legacy .scroll-nav-wrapper {
|
||||
right: -45px;
|
||||
width: 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
body.layout-legacy .scroll-to-bottom {
|
||||
@@ -3148,7 +3064,7 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
color: #888;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: flex-end;
|
||||
line-height: normal;
|
||||
flex-wrap: nowrap;
|
||||
gap: 2px 10px;
|
||||
@@ -3156,9 +3072,8 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
|
||||
.comment-header-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px 6px;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
@@ -3281,13 +3196,9 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
.comment-author {
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
margin-right: 2px;
|
||||
margin-right: 8px;
|
||||
padding-right: 0 !important;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
@@ -3345,8 +3256,6 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
font-size: 0.9em;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comment-context-link:hover,
|
||||
@@ -3410,7 +3319,10 @@ body.layout-legacy .scroll-to-bottom svg {
|
||||
}
|
||||
|
||||
.comment-backlinks {
|
||||
display: contents;
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.comments-header-actions {
|
||||
@@ -3707,7 +3619,6 @@ html[theme="f0ck95"] img.avatar {
|
||||
html[theme="f0ck95"] ._204863 {
|
||||
content: " ";
|
||||
background: -webkit-linear-gradient(left, #08216b, #a5cef7);
|
||||
background: linear-gradient(90deg, #08216b, #a5cef7);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: white;
|
||||
@@ -3717,31 +3628,6 @@ html[theme="f0ck95"] ._204863 {
|
||||
border-right: outset 2px silver;
|
||||
border-bottom: outset 2px silver;
|
||||
line-height: 1.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html[theme="f0ck95"] ._204863 .location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[theme="f0ck95"] ._204863 .location::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck95"] .iconset#a_delete {
|
||||
@@ -3853,8 +3739,8 @@ html[theme='f0ck95d'] {
|
||||
|
||||
html[theme="f0ck95d"] .item_title {
|
||||
background: black;
|
||||
border-left: 2px solid gray;
|
||||
border-right: 2px solid gray;
|
||||
border-left: inset 3px silver;
|
||||
border-right: outset 3px silver;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .err {
|
||||
@@ -3869,35 +3755,15 @@ html[theme="f0ck95d"] ._error_topbar {
|
||||
html[theme="f0ck95d"] ._204863 {
|
||||
content: " ";
|
||||
background: -webkit-linear-gradient(left, #0e111a, #073c71);
|
||||
background: linear-gradient(90deg, #0e111a, #073c71);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: white;
|
||||
height: auto;
|
||||
border: 2px solid gray;
|
||||
border-left: inset 2px silver;
|
||||
border-top: inset 2px silver;
|
||||
border-right: outset 2px silver;
|
||||
border-bottom: outset 2px silver;
|
||||
line-height: 1.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] ._204863 .location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] ._204863 .location::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .embed-responsive-image {
|
||||
@@ -3906,13 +3772,15 @@ html[theme="f0ck95d"] .embed-responsive-image {
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .media-object {
|
||||
border-left: 2px solid gray;
|
||||
border-right: 2px solid gray;
|
||||
background: black;
|
||||
border-bottom: 2px solid gray;
|
||||
border-left: inset 3px silver;
|
||||
border-right: outset 3px silver;
|
||||
}
|
||||
|
||||
|
||||
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
||||
background: black;
|
||||
border-left: inset 1px silver;
|
||||
border-right: outset 1px silver;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .embed-responsive-image {
|
||||
border: none;
|
||||
@@ -4028,11 +3896,6 @@ html[theme="f0ck95d"] .admin-search button {
|
||||
color: black;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
||||
background: black;
|
||||
|
||||
}
|
||||
|
||||
/* fullscreen */
|
||||
html[res="fullscreen"] ._204863 {
|
||||
border-top: none;
|
||||
@@ -4625,17 +4488,16 @@ span.placeholder {
|
||||
transition: right 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Sidebar hidden — reclaim the full width */
|
||||
body.sidebar-right-hidden .pagination-container-fluid {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Modern layout: wider sidebar needs wider offset */
|
||||
body.layout-modern .pagination-container-fluid {
|
||||
right: 400px;
|
||||
}
|
||||
|
||||
/* Sidebar hidden — reclaim the full width */
|
||||
body.sidebar-right-hidden .pagination-container-fluid,
|
||||
body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.bottom-pagination.fixed-pagination {
|
||||
margin: 10px auto;
|
||||
pointer-events: auto;
|
||||
@@ -4644,6 +4506,7 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
padding: 2px;
|
||||
max-width: fit-content;
|
||||
}
|
||||
@@ -4856,7 +4719,7 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||
.index-layout-wrapper {
|
||||
@@ -5410,10 +5273,6 @@ html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-modal-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
html.image-expand-active .image-modal-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
@@ -7337,11 +7196,6 @@ span#favs {
|
||||
border-top: 1px solid var(--nav-border-color);
|
||||
}
|
||||
|
||||
|
||||
body.layout-modern span#favs {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
span#favs[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -7848,7 +7702,6 @@ div.posts>a.thumb > p {
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Make individual item entry subtler and faster for infinite scroll */
|
||||
@@ -9642,16 +9495,77 @@ body.layout-legacy .comment-content img.emoji {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@keyframes flashGreen {
|
||||
0% {
|
||||
background-color: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: var(--item-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes newItemFlash {
|
||||
0% {
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
}
|
||||
|
||||
60% {
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
border-left-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.new-item-fade {
|
||||
box-shadow: inset 3px 0px 0px var(--accent);
|
||||
animation: newItemFlash 2.5s ease forwards;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-activity .new-item-fade {
|
||||
box-shadow: none;
|
||||
border-left: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Comment posted by the current user — fades in + accent border that auto-disappears */
|
||||
@keyframes commentEnter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
}
|
||||
|
||||
60% {
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
70% {
|
||||
border-left-color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
100% {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-entering {
|
||||
animation: commentEnter 7.5s ease forwards;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.comment-highlighted {
|
||||
box-shadow: inset 3px 0px 0px var(--accent) !important;
|
||||
@@ -9833,20 +9747,6 @@ html[theme="f0ck95d"] .badge-dark {
|
||||
}
|
||||
}
|
||||
|
||||
/* At 600–999px: center the button in the free space left of the sidebar.
|
||||
Shift left by half the sidebar width so transform:translateX(-50%) lands
|
||||
at the center of the available area rather than the full viewport. */
|
||||
@media (min-width: 600px) and (max-width: 999px) {
|
||||
.mobile-scroll-to-top {
|
||||
left: calc(50% - min(140px, 40vw));
|
||||
transition: left 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden .mobile-scroll-to-top {
|
||||
left: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.steuerung.steuerung-icon .fa-solid {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -9988,6 +9888,14 @@ html[theme="orange"] .image-brand {
|
||||
}
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
||||
border-bottom: outset 2px silver;
|
||||
}
|
||||
|
||||
html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
|
||||
border-bottom: outset 2px silver;
|
||||
}
|
||||
|
||||
/* Fade in video on page load */
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -10015,7 +9923,7 @@ html[theme="orange"] .image-brand {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
@media (min-width: 1200px) {
|
||||
.pagewrapper {
|
||||
padding-right: 300px;
|
||||
}
|
||||
@@ -10033,7 +9941,7 @@ html[theme="orange"] .image-brand {
|
||||
Since Index and Item pages have their own layout wrappers that handle space reservation,
|
||||
we disable the generic pagewrapper padding on those pages to avoid "double padding" or broken expansion.
|
||||
*/
|
||||
@media (min-width: 1000px) {
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
.pagewrapper:has(.index-layout-wrapper),
|
||||
.pagewrapper:has(.item-layout-container),
|
||||
@@ -10182,7 +10090,7 @@ ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 0 5px;
|
||||
padding: 0 15px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -10299,7 +10207,7 @@ ol {
|
||||
}
|
||||
|
||||
/* ── Mobile: ≤768px ── */
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Show hamburger */
|
||||
.navbar-toggler {
|
||||
@@ -11162,7 +11070,8 @@ div.posts>a::after {
|
||||
|
||||
|
||||
.pin-indicator {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
font-size: 13px;
|
||||
fill: var(--accent);
|
||||
@@ -11484,12 +11393,11 @@ body.layout-legacy .navbar-header {
|
||||
}
|
||||
|
||||
/* layout-modern: restore sidebar right-padding AND block-flow for generic pages
|
||||
(profile, settings, subscriptions, tags, etc.) — identical behaviour to layout-legacy.
|
||||
Specialized pages (index, item, meme) manage their own layout, so exclude them.
|
||||
Must be ≥1000px so the gap at 1000–1199px is covered. */
|
||||
@media (min-width: 1000px) {
|
||||
(profile, settings, subscriptions, etc.) — identical behaviour to layout-legacy.
|
||||
Specialized pages (index, item, meme) manage their own layout, so exclude them. */
|
||||
@media (min-width: 1200px) {
|
||||
body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
|
||||
padding-right: 300px;
|
||||
padding-right: 400px;
|
||||
}
|
||||
|
||||
body.layout-modern.sidebar-right-hidden>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
|
||||
@@ -13058,45 +12966,59 @@ body.layout-modern .tag-ac-input {
|
||||
Full-height chat layout — conversation page
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── UNIVERSAL: position:fixed takes it out of flow entirely ───────
|
||||
No pagewrapper padding, no body scroll involvement, just viewport-anchored. */
|
||||
.messages-convo-page {
|
||||
position: fixed !important;
|
||||
top: var(--navbar-h, 50px) !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 1;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
transition: right 0.3s ease-in-out;
|
||||
}
|
||||
/* ── DESKTOP (> 768px): same pattern as the sidebar ───────
|
||||
position:fixed takes it out of flow entirely — no pagewrapper
|
||||
padding, no body scroll involvement, just viewport-anchored. */
|
||||
@media (min-width: 769px) {
|
||||
.messages-convo-page {
|
||||
position: fixed !important;
|
||||
top: var(--navbar-h, 50px) !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 1;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
transition: right 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Desktop: 300px reserved space (both layouts) */
|
||||
@media (min-width: 1000px) {
|
||||
body:not(.sidebar-right-hidden) .messages-convo-page {
|
||||
right: 300px !important;
|
||||
@media (min-width: 1200px) {
|
||||
body:not(.sidebar-right-hidden) .messages-convo-page {
|
||||
right: 300px !important;
|
||||
}
|
||||
body.layout-modern:not(.sidebar-right-hidden) .messages-convo-page {
|
||||
right: 400px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Kill the layout-legacy pagewrapper min-height:100vh that causes body overflow */
|
||||
body:has(.messages-convo-page) .pagewrapper {
|
||||
min-height: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attached Mobile: mobile sidebar width reserved space */
|
||||
@media (min-width: 600px) and (max-width: 999px) {
|
||||
body:not(.sidebar-right-hidden) .messages-convo-page {
|
||||
right: min(280px, 80vw) !important;
|
||||
/* ── MOBILE (≤ 768px): position:fixed pagewrapper ─────────
|
||||
Works on mobile; locks the pagewrapper to the viewport. */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
html:has(.messages-convo-page),
|
||||
body:has(.messages-convo-page) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Kill the pagewrapper min-height and overflow so body doesn't scroll behind it */
|
||||
html:has(.messages-convo-page),
|
||||
body:has(.messages-convo-page) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body:has(.messages-convo-page) .pagewrapper {
|
||||
min-height: 0 !important;
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
body:has(.messages-convo-page) .pagewrapper {
|
||||
position: fixed;
|
||||
top: var(--navbar-h, 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared: convo pane is a flex column ──────────────────
|
||||
@@ -13106,8 +13028,7 @@ body:has(.messages-convo-page) .pagewrapper {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
max-width: 720px;
|
||||
/* Removed width: 100% here because it overconstrains the fixed element
|
||||
against left:0 and right:300px, causing negative margins and overlap. */
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -16139,21 +16060,12 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
color: #ccc;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.9em;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.info-table td a {
|
||||
color: var(--accent, #9f0);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#info-modal .modal-content .info-table td a {
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.info-table td a:hover {
|
||||
@@ -16170,26 +16082,6 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.info-hash-codeblock {
|
||||
margin: 2px 0;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color, #eee);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.info-hash-codeblock code {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.info-table tr:last-child th,
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
/* Meme Creator Styles */
|
||||
|
||||
.meme-creator-container {
|
||||
box-sizing: border-box !important;
|
||||
padding: 20px;
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
@@ -112,7 +111,6 @@
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
max-width: 650px;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
background: #000;
|
||||
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||
@@ -240,49 +238,22 @@ canvas#memeCanvas {
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
|
||||
/* Mobile Stacking - Clean Flexbox Column Layout */
|
||||
/* Mobile Stacking - Simple 2-Row Layout */
|
||||
@media (max-width: 950px) {
|
||||
.meme-editor-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 0.6fr auto !important;
|
||||
gap: 20px;
|
||||
}
|
||||
.meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 500px !important;
|
||||
grid-row: 2;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
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;
|
||||
grid-row: 1;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
@@ -323,19 +294,20 @@ 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: 1000px) {
|
||||
@media (min-width: 1200px) {
|
||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
|
||||
.meme-layout-wrapper {
|
||||
padding-right: 300px;
|
||||
}
|
||||
|
||||
/* Collapse reserved space when sidebar is hidden */
|
||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapse reserved space when sidebar is hidden */
|
||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -362,8 +362,6 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -421,26 +419,6 @@
|
||||
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 {
|
||||
@@ -684,32 +662,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
|
||||
background: rgba(81, 207, 102, 0.2);
|
||||
border-color: #51cf66;
|
||||
color: #51cf66;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
|
||||
background: rgba(255, 187, 51, 0.2);
|
||||
border-color: #ffbb33;
|
||||
color: #ffbb33;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* Tags */
|
||||
.tag-input-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 443 B |
@@ -1,24 +1,18 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
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 idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
postid: +idLink.innerText,
|
||||
// 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 ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
|
||||
} else if (isHidden) {
|
||||
this.container.classList.add('faded-out');
|
||||
this.container.style.display = 'none';
|
||||
document.body.classList.add('sidebar-left-hidden');
|
||||
@@ -596,7 +596,7 @@ class CommentSystem {
|
||||
if (cached) cached.content = fullContent;
|
||||
}
|
||||
|
||||
contentEl.dataset.raw = fullContent;
|
||||
contentEl.dataset.raw = this.escapeHtml(fullContent);
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
@@ -622,7 +622,6 @@ 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);
|
||||
@@ -858,14 +857,26 @@ class CommentSystem {
|
||||
* border-right) is fully restored after the effect completes.
|
||||
*
|
||||
* @param {Element} el - The .comment element to animate.
|
||||
* @param {boolean} entering - Unused (kept for compatibility).
|
||||
* @param {boolean} entering - True to also add 'comment-entering' (own post);
|
||||
* false for 'new-item-fade' only (live others).
|
||||
*/
|
||||
static _animateNewComment(el, entering = false) {
|
||||
if (!el) return;
|
||||
el.classList.add('new-item-fade');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('new-item-fade');
|
||||
}, 2500);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -881,6 +892,7 @@ 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
|
||||
@@ -1026,7 +1038,7 @@ class CommentSystem {
|
||||
preview.dataset.id = targetId;
|
||||
|
||||
// Remove temporary animation/highlight classes
|
||||
preview.classList.remove('new-item-fade', 'comment-highlighted');
|
||||
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
|
||||
|
||||
// Remove input forms or action buttons
|
||||
const actions = preview.querySelector('.comment-actions');
|
||||
@@ -1140,7 +1152,6 @@ 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;
|
||||
@@ -1161,13 +1172,12 @@ class CommentSystem {
|
||||
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
||||
const contentEl = body.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
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 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
|
||||
);
|
||||
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
if (isNew) {
|
||||
@@ -1622,25 +1632,17 @@ class CommentSystem {
|
||||
syncLevel(roots, list, false);
|
||||
}
|
||||
|
||||
// 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; }
|
||||
// Maximum characters to fully render in the item view per comment
|
||||
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
|
||||
|
||||
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
||||
if (!content) return '';
|
||||
|
||||
// Truncate long comments before any processing
|
||||
// Truncate extremely long comments before any processing
|
||||
let truncated = false;
|
||||
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 (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
@@ -1789,10 +1791,9 @@ 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
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
return `<span class="greentext">>${renderedContent}</span>`;
|
||||
}
|
||||
|
||||
@@ -2198,7 +2199,7 @@ class CommentSystem {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
renderCommentAttachments(files, content = '') {
|
||||
@@ -2406,50 +2407,10 @@ class CommentSystem {
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
|
||||
// Global click listener to close popups (useful for mobile dismissal)
|
||||
document.addEventListener('click', (e) => {
|
||||
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');
|
||||
const isLink = e.target.closest('.comment-context-link');
|
||||
const isPopup = e.target.closest('.comment-preview-popup');
|
||||
|
||||
if (!isLink && !isPopup) {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
@@ -2864,10 +2825,7 @@ class CommentSystem {
|
||||
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);
|
||||
const fullContent = contentEl.dataset.raw;
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||
// Append "see less" button after full content
|
||||
@@ -2875,7 +2833,6 @@ 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;
|
||||
@@ -2886,13 +2843,9 @@ class CommentSystem {
|
||||
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);
|
||||
const fullContent = contentEl.dataset.raw;
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -3213,9 +3166,6 @@ 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;
|
||||
@@ -3386,15 +3336,9 @@ 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',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
});
|
||||
|
||||
@@ -3660,9 +3604,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) {
|
||||
if (!rawContent) return;
|
||||
const lineCount = rawContent.split('\n').length;
|
||||
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
@@ -3864,12 +3808,9 @@ class CommentSystem {
|
||||
toggleComments() {
|
||||
if (!this.container) return;
|
||||
|
||||
if (document.body.classList.contains('layout-modern')) {
|
||||
if (window.innerWidth < 1000) return;
|
||||
if (typeof window.toggleSidebarLeft === 'function') {
|
||||
window.toggleSidebarLeft();
|
||||
return;
|
||||
}
|
||||
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
|
||||
window.toggleSidebarLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = this.container.closest('.item-layout-container');
|
||||
@@ -4516,71 +4457,20 @@ class CommentSystem {
|
||||
}, { passive: true });
|
||||
};
|
||||
|
||||
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
||||
let lastHoldTime = 0;
|
||||
const addTabTouch = (btn, cb, onHold) => {
|
||||
// Helper: reliable touch handling for tab buttons on mobile
|
||||
const addTabTouch = (btn, cb) => {
|
||||
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
|
||||
@@ -4592,13 +4482,23 @@ 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__'), () => toggleFavDefault(favTab));
|
||||
addTabTouch(favTab, () => showTab('__favs__'));
|
||||
// Right-click on fav tab → set/unset as default
|
||||
favTab.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (Date.now() - lastHoldTime < 1000) return;
|
||||
toggleFavDefault(favTab);
|
||||
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');
|
||||
}
|
||||
});
|
||||
// Mark as default if already set
|
||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1976,73 +1976,6 @@ 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';
|
||||
@@ -2052,13 +1985,22 @@ 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 (Date.now() - lastDmHoldTime < 1000) return;
|
||||
toggleDmFavDefault(favsTab);
|
||||
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');
|
||||
}
|
||||
});
|
||||
// Mark as default if already set
|
||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||
@@ -2086,7 +2028,6 @@ 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,10 +1000,9 @@
|
||||
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: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1158,14 +1157,10 @@
|
||||
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',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `postid=${id}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
// Sync count to server truth (handles race conditions)
|
||||
@@ -1587,10 +1582,9 @@
|
||||
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: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1856,12 +1850,11 @@
|
||||
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',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
'x-csrf-token': window.scrollerCsrf || ''
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: item.external_media_url || item.dest,
|
||||
@@ -1973,10 +1966,9 @@
|
||||
showShareToast(info.toast);
|
||||
}
|
||||
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -2127,12 +2119,7 @@
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
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 raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
const lines = raw.split('\n');
|
||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
@@ -2800,16 +2787,11 @@
|
||||
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',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: postBody
|
||||
});
|
||||
const data = await resp.json();
|
||||
@@ -2917,14 +2899,10 @@
|
||||
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',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `tagname=${encodeURIComponent(tag)}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
@@ -3680,11 +3658,7 @@
|
||||
if (sMarkAll) {
|
||||
sMarkAll.addEventListener('click', async () => {
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
await fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
await fetch('/api/notifications/read', { method: 'POST' });
|
||||
updateScrollerNotifBadge(0);
|
||||
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
||||
updateScrollerTabBadges(sCachedNotifs);
|
||||
@@ -3699,12 +3673,7 @@
|
||||
if (!item) return;
|
||||
const nid = item.dataset.id;
|
||||
if (nid && item.classList.contains('unread')) {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/notifications/${nid}/read`, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
}).catch(() => {});
|
||||
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
|
||||
item.classList.remove('unread');
|
||||
// Update cache
|
||||
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
||||
|
||||
@@ -1119,97 +1119,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const playSidebarEmojiVideos = (container) => {
|
||||
@@ -153,6 +153,12 @@
|
||||
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));
|
||||
@@ -262,10 +268,9 @@
|
||||
// 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
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
}
|
||||
|
||||
@@ -479,9 +484,8 @@
|
||||
};
|
||||
|
||||
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, itemKey);
|
||||
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||
|
||||
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
||||
|
||||
@@ -498,7 +502,7 @@
|
||||
}
|
||||
|
||||
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
||||
|
||||
// Build avatar URL — same priority as the rest of the app
|
||||
let avatarSrc = '/a/default.png';
|
||||
@@ -543,8 +547,8 @@
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
<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>
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -908,7 +908,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
lines.forEach(url => {
|
||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
|
||||
selectedFiles.push({ type: 'url', url, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
}
|
||||
});
|
||||
urlInput.value = '';
|
||||
@@ -931,7 +931,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: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
}
|
||||
urlInput.value = '';
|
||||
if (urlBadge) urlBadge.style.display = 'none';
|
||||
@@ -1177,7 +1177,7 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
||||
if (isShitpost) {
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
} else {
|
||||
selectedFiles.push(file); // Legacy single file mode uses raw File
|
||||
}
|
||||
@@ -1490,8 +1490,6 @@ window.initUploadForm = (selector) => {
|
||||
infoRow.className = 'file-meta-row-small';
|
||||
|
||||
let ratingSwitch = '';
|
||||
let visibilitySwitch = '';
|
||||
let expirySwitch = '';
|
||||
let tagsUI = '';
|
||||
let ocUI = '';
|
||||
let commentUI = '';
|
||||
@@ -1519,56 +1517,6 @@ 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 = `
|
||||
@@ -1612,37 +1560,19 @@ window.initUploadForm = (selector) => {
|
||||
</div>
|
||||
${titleUI}
|
||||
${ratingSwitch}
|
||||
${visibilitySwitch}
|
||||
${expirySwitch}
|
||||
${tagsUI}
|
||||
${commentUI}
|
||||
`;
|
||||
|
||||
if (isShitpost) {
|
||||
// Handle Rating
|
||||
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
|
||||
infoRow.querySelectorAll('.item-rating-option 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');
|
||||
@@ -2506,11 +2436,6 @@ 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: {
|
||||
@@ -2520,9 +2445,7 @@ window.initUploadForm = (selector) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
|
||||
visibility: visibilityVal,
|
||||
expiry: expiryVal,
|
||||
rating: globalRatingEl.value,
|
||||
tags: tags.join(','),
|
||||
comment: comment,
|
||||
is_oc: isOc,
|
||||
@@ -2629,13 +2552,9 @@ 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;
|
||||
@@ -2652,8 +2571,6 @@ 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');
|
||||
@@ -2705,8 +2622,6 @@ 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,
|
||||
@@ -2823,14 +2738,17 @@ window.initUploadForm = (selector) => {
|
||||
// Skip redirect if every item was a background URL job
|
||||
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (!allPending) {
|
||||
const targetUrl = (lastData && (lastData.visibility > 0 || lastData.redirect || lastData.slug))
|
||||
? (lastData.redirect || `/${lastData.slug || lastData.itemid}`)
|
||||
: '/';
|
||||
// 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(targetUrl, true, { bypassCache: true });
|
||||
if (targetUrl === '/') injectNewItem();
|
||||
await window.loadPageAjax('/', true, { bypassCache: true });
|
||||
injectNewItem();
|
||||
} else {
|
||||
window.location.href = targetUrl;
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
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 idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
postid: +idLink.innerText,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -306,12 +306,7 @@ if (!window.UserCommentSystem) {
|
||||
const trimmed = line.trimStart();
|
||||
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 rendered = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit
|
||||
@@ -410,12 +405,11 @@ 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, itemKey);
|
||||
const content = this.renderCommentContent(c.content, c.item_id);
|
||||
|
||||
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
@@ -456,7 +450,7 @@ if (!window.UserCommentSystem) {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,16 +38,6 @@ 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];
|
||||
};
|
||||
@@ -63,29 +53,6 @@ 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");
|
||||
};
|
||||
@@ -493,15 +460,4 @@ 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,46 +204,3 @@ 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,12 +165,6 @@
|
||||
"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",
|
||||
@@ -541,7 +535,6 @@
|
||||
"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:",
|
||||
@@ -801,12 +794,6 @@
|
||||
"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.",
|
||||
"visibility": {
|
||||
"public": "Öffentlich",
|
||||
"unlisted": "Nicht gelistet",
|
||||
"private": "Privat",
|
||||
"change_visibility": "Sichtbarkeit ändern"
|
||||
}
|
||||
"admin_desc": "Du bist Admin, leg los."
|
||||
}
|
||||
}
|
||||
@@ -165,12 +165,6 @@
|
||||
"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",
|
||||
@@ -545,7 +539,6 @@
|
||||
"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:",
|
||||
@@ -803,12 +796,6 @@
|
||||
"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.",
|
||||
"visibility": {
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"private": "Private",
|
||||
"change_visibility": "Change Visibility"
|
||||
}
|
||||
"admin_desc": "You are an admin, go ahead."
|
||||
}
|
||||
}
|
||||
@@ -165,10 +165,6 @@
|
||||
"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",
|
||||
@@ -539,7 +535,6 @@
|
||||
"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,10 +165,6 @@
|
||||
"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",
|
||||
@@ -540,7 +536,6 @@
|
||||
"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,29 +1,12 @@
|
||||
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 = () => {
|
||||
if (!cfg.nsfp?.length) return null;
|
||||
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
|
||||
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(" or ") : null;
|
||||
};
|
||||
|
||||
const resolveNumericItemId = async (itemIdOrSlug) => {
|
||||
if (!itemIdOrSlug) return null;
|
||||
if (typeof itemIdOrSlug === 'number') return itemIdOrSlug;
|
||||
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
|
||||
try {
|
||||
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
|
||||
return rows[0]?.id || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : 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);
|
||||
@@ -153,60 +136,8 @@ 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, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
|
||||
if (fav && rawUser) {
|
||||
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||
if (isPrivate && !isAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
is_private: true,
|
||||
message: "private favorites"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 } = {}) => {
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
|
||||
// --- title: prefix — search items.title instead of the tags table ---
|
||||
@@ -263,22 +194,7 @@ export default {
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
|
||||
// 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;
|
||||
let baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
if (!session) {
|
||||
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
|
||||
let tagFilter = db``;
|
||||
@@ -334,9 +250,6 @@ export default {
|
||||
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
||||
}
|
||||
|
||||
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
|
||||
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce(items.visibility, 0) = 0`;
|
||||
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
||||
let total = getCachedCount(cacheKey);
|
||||
|
||||
@@ -348,7 +261,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -391,7 +303,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -422,8 +333,6 @@ export default {
|
||||
const rows = (await db`
|
||||
select
|
||||
items.id,
|
||||
items.slug,
|
||||
items.visibility,
|
||||
items.mime,
|
||||
items.dest,
|
||||
items.username as username,
|
||||
@@ -519,18 +428,7 @@ 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, 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 } = {}) => {
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
|
||||
// --- title: prefix — search items.title instead of the tags table ---
|
||||
@@ -560,17 +458,7 @@ export default {
|
||||
if (uhData.length) userHallObj = uhData[0];
|
||||
}
|
||||
const mime = (rawMime ?? "");
|
||||
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 itemid = rawItemid ? +rawItemid : null;
|
||||
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'
|
||||
@@ -584,27 +472,18 @@ 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: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||
|
||||
const effMode = Number(mode ?? 0);
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
let baseMode = multiRatingSQL ?? lib.getMode(effMode);
|
||||
if (!session) {
|
||||
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
||||
|
||||
if (itemid === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
const modequery = baseMode;
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -646,9 +525,6 @@ export default {
|
||||
return db`
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
|
||||
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${hallFilter}
|
||||
@@ -662,9 +538,11 @@ export default {
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
|
||||
|
||||
// 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.*,
|
||||
@@ -688,48 +566,15 @@ 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
|
||||
${itemLookup} and
|
||||
items.id = ${itemid} 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) {
|
||||
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) {
|
||||
if (actitem && user_id) {
|
||||
db`
|
||||
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
||||
values (${user_id}, ${itemid}, 1, now())
|
||||
@@ -738,12 +583,15 @@ export default {
|
||||
last_viewed = now()
|
||||
`.catch(e => console.error('Failed to track view:', e));
|
||||
}
|
||||
// Guest global filter check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
|
||||
if (!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 (!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
|
||||
`;
|
||||
if (filteredItem.length > 0) {
|
||||
if (unfilteredItem[0]) {
|
||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -758,6 +606,11 @@ export default {
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible."
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
||||
@@ -765,11 +618,11 @@ export default {
|
||||
|
||||
// Determine the effective mode for optimization check (similar to Random)
|
||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
||||
const useTagsDriver = !!session && (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
const useTagsDriver = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
|
||||
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
||||
return db`
|
||||
select items.id, items.slug
|
||||
select items.id
|
||||
from items
|
||||
left join tags_assign on tags_assign.item_id = items.id
|
||||
left join tags on tags.id = tags_assign.tag_id
|
||||
@@ -780,7 +633,7 @@ export default {
|
||||
where
|
||||
${buildConditions()}
|
||||
${whereClause}
|
||||
group by items.id, items.slug
|
||||
group by items.id
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`;
|
||||
@@ -795,7 +648,7 @@ export default {
|
||||
const checkFilter = !session && nsfpIds.length > 0;
|
||||
|
||||
const query = db`
|
||||
SELECT ta.item_id as id, items.slug
|
||||
SELECT ta.item_id as id
|
||||
FROM tags_assign ta
|
||||
INNER JOIN items ON items.id = ta.item_id
|
||||
${checkFilter
|
||||
@@ -804,7 +657,6 @@ 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``}
|
||||
@@ -853,7 +705,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, "user_options".hide_fav_badge
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
@@ -866,24 +718,24 @@ export default {
|
||||
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
||||
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
||||
const repostRows = await db`
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
SELECT id, 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, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
repostItems = repostRows.map(r => ({ id: r.id, 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, slug, username, stamp FROM items
|
||||
SELECT id, 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, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
}
|
||||
|
||||
// Also find visually-similar items via phash, merging with checksum results
|
||||
@@ -893,7 +745,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, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||
existingIds.add(pm.id);
|
||||
}
|
||||
}
|
||||
@@ -916,29 +768,6 @@ 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 & untagged restriction check for public uploads (visibility === 0)
|
||||
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
|
||||
let guestBlocked = false;
|
||||
if (!isTagged && !cfg.websrv.public_untagged) guestBlocked = true;
|
||||
else if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
|
||||
else if (isNsfl) guestBlocked = true;
|
||||
|
||||
if (guestBlocked) {
|
||||
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`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Mode-mismatch visibility check:
|
||||
// Only enforce for members (session users) with an explicit mode preference.
|
||||
@@ -951,7 +780,7 @@ export default {
|
||||
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) {
|
||||
if (modeBlocked) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -968,11 +797,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -984,8 +808,6 @@ 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,
|
||||
@@ -1044,21 +866,17 @@ 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,
|
||||
expires_at: actitem.expires_at || null,
|
||||
expires_in: lib.expiresIn(actitem.expires_at)
|
||||
|
||||
original_filename: actitem.original_filename || null
|
||||
},
|
||||
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
|
||||
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
||||
pagination: {
|
||||
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,
|
||||
end: endItem[0]?.id || itemid,
|
||||
start: startItem[0]?.id || itemid,
|
||||
next: nextItem[0]?.id || null,
|
||||
prev: prevItem[0]?.id || null,
|
||||
page: actitem.id,
|
||||
cheat: cheat
|
||||
},
|
||||
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
|
||||
@@ -1066,14 +884,7 @@ export default {
|
||||
tmp
|
||||
};
|
||||
return data;
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
const hall = rawHall || null;
|
||||
|
||||
@@ -1114,22 +925,7 @@ export default {
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
let baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
if (!session) {
|
||||
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
|
||||
let item;
|
||||
@@ -1142,7 +938,6 @@ 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}
|
||||
@@ -1165,7 +960,6 @@ 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
|
||||
@@ -1205,7 +999,6 @@ 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``}
|
||||
@@ -1228,7 +1021,6 @@ 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``}
|
||||
@@ -1245,7 +1037,6 @@ 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()
|
||||
@@ -1254,9 +1045,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 = 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)
|
||||
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)
|
||||
? (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
|
||||
@@ -1272,7 +1063,6 @@ 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``}
|
||||
@@ -1298,8 +1088,7 @@ export default {
|
||||
};
|
||||
},
|
||||
getComments: async (itemId, sort = 'new', process = true) => {
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!numericId) return [];
|
||||
if (!itemId) return [];
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
const comments = await db`
|
||||
@@ -1312,7 +1101,7 @@ export default {
|
||||
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 = ${numericId} AND c.is_deleted = false
|
||||
WHERE c.item_id = ${itemId} 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
|
||||
@@ -1455,12 +1244,11 @@ export default {
|
||||
}
|
||||
},
|
||||
getSubscriptionStatus: async (userId, itemId) => {
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return false;
|
||||
if (!userId || !itemId) return false;
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
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`);
|
||||
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`);
|
||||
return sub.length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -1468,10 +1256,9 @@ export default {
|
||||
},
|
||||
processMentions,
|
||||
markNotificationsRead: async (userId, itemId) => {
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return;
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${numericId} AND is_read = false`;
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${itemId} AND is_read = false`;
|
||||
} catch (e) {
|
||||
console.error('[F0CKLIB] Error marking notifications as read:', e);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import 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, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
|
||||
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";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -319,7 +319,7 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
||||
router.get(/\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
||||
const userId = +req.params.userId;
|
||||
const 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,77 +651,6 @@ 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");
|
||||
@@ -732,7 +661,6 @@ 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
|
||||
};
|
||||
@@ -746,18 +674,15 @@ 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(), cleanup_include_engaged: getCleanupIncludeEngaged() });
|
||||
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
@@ -776,23 +701,17 @@ 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', 'cleanup_include_engaged')`;
|
||||
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`;
|
||||
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
|
||||
|
||||
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
|
||||
const isEnabled = settingsMap['enable_cleanup'] === 'true';
|
||||
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.');
|
||||
@@ -802,7 +721,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} (includeEngaged: ${includeEngaged})...`);
|
||||
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`);
|
||||
|
||||
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
|
||||
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
|
||||
@@ -818,7 +737,7 @@ export default (router, tpl) => {
|
||||
AND i.is_pinned = false
|
||||
AND i.stamp >= ${start_stamp}
|
||||
AND i.stamp <= ${end_stamp}
|
||||
${includeEngaged ? db`` : db`AND (
|
||||
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
|
||||
@@ -827,7 +746,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})`;
|
||||
@@ -1509,8 +1428,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, 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})
|
||||
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})
|
||||
`;
|
||||
|
||||
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>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
@@ -35,12 +35,12 @@ export default (router, tpl) => {
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
@@ -170,8 +170,7 @@ export default (router, tpl) => {
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid,
|
||||
slug: data.item?.slug || null
|
||||
id: itemid
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -195,7 +194,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);
|
||||
@@ -219,10 +218,9 @@ export default (router, tpl) => {
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: req.session,
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin,
|
||||
session: !!req.session,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
fav: query.fav === 'true',
|
||||
random: isRandom,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
@@ -233,19 +231,6 @@ 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({
|
||||
|
||||
@@ -2,22 +2,15 @@ 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 = () => {
|
||||
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 getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const metaCache = new Map();
|
||||
const MAX_META_CACHE = 2000;
|
||||
|
||||
@@ -601,12 +594,10 @@ export default router => {
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
exclude: req.session?.excluded_tags || []
|
||||
});
|
||||
|
||||
if (!data || !data.itemid) {
|
||||
if (!data.itemid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
items: []
|
||||
@@ -645,7 +636,6 @@ export default router => {
|
||||
items: {
|
||||
...safeItem,
|
||||
id: item.id,
|
||||
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
||||
dest: relativeDest,
|
||||
url: directUrl,
|
||||
direct_url: directUrl
|
||||
@@ -710,11 +700,9 @@ export default router => {
|
||||
};
|
||||
|
||||
const excludedTags = req.session?.excluded_tags || [];
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce("items".visibility, 0) = 0`;
|
||||
|
||||
const newest = (await db`select max(id) as id from "items"`)[0]?.id || 0;
|
||||
const oldest = (await db`select min(id) as id from "items"`)[0]?.id || 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 rows = (await db`
|
||||
@@ -724,7 +712,6 @@ 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}`
|
||||
@@ -740,8 +727,8 @@ export default router => {
|
||||
`).sort((a, b) => b.id - a.id);
|
||||
|
||||
return res.json({
|
||||
atEnd: rows.length > 0 && rows[0].id === newest,
|
||||
atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
|
||||
atEnd: rows[0].id === newest,
|
||||
atStart: rows[rows.length - 1].id === oldest,
|
||||
success: true,
|
||||
items: rows
|
||||
}, 200);
|
||||
@@ -756,6 +743,20 @@ export default router => {
|
||||
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
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (item.length === 0) {
|
||||
return res.json({
|
||||
@@ -764,54 +765,12 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
const actitem = item[0];
|
||||
const session = req.session;
|
||||
const isOwnerOrAdmin = session && (
|
||||
session.admin ||
|
||||
session.is_moderator ||
|
||||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase())
|
||||
);
|
||||
|
||||
// Exclude unlisted (1) and private (2) items from API responses for non-owners/non-admins
|
||||
if ((actitem.visibility || 0) > 0 && !isOwnerOrAdmin) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'no items found'
|
||||
});
|
||||
}
|
||||
|
||||
let guestTagFilter = db``;
|
||||
if (!session) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
guestTagFilter = cfg.websrv.public_untagged
|
||||
? db`and (id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))`
|
||||
: db`and id in (select item_id from tags_assign where tag_id in (1, 2))`;
|
||||
} else {
|
||||
guestTagFilter = cfg.websrv.public_untagged
|
||||
? db`and (id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))`
|
||||
: db`and id in (select item_id from tags_assign where tag_id = 1)`;
|
||||
}
|
||||
}
|
||||
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const rows = {
|
||||
...actitem,
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
...item[0],
|
||||
...{
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
}
|
||||
};
|
||||
|
||||
return res.json({
|
||||
@@ -1079,24 +1038,7 @@ export default router => {
|
||||
});
|
||||
|
||||
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
|
||||
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;
|
||||
const postid = +req.post.postid;
|
||||
|
||||
// Check if already faved by this user — compare as numbers to avoid type mismatch
|
||||
const existing = await db`
|
||||
@@ -1124,7 +1066,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, "user_options".hide_fav_badge
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
@@ -1226,121 +1168,6 @@ 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,68 +315,6 @@ 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;
|
||||
|
||||
212
src/inc/routes/apiv2/svelte.mjs
Normal file
212
src/inc/routes/apiv2/svelte.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -25,8 +25,7 @@ export default router => {
|
||||
|
||||
group.post(/$/, lib.loggedin, async (req, res) => {
|
||||
// assign and/or create tag
|
||||
const rawTagname = req.post?.tagname || req.body?.tagname;
|
||||
if (!req.params.postid || !rawTagname) {
|
||||
if (!req.params.postid || !req.post.tagname) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'missing postid or tag'
|
||||
@@ -34,7 +33,7 @@ export default router => {
|
||||
}
|
||||
|
||||
const postid = +req.params.postid;
|
||||
const tagname = rawTagname.trim();
|
||||
const tagname = req.post.tagname?.trim();
|
||||
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
||||
|
||||
if (protectedTags.includes(tagname.toLowerCase())) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { spawn as _spawnRaw } from 'child_process';
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import path from "path";
|
||||
@@ -138,71 +137,6 @@ const parseMultipart = (buffer, boundary) => {
|
||||
|
||||
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
|
||||
|
||||
const getTargetVisibility = (req, postVis) => {
|
||||
if (cfg.enable_private_uploads === false) return 0;
|
||||
|
||||
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
|
||||
? cfg.default_upload_visibility
|
||||
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
|
||||
|
||||
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
|
||||
|
||||
if (!allowUserOverride) {
|
||||
return sysDefault;
|
||||
}
|
||||
|
||||
const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null;
|
||||
const val = (rawHeader || postVis || '').toString().trim().toLowerCase();
|
||||
if (val === 'private' || val === '2') return 2;
|
||||
if (val === 'unlisted' || val === '1') return 1;
|
||||
if (val === 'public' || val === '0') return 0;
|
||||
return (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
|
||||
? req.session.default_upload_visibility
|
||||
: sysDefault;
|
||||
};
|
||||
|
||||
export function calculateExpiresAt(expiryVal, baseStamp = ~~(Date.now() / 1000)) {
|
||||
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||
return null;
|
||||
}
|
||||
if (!expiryVal || expiryVal === 'permanent' || expiryVal === 'never' || expiryVal === '0') {
|
||||
return null;
|
||||
}
|
||||
const val = expiryVal.toString().trim().toLowerCase();
|
||||
switch (val) {
|
||||
case '30minutes':
|
||||
case '30m':
|
||||
case '1800':
|
||||
return baseStamp + 1800;
|
||||
case '1hour':
|
||||
case '1h':
|
||||
case '3600':
|
||||
return baseStamp + 3600;
|
||||
case '24hours':
|
||||
case '24h':
|
||||
case '1day':
|
||||
case '86400':
|
||||
return baseStamp + 86400;
|
||||
case '1week':
|
||||
case '1w':
|
||||
case '7days':
|
||||
case '604800':
|
||||
return baseStamp + 604800;
|
||||
case '1month':
|
||||
case '1m':
|
||||
case '30days':
|
||||
case '2592000':
|
||||
return baseStamp + 2592000;
|
||||
default:
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
return parsed > 2000000000 ? parsed : baseStamp + parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Collect request body as buffer with debug logging
|
||||
const collectBody = (req) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -435,11 +369,6 @@ export default router => {
|
||||
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
@@ -451,14 +380,11 @@ export default router => {
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
title: title,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -511,9 +437,6 @@ export default router => {
|
||||
});
|
||||
} else {
|
||||
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
const session = {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
@@ -751,7 +674,6 @@ export default router => {
|
||||
await fs.unlink(source).catch(() => { });
|
||||
|
||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
@@ -764,14 +686,11 @@ export default router => {
|
||||
username: session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
title: title,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -806,8 +725,8 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved and public)
|
||||
if (!isApprovalRequired && targetVisibility === 0) {
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
@@ -823,8 +742,8 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel (only if auto-approved and public)
|
||||
if (!isApprovalRequired && targetVisibility === 0) {
|
||||
// Push to Matrix Channel (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
try {
|
||||
const self = router.self;
|
||||
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
|
||||
|
||||
@@ -208,7 +208,7 @@ export default (router, tpl) => {
|
||||
});
|
||||
|
||||
// DELETE /api/chat/:id — admin: delete a single message
|
||||
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -156,8 +155,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
|
||||
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
|
||||
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
/* <mode-override> */
|
||||
// prioritize query mode (from AJAX) over session default
|
||||
@@ -176,7 +174,7 @@ export default (router, tpl) => {
|
||||
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||
|
||||
const comments = await db`
|
||||
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
|
||||
SELECT c.*, i.mime, i.id as item_id
|
||||
FROM comments c
|
||||
LEFT JOIN items i ON c.item_id = i.id
|
||||
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
||||
@@ -210,7 +208,6 @@ export default (router, tpl) => {
|
||||
let processedComments = mentionsProcessed.map(c => {
|
||||
return {
|
||||
...c,
|
||||
item_slug: getEnableItemSlugs() ? c.item_slug : null,
|
||||
content: c.content
|
||||
};
|
||||
});
|
||||
@@ -573,9 +570,7 @@ export default (router, tpl) => {
|
||||
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
|
||||
const itemQuery = await db`
|
||||
SELECT
|
||||
i.slug,
|
||||
i.xd_score,
|
||||
COALESCE(i.visibility, 0) as visibility,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||
@@ -597,7 +592,6 @@ export default (router, tpl) => {
|
||||
type: 'comment',
|
||||
id: commentId,
|
||||
item_id: item_id,
|
||||
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
|
||||
parent_id: parent_id || null,
|
||||
body: notifyBody,
|
||||
username: req.session.user,
|
||||
@@ -615,30 +609,29 @@ export default (router, tpl) => {
|
||||
// 1. Thread live update
|
||||
db.notify('comments', JSON.stringify(livePayload));
|
||||
|
||||
// 2. Sidebar activity update (only for public items)
|
||||
const itemVisibility = itemQuery[0]?.visibility ?? 0;
|
||||
if (itemVisibility === 0) {
|
||||
const activityIsLong = content.length > 120
|
||||
|| content.split('\n').length > 2
|
||||
|| activityFiles.length > 0
|
||||
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content);
|
||||
db.notify('activity', JSON.stringify({
|
||||
user_id: req.session.id,
|
||||
item_id: item_id,
|
||||
type: 'comment',
|
||||
body: notifyBody,
|
||||
id: commentId,
|
||||
item_rating_class: ratingClass,
|
||||
item_rating_label: ratingLabel,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
username: req.session.user,
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
files: activityFiles,
|
||||
is_long: activityIsLong
|
||||
}));
|
||||
}
|
||||
// 2. Sidebar activity update
|
||||
// Compute is_long using the full content (not the truncated notifyBody) so the
|
||||
// sidebar renders the correct clamped state immediately on first paint.
|
||||
const activityIsLong = content.length > 120
|
||||
|| content.split('\n').length > 2
|
||||
|| activityFiles.length > 0
|
||||
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content);
|
||||
db.notify('activity', JSON.stringify({
|
||||
user_id: req.session.id,
|
||||
item_id: item_id,
|
||||
type: 'comment',
|
||||
body: notifyBody,
|
||||
id: commentId,
|
||||
item_rating_class: ratingClass,
|
||||
item_rating_label: ratingLabel,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
username: req.session.user,
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
files: activityFiles,
|
||||
is_long: activityIsLong
|
||||
}));
|
||||
|
||||
// Automatically subscribe user to the thread
|
||||
const subResult = await db`
|
||||
@@ -1002,8 +995,7 @@ export default (router, tpl) => {
|
||||
|
||||
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
||||
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
|
||||
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
|
||||
const comments = await db`
|
||||
@@ -1011,7 +1003,6 @@ export default (router, tpl) => {
|
||||
c.*,
|
||||
i.mime,
|
||||
i.id as item_id,
|
||||
i.slug as item_slug,
|
||||
i.dest as item_dest,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||
@@ -1028,7 +1019,6 @@ export default (router, tpl) => {
|
||||
WHERE c.is_deleted = false
|
||||
AND i.active = true
|
||||
AND i.is_deleted = false
|
||||
AND COALESCE(i.visibility, 0) = 0
|
||||
AND ${db.unsafe(modequery)}
|
||||
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
@@ -1037,15 +1027,9 @@ export default (router, tpl) => {
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
// Normalize item_slug based on getEnableItemSlugs()
|
||||
const processedCommentsList = comments.map(c => ({
|
||||
...c,
|
||||
item_slug: getEnableItemSlugs() ? c.item_slug : null
|
||||
}));
|
||||
|
||||
// Fetch comment file attachments
|
||||
const filesMap = new Map();
|
||||
if (processedCommentsList.length > 0) {
|
||||
if (comments.length > 0) {
|
||||
const commentIds = comments.map(c => c.id);
|
||||
try {
|
||||
const files = await db`
|
||||
|
||||
@@ -437,9 +437,8 @@ export default (router) => {
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
original_filename: original_filename || null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user