68 Commits

Author SHA1 Message Date
3179d15b42 cleanip 2026-08-08 10:10:27 +02:00
6dd70a0fc2 pickle rick 2026-08-08 10:04:54 +02:00
6ab0505e7e jg 2026-08-08 09:57:29 +02:00
162f1b40d0 pickle rick 2026-08-07 22:38:51 +02:00
9d2b0137da f 2026-08-07 22:35:56 +02:00
e179eb1955 create koepfe 2026-08-07 22:33:52 +02:00
a80e6dab96 jfh 2026-08-07 22:22:51 +02:00
c22ae5bde0 pisse aus meinem arsch 2026-08-07 22:18:32 +02:00
c9f5dc02fa hgdf 2026-08-07 22:14:23 +02:00
4fd15ec338 g 2026-08-07 22:08:53 +02:00
feb408338a 69 2026-08-07 21:42:12 +02:00
c7414e4b21 CSS QoL 2026-08-06 23:55:51 +02:00
1262b15a5b add option to also hide fav activity from public 2026-08-06 23:00:12 +02:00
2cd8bd91ff add option for users to make their favorites private 2026-08-06 22:40:53 +02:00
7b186dcdb2 remove this since it can break v6 config 2026-08-06 22:24:04 +02:00
973753d231 fix nginx ipv6 and make tor only available to localhost 2026-08-06 21:48:05 +02:00
c30b3535ae smoothies 2026-07-28 22:30:04 +02:00
eed8b993e1 truncate . 2026-07-28 22:06:31 +02:00
9c239769e6 quote truncation 2026-07-28 22:00:58 +02:00
6e07194b27 possible fix for weird ass username display bug 2026-07-28 16:42:18 +02:00
866a8003fe fix comment quoting 2026-07-28 16:26:26 +02:00
5ab1a9614a d 2026-07-28 07:47:49 +02:00
e45df6a769 d 2026-07-28 07:46:06 +02:00
99eb9735a1 show pill 2026-07-28 06:38:20 +02:00
b651621cee foobarbaz 2026-07-28 06:30:50 +02:00
d3ce2e38f0 update readme 2026-07-28 03:34:57 +02:00
096786516e 503 2026-07-28 03:11:16 +02:00
ec895a285b onion rules 2026-07-28 03:08:46 +02:00
20aaf183d8 less is more 2026-07-28 03:01:30 +02:00
25ab4ba949 adding tor switch and update readme 2026-07-27 12:32:08 +02:00
6a52445987 custom location for CF Gate and removing box-shadow 2026-07-27 12:28:06 +02:00
63c39ebe90 deactivate captcha for onion 2026-07-27 10:26:05 +02:00
0a21b6c8f8 positioning is kinda weird for notic-count 2026-07-27 00:44:00 +02:00
a7aabac956 update example gate 2026-07-27 00:22:13 +02:00
9f5510bc04 add custom gate template for private society 2026-07-27 00:12:51 +02:00
54e2e8871b respect config settings 2026-07-26 23:24:28 +02:00
c99f3e11d8 fix invalid csrf token 2026-07-26 23:20:20 +02:00
d14feaf3eb correctly display url in gate 2026-07-26 23:10:41 +02:00
5e35fd48d4 Revert "adding hardcoded meta for onion"
This reverts commit a07c21f22b.
2026-07-26 23:07:27 +02:00
a07c21f22b adding hardcoded meta for onion 2026-07-26 23:04:45 +02:00
65fb96f675 f 2026-07-26 22:38:10 +02:00
20a6ffcb04 break long text in info modal 2026-07-26 22:27:57 +02:00
8518e87c8d fix pagination 2026-07-26 22:09:44 +02:00
a3148848f2 option to advertise onion url 2026-07-26 21:43:39 +02:00
abedf7a789 trying HS 2026-07-26 21:37:07 +02:00
e61e24dfe8 fix scroller tagging invalid csrf token 2026-07-26 21:20:00 +02:00
44af6ae061 mobile default tab selection 2026-07-26 20:45:16 +02:00
0739d72334 remove the fucking animations for new comments 2026-07-25 22:49:27 +02:00
848055446a enabling ipv6 for f0ckm-nginx 2026-07-25 22:38:34 +02:00
88e6b6efb9 persist hide item ratings 2026-07-25 21:52:47 +02:00
eea70ed62e fix quoting 2026-07-25 21:49:11 +02:00
b6c906636c bggg 2026-07-23 21:40:13 +02:00
55b5a0ba9f fuyo 2026-07-23 21:37:22 +02:00
5d60d80fc9 g 2026-07-23 21:35:36 +02:00
4dfb80b149 modern shit 2026-07-23 21:25:38 +02:00
b319f21cd4 favvv 2026-07-23 21:16:25 +02:00
28ed8d985c f0ck95d love 2026-07-23 21:14:32 +02:00
0f5e795793 fuck no 2026-07-23 20:51:06 +02:00
78d14a1b56 daisuki 2026-07-23 20:46:40 +02:00
3f149531da genki 2026-07-23 20:42:49 +02:00
7a4432277d fix tags page 2026-07-19 23:19:47 +02:00
84eee7e13d plastic surgery 2026-07-19 23:05:08 +02:00
2206b3eb86 fix modern layout 2026-07-19 23:01:28 +02:00
5529724982 sunshine daisy 2026-07-19 22:49:13 +02:00
9c6070afff xd 2026-07-19 19:53:37 +02:00
5bfa336773 staleness sucks 2026-07-19 19:44:54 +02:00
517444f9ce ignore svelte for prod 2026-07-19 19:43:21 +02:00
edb92ffa2a vert align top context link 2026-07-19 19:42:32 +02:00
127 changed files with 2505 additions and 6281 deletions

View File

@@ -1,9 +1,9 @@
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
POSTGRES_PASSWORD=f0ckm
# --- Nginx & Let's Encrypt Configuration (Optional) ---
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
# COMPOSE_PROFILES=f0ckm-nginx
# --- Nginx & Tor Profiles (Optional) ---
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
# COMPOSE_PROFILES=tor
#
# VIRTUAL_HOST=yourdomain.com
# LETSENCRYPT_HOST=yourdomain.com

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ public/tag_cache
config.json
bundle.css
public/s/fonts/impact.woff
f0ck-svelte/*

View File

@@ -78,3 +78,23 @@ now visit http://localhost:1337 in your browser, you can develop without needing
## NGINX
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
## Tor Hidden Service (.onion)
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
```bash
cat ./f0ckm-data/tor/f0ckm_hs/hostname
```
### Automatic Onion-Location Header
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
```json
"main": {
"onion": "http://yourgeneratedaddress.onion"
}
```

9
config/tor/torrc Normal file
View File

@@ -0,0 +1,9 @@
# Tor configuration for f0ckm
# Enable SOCKS proxy for internal container & external connections
SocksPort 0.0.0.0:9050
# Tor Hidden Service (v3 Onion Service) configuration
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
HiddenServiceDir /var/lib/tor/f0ckm_hs/
HiddenServicePort 80 f0ckm:1337

View File

@@ -5,6 +5,7 @@
"domain": "example.com",
"regex": "example\\.com"
},
"onion": "http://your-onion-address.onion",
"socks": "socks5://127.0.0.1:9050",
"mail": "admin@example.com",
"maxfilesize": 104857600,
@@ -28,6 +29,10 @@
],
"enable_pdf": false,
"enable_nsfl": false,
"enable_private_uploads": true,
"default_upload_visibility": 0,
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
"nsfl_tag_id": 4,
"allowedMimes": [
"audio",
@@ -61,6 +66,7 @@
"show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [],
"enable_tor_hs": true,
"enable_global_chat": true,
"enable_danmaku": true,
"private_messages": true,
@@ -132,6 +138,8 @@
"open_registration_require_mail_andor_token": false,
"private_society": false,
"private_society_gate": "cloudflare",
"private_society_gate_location": "Frankfurt",
"private_society_gate_template": "_gate_template",
"public_nsfw": false,
"paths": {
"images": "/b",

View File

@@ -31,6 +31,7 @@ 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:
@@ -49,9 +50,14 @@ services:
tor:
image: dockurr/tor
container_name: tor
profiles:
- tor
ports:
- "9050:9050"
- "8118:8118"
- "127.0.0.1:9050:9050"
- "127.0.0.1:8118:8118"
volumes:
- ./config/tor:/etc/tor:ro
- ./f0ckm-data/tor:/var/lib/tor:Z
networks:
- f0ckm-net
restart: always
@@ -99,6 +105,8 @@ services:
container_name: f0ckm-nginx
profiles:
- f0ckm-nginx
environment:
- ENABLE_IPV6=true
ports:
- "80:80"
- "443:443"

View File

@@ -1,23 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

View File

@@ -1,42 +0,0 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.3 create --template minimal --types ts --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "f0ck-svelte",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.1",
"daisyui": "^5.6.18",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}

View File

@@ -1,77 +0,0 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: f0ck --default, light;
}
@theme {
/* f0ck dark theme — AMOLED-inspired */
--color-base-100: oklch(10% 0.02 265);
--color-base-200: oklch(13% 0.02 265);
--color-base-300: oklch(16% 0.02 265);
--color-base-content: oklch(92% 0.01 265);
--color-primary: oklch(55% 0.22 265);
--color-primary-content: oklch(98% 0.01 265);
--color-secondary: oklch(60% 0.18 300);
--color-secondary-content: oklch(98% 0.01 265);
--color-accent: oklch(72% 0.15 290);
--color-accent-content: oklch(10% 0.02 265);
--color-neutral: oklch(20% 0.02 265);
--color-neutral-content: oklch(85% 0.01 265);
--color-info: oklch(70% 0.15 220);
--color-success: oklch(70% 0.15 160);
--color-warning: oklch(78% 0.18 80);
--color-error: oklch(63% 0.22 25);
/* Rating colors */
--color-sfw: oklch(70% 0.15 160);
--color-nsfw: oklch(65% 0.20 25);
--color-nsfl: oklch(60% 0.22 0);
}
/* ── Global resets & base styles ───────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
}
html {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--color-base-100);
color: var(--color-base-content);
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
overscroll-behavior: none;
}
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: oklch(30% 0.02 265); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: oklch(40% 0.02 265); }
/* ── Rating badge utility classes ─────────────────────────────────────── */
.badge-sfw { background: oklch(70% 0.15 160 / 20%); color: oklch(70% 0.15 160); border-color: oklch(70% 0.15 160 / 40%); }
.badge-nsfw { background: oklch(65% 0.20 25 / 20%); color: oklch(65% 0.20 25); border-color: oklch(65% 0.20 25 / 40%); }
.badge-nsfl { background: oklch(55% 0.22 0 / 20%); color: oklch(55% 0.22 0); border-color: oklch(55% 0.22 0 / 40%); }
.badge-unt { background: oklch(55% 0.02 265 / 20%); color: oklch(65% 0.02 265); border-color: oklch(55% 0.02 265 / 40%); }
/* ── Grid thumb skeleton pulse ─────────────────────────────────────────── */
@keyframes thumb-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.thumb-skeleton { animation: thumb-pulse 1.5s ease-in-out infinite; }
/* ── Fade-in for navigations ───────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.page-enter { animation: fade-in 0.15s ease-out; }

View File

@@ -1,21 +0,0 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { SessionUser } from '$lib/types/session';
declare global {
namespace App {
interface Error {
message: string;
code?: number;
}
interface Locals {
session: SessionUser | null;
}
interface PageData {
session: SessionUser | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,41 +0,0 @@
import type { Handle } from '@sveltejs/kit';
/**
* Server-side hook: read session from the existing f0ckm backend
* by forwarding the session cookie on every SSR request.
*
* This keeps auth as a single source of truth in the backend.
*/
export const handle: Handle = async ({ event, resolve }) => {
// Forward cookies to the backend session endpoint
const cookieHeader = event.request.headers.get('cookie') ?? '';
try {
const res = await fetch(`${getApiBase(event)}/api/v2/session`, {
headers: { cookie: cookieHeader }
});
if (res.ok) {
const data = await res.json();
event.locals.session = data.loggedIn ? data.user : null;
} else {
event.locals.session = null;
}
} catch {
// Backend not reachable (dev without backend running)
event.locals.session = null;
}
return resolve(event);
};
/**
* Determine the API base URL for server-side fetches.
* In production the SvelteKit server and the API backend run on separate ports.
* The VITE_API_URL env var (or API_URL for SSR) sets the backend address.
*/
function getApiBase(event: Parameters<Handle>[0]['event']): string {
// SSR: use internal backend address (avoids going through Nginx proxy)
const internal = process.env.API_URL ?? 'http://localhost:1337';
return internal;
}

View File

@@ -1,132 +0,0 @@
import type { Comment } from '$lib/types/comment';
import { csrfFetch } from '$lib/utils/csrfFetch';
// ── Comments ──────────────────────────────────────────────────────────────────
export interface CommentsResponse {
success: boolean;
comments: Comment[];
total?: number;
}
export async function getComments(
itemId: number,
fetchFn = fetch
): Promise<CommentsResponse> {
const res = await fetchFn(`/api/comments/${itemId}`);
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
return res.json();
}
export async function postComment(payload: {
itemId: number;
content: string;
replyTo?: number | null;
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
const body = new URLSearchParams({
item_id: String(payload.itemId),
content: payload.content,
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
});
const res = await csrfFetch('/api/comments', { method: 'POST', body });
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
return res.json();
}
export async function deleteComment(
commentId: number
): Promise<{ success: boolean; msg?: string }> {
// Backend uses POST /api/comments/:id/delete (not DELETE method)
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
return res.json();
}
// ── Favorites ─────────────────────────────────────────────────────────────────
export interface FavResponse {
success: boolean;
itemid: number;
favs: Array<{
user: string;
display_name: string | null;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
}>;
}
export async function toggleFav(itemId: number): Promise<FavResponse> {
const body = new URLSearchParams({ postid: String(itemId) });
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
return res.json();
}
// ── Tags ──────────────────────────────────────────────────────────────────────
export interface TagsResponse {
success: boolean;
tags: import('$lib/types/item').Tag[];
msg?: string;
}
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
const body = new URLSearchParams({ tagname });
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
return res.json();
}
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
return res.json();
}
export interface CycleRatingResponse {
success: boolean;
rating_tag_id: number;
rating_label: 'SFW' | 'NSFW' | 'NSFL';
rating_class: 'sfw' | 'nsfw' | 'nsfl';
msg?: string;
}
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
return res.json();
}
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error('suggestTags failed');
return res.json();
}
// ── Item management (mod/admin) ───────────────────────────────────────────────
export async function deleteItem(
itemId: number,
reason = ''
): Promise<{ success: boolean; msg?: string }> {
const body = new URLSearchParams({ postid: String(itemId), reason });
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
return res.json();
}
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
// Backend reads id from query string: req.url.qs.id
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
return res.json();
}
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
return res.json();
}

View File

@@ -1,29 +0,0 @@
import type { SessionResponse } from '$lib/types/session';
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
const res = await fetchFn('/api/v2/session');
if (!res.ok) return { loggedIn: false, user: null };
return res.json();
}
export async function login(
username: string,
password: string,
kmsi = false
): Promise<{ success: boolean; msg?: string }> {
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
const res = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
},
body: body.toString(),
credentials: 'include'
});
return res.json();
}
export async function logout(): Promise<void> {
await fetch('/logout', { method: 'POST', credentials: 'include' });
}

View File

@@ -1,96 +0,0 @@
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
export interface GetItemsParams {
page?: number;
mode?: number;
ratings?: string;
mime?: string;
tag?: string;
hall?: string;
user?: string;
fav?: boolean;
random?: boolean;
strict?: boolean;
min_xd?: number;
newer?: number;
tagger?: string;
}
/**
* Fetch a page of grid items.
* Called from +page.ts (server-side) and from the client for infinite-scroll.
*/
export async function getItems(
params: GetItemsParams,
fetchFn: typeof fetch = fetch
): Promise<ItemsResponse> {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.mode !== undefined) qs.set('mode', String(params.mode));
if (params.ratings) qs.set('ratings', params.ratings);
if (params.mime) qs.set('mime', params.mime);
if (params.tag) qs.set('tag', params.tag);
if (params.hall) qs.set('hall', params.hall);
if (params.user) qs.set('user', params.user);
if (params.fav) qs.set('fav', 'true');
if (params.random) qs.set('random', '1');
if (params.strict) qs.set('strict', '1');
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
if (params.newer) qs.set('newer', String(params.newer));
if (params.tagger) qs.set('tagger', params.tagger);
const res = await fetchFn(`/api/v2/items?${qs}`);
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
return res.json();
}
export interface GetItemParams {
mode?: number;
ratings?: string;
tag?: string;
hall?: string;
user?: string;
fav?: boolean;
random?: boolean;
strict?: boolean;
mime?: string;
userHall?: string;
userHallOwner?: string;
}
/**
* Fetch a single item with its neighbours and metadata.
*/
export async function getItem(
id: number | string,
params: GetItemParams = {},
fetchFn: typeof fetch = fetch
): Promise<ItemResponse> {
const qs = new URLSearchParams();
if (params.mode !== undefined) qs.set('mode', String(params.mode));
if (params.ratings) qs.set('ratings', params.ratings);
if (params.tag) qs.set('tag', params.tag);
if (params.hall) qs.set('hall', params.hall);
if (params.user) qs.set('user', params.user);
if (params.fav) qs.set('fav', 'true');
if (params.random) qs.set('random', '1');
if (params.strict) qs.set('strict', '1');
if (params.mime) qs.set('mime', params.mime);
if (params.userHall) qs.set('userHall', params.userHall);
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
const suffix = qs.toString() ? `?${qs}` : '';
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
return res.json();
}
/**
* Get a random item ID (optionally within a context).
* Returns the JSON response; caller should navigate to response.url.
*/
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
const res = await fetchFn('/api/v2/random');
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
return res.json();
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,232 +0,0 @@
<script lang="ts">
import { getComments, postComment, deleteComment } from '$lib/api/actions';
import { session, isModOrAdmin } from '$lib/stores/session';
import { flash } from '$lib/stores/flash';
import { timeAgo, formatDate } from '$lib/utils/timeago';
import { onSSE } from '$lib/stores/sse';
import { onDestroy } from 'svelte';
import type { Comment } from '$lib/types/comment';
interface Props {
itemId: number;
}
let { itemId }: Props = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<Comment[]>([]);
let loading = $state(true);
let error = $state('');
let text = $state('');
let replyTo = $state<Comment | null>(null);
let submitting = $state(false);
// ── Load ──────────────────────────────────────────────────────────────────
async function load() {
loading = true;
error = '';
try {
const data = await getComments(itemId);
comments = data.comments ?? [];
} catch {
error = 'Failed to load comments.';
} finally {
loading = false;
}
}
// Run on mount and when itemId changes
$effect(() => { load(); });
// ── Live updates via SSE ──────────────────────────────────────────────────
const unsubSSE = onSSE((event) => {
if (event.type !== 'comments') return;
const d = event.data as { item_id: number; type?: string };
if (d.item_id !== itemId) return;
// Reload the full comment list on any comment activity for this item
load();
});
onDestroy(unsubSSE);
// ── Post comment ──────────────────────────────────────────────────────────
async function submit(e: Event) {
e.preventDefault();
if (!text.trim()) return;
submitting = true;
try {
const res = await postComment({
itemId,
content: text.trim(),
replyTo: replyTo?.id ?? null
});
if (res.success) {
text = '';
replyTo = null;
await load();
flash('Comment posted', 'success');
} else {
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
}
} catch {
flash('Network error', 'error');
} finally {
submitting = false;
}
}
// ── Delete comment ────────────────────────────────────────────────────────
async function handleDelete(comment: Comment) {
if (!confirm('Delete this comment?')) return;
try {
const res = await deleteComment(comment.id);
if (res.success) {
await load();
flash('Deleted', 'success');
} else {
flash(res.msg ?? 'Failed to delete', 'error');
}
} catch {
flash('Network error', 'error');
}
}
function canDelete(comment: Comment) {
return $session && ($isModOrAdmin || $session.user === comment.username);
}
function avatarSrc(comment: Comment): string {
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
if (comment.avatar) return `/t/${comment.avatar}.webp`;
return '/a/default.png';
}
</script>
<section class="flex flex-col gap-3">
<!-- Header -->
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
</span>
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
</button>
</div>
<!-- Comment form -->
{#if $session}
<form onsubmit={submit} class="flex flex-col gap-2">
{#if replyTo}
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100"></button>
</div>
{/if}
<div class="flex gap-2">
<textarea
bind:value={text}
placeholder="Write a comment…"
rows="2"
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
disabled={submitting}
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
></textarea>
<button
type="submit"
class="btn btn-primary btn-sm self-end"
disabled={submitting || !text.trim()}
>
{#if submitting}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
{/if}
</button>
</div>
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
</form>
{:else}
<p class="text-xs opacity-40">
<a href="/login" class="link link-primary">Log in</a> to comment.
</p>
{/if}
<!-- Comments list -->
{#if loading}
<div class="flex justify-center py-4">
<span class="loading loading-dots loading-sm opacity-40"></span>
</div>
{:else if error}
<p class="text-xs text-error opacity-70">{error}</p>
{:else if comments.length === 0}
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each comments as comment (comment.id)}
<li
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
id="comment-{comment.id}"
>
<!-- Avatar -->
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
<div class="avatar">
<div class="w-7 rounded-full border border-base-300">
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
</div>
</div>
</a>
<!-- Body -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-1.5 flex-wrap">
<a
href="/user/{comment.username?.toLowerCase()}"
class="font-semibold text-xs hover:text-primary transition-colors"
style={comment.username_color ? `color: ${comment.username_color}` : ''}
>
{comment.display_name || comment.username}
</a>
<time
datetime={comment.stamp_full}
title={formatDate(comment.stamp_full)}
class="text-xs opacity-40"
>
{comment.stamp}
</time>
</div>
{#if comment.is_deleted}
<p class="text-xs italic opacity-50">— deleted —</p>
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
{@html comment.content}
</div>
{/if}
<!-- Actions -->
{#if !comment.is_deleted}
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
{#if $session}
<button
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
class="btn btn-ghost btn-xs gap-1 text-xs"
>
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
</button>
{/if}
{#if canDelete(comment)}
<button
onclick={() => handleDelete(comment)}
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
>
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
</button>
{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>

View File

@@ -1,83 +0,0 @@
<script lang="ts">
import type { GridItem } from '$lib/types/item';
import type { ItemLink } from '$lib/types/item';
interface Props {
item: GridItem;
link: ItemLink;
}
let { item, link }: Props = $props();
// Build the item URL respecting the current filter context
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
const thumbUrl = $derived(`/t/${item.id}.webp`);
// Rating badge
const ratingClass = $derived(
item.tag === 'NSFL' ? 'badge-nsfl' :
item.tag === 'NSFW' ? 'badge-nsfw' :
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
);
</script>
<a
href={itemUrl}
data-sveltekit-preload-data="hover"
class="
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
hover:border-primary/50 transition-all duration-200
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
"
>
<!-- Thumbnail -->
<div class="aspect-video w-full overflow-hidden bg-base-300">
<img
src={thumbUrl}
alt="Item {item.id}"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
/>
</div>
<!-- Overlay on hover -->
<div class="
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
opacity-0 group-hover:opacity-100 transition-opacity duration-200
flex flex-col justify-end p-2 gap-1
">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
<div class="flex items-center gap-1">
{#if item.is_oc}
<span class="badge badge-xs badge-accent">OC</span>
{/if}
{#if item.is_pinned}
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
{/if}
{#if item.xd_tier > 0}
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
{/if}
</div>
</div>
{#if item.display_name || item.username}
<span class="text-xs text-base-content/60 truncate">
{item.display_name || item.username}
</span>
{/if}
</div>
<!-- Rating badge (always visible) -->
{#if item.tag}
<div class="absolute top-1.5 right-1.5">
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
</div>
{/if}
<!-- Notification dot -->
{#if item.has_notification}
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
{/if}
</a>

View File

@@ -1,503 +0,0 @@
<script lang="ts">
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
import TagList from '$lib/components/item/TagList.svelte';
import Comments from '$lib/components/item/Comments.svelte';
import { mimeCategory } from '$lib/utils/mimeHelpers';
import { formatDate } from '$lib/utils/timeago';
import { session, isModOrAdmin } from '$lib/stores/session';
import { goto, preloadData } from '$app/navigation';
import { flash } from '$lib/stores/flash';
import { onSSE } from '$lib/stores/sse';
import { onDestroy } from 'svelte';
import { broadcastPin } from '$lib/stores/pin';
import {
toggleFav, addTag, deleteTag,
deleteItem, pinItem, unpinItem, cycleRating
} from '$lib/api/actions';
interface Props {
item: FullItem;
pagination: Pagination;
link: ItemLink;
isSubscribed?: boolean;
}
let { item, pagination, link, isSubscribed = false }: Props = $props();
// ── Reactive local state (mutable copies updated after actions) ───────────
let favorites = $state(item.favorites);
let tags = $state(item.tags);
let isPinned = $state(item.is_pinned);
let isOc = $state(item.is_oc);
let isSfw = $state(item.is_sfw);
let isNsfw = $state(item.is_nsfw);
let isNsfl = $state(item.is_nsfl);
// Sync when item prop changes (navigation)
$effect(() => {
favorites = item.favorites;
tags = item.tags;
isPinned = item.is_pinned;
isOc = item.is_oc;
isSfw = item.is_sfw;
isNsfw = item.is_nsfw;
isNsfl = item.is_nsfl;
});
// ── Live SSE updates ──────────────────────────────────────────────────────
const unsubSSE = onSSE((event) => {
if (event.type === 'favorites') {
const d = event.data as { item_id: number; favs: FavoriteUser[] };
if (d.item_id === item.id) favorites = d.favs;
} else if (event.type === 'tags') {
const d = event.data as { item_id: number; tags: Tag[] };
if (d.item_id === item.id) {
tags = d.tags;
// Re-derive rating state from the fresh tag list
const SFW_ID = 1, NSFW_ID = 2;
const ids = d.tags.map(t => t.id);
isSfw = ids.includes(SFW_ID);
isNsfw = ids.includes(NSFW_ID);
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
}
} else if (event.type === 'delete_item') {
const d = event.data as { id: number };
if (d.id === item.id) goto('/');
}
});
onDestroy(unsubSSE);
const cat = $derived(mimeCategory(item.mime));
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
// Rating derived from local mutable state (so cycle updates instantly)
const ratingClass = $derived(
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
);
const ratingLabel = $derived(
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
);
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
let ratingCycling = $state(false);
async function handleCycleRating() {
if (!isMod) return;
ratingCycling = true;
try {
const res = await cycleRating(item.id);
if (res.success) {
isSfw = res.rating_class === 'sfw';
isNsfw = res.rating_class === 'nsfw';
isNsfl = res.rating_class === 'nsfl';
flash(`Rating → ${res.rating_label}`, 'success');
} else {
flash(res.msg ?? 'Failed to cycle rating', 'error');
}
} catch {
flash('Network error', 'error');
} finally {
ratingCycling = false;
}
}
// ── Keyboard navigation ───────────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
}
// ── Tag input state ───────────────────────────────────────────────────────
let tagInput = $state('');
let tagAdding = $state(false);
let showTagInput = $state(false);
let suggestions = $state<string[]>([]);
async function submitTag(e: Event) {
e.preventDefault();
const name = tagInput.trim();
if (!name) return;
tagAdding = true;
try {
const res = await addTag(item.id, name);
if (res.success) {
tags = res.tags;
tagInput = '';
showTagInput = false;
flash(`Tag "${name}" added`, 'success');
} else {
flash(res.msg ?? 'Failed to add tag', 'error');
}
} catch {
flash('Network error', 'error');
} finally {
tagAdding = false;
}
}
async function handleRemoveTag(tag: Tag) {
try {
const res = await deleteTag(item.id, tag.tag);
if (res.success) {
tags = res.tags;
flash(`Tag "${tag.tag}" removed`, 'success');
} else {
flash(res.msg ?? 'Failed to remove tag', 'error');
}
} catch {
flash('Network error', 'error');
}
}
// ── Favorite ──────────────────────────────────────────────────────────────
let favLoading = $state(false);
async function handleFav() {
if (!$session) { flash('Log in to favorite', 'warning'); return; }
favLoading = true;
try {
const res = await toggleFav(item.id);
if (res.success) favorites = res.favs;
} catch {
flash('Failed to update favorite', 'error');
} finally {
favLoading = false;
}
}
// ── Delete ────────────────────────────────────────────────────────────────
async function handleDelete() {
const reason = prompt('Reason for deletion (optional):') ?? '';
if (reason === null) return; // cancelled
try {
const res = await deleteItem(item.id, reason);
if (res.success) {
flash('Item deleted', 'success');
goto('/');
} else {
flash(res.msg ?? 'Delete failed', 'error');
}
} catch {
flash('Network error', 'error');
}
}
// ── Pin ───────────────────────────────────────────────────────────────────
async function handlePin() {
try {
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
if (res.success) {
isPinned = res.pinned;
broadcastPin(item.id, res.pinned); // patch grid card live
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
} else {
flash('Pin failed', 'error');
}
} catch {
flash('Network error', 'error');
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
<!-- Tags card -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3 gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
{#if $session}
<button
class="btn btn-xs btn-ghost"
onclick={() => showTagInput = !showTagInput}
aria-label="Add tag"
>
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
</button>
{/if}
</div>
<!-- Tag input -->
{#if showTagInput}
<form onsubmit={submitTag} class="flex gap-1">
<input
bind:value={tagInput}
type="text"
placeholder="tag name…"
class="input input-xs input-bordered flex-1"
disabled={tagAdding}
autocomplete="off"
autofocus
/>
<button
type="submit"
class="btn btn-xs btn-primary"
disabled={tagAdding || !tagInput.trim()}
>
{#if tagAdding}
<span class="loading loading-spinner loading-xs"></span>
{:else}
OK
{/if}
</button>
</form>
{/if}
{#if tags.length > 0}
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
{:else}
<p class="text-xs opacity-40">No tags yet.</p>
{/if}
</div>
</div>
<!-- xD Score -->
{#if item.xd_tier > 0}
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
{item.xd_label}
<span class="opacity-70 text-xs">{item.xd_score}</span>
</div>
{/if}
<!-- Comments -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3">
<Comments itemId={item.id} />
</div>
</div>
</aside>
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
{#if item.title}
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
{/if}
<!-- Media + prev/next arrows -->
<div class="relative group">
<!-- Prev -->
<a
href={prevUrl ?? '#'}
aria-disabled={!prevUrl}
onmouseenter={() => prevUrl && preloadData(prevUrl)}
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
opacity-0 group-hover:opacity-100 transition-opacity
{!prevUrl ? 'pointer-events-none' : ''}"
>
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</div>
</a>
<div class="rounded-lg overflow-hidden bg-base-300">
{#if cat === 'youtube'}
<YouTubeEmbed videoId={ytId} />
{:else if cat === 'video'}
<VideoPlayer src={item.dest} mime={item.mime} />
{:else if cat === 'audio'}
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
{:else if cat === 'image'}
<ImageViewer src={item.dest} alt="Item {item.id}" />
{:else if cat === 'pdf'}
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
{:else}
<div class="flex flex-col items-center justify-center p-12 gap-4">
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
<a href={item.dest} download class="btn btn-primary gap-2">
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
</a>
</div>
{/if}
</div>
<!-- Next -->
<a
href={nextUrl ?? '#'}
aria-disabled={!nextUrl}
onmouseenter={() => nextUrl && preloadData(nextUrl)}
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
opacity-0 group-hover:opacity-100 transition-opacity
{!nextUrl ? 'pointer-events-none' : ''}"
>
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</div>
</a>
</div>
<!-- Metadata / action bar -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3 gap-2">
<!-- Prev / Random / Next -->
<div class="flex items-center justify-between text-sm">
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
onmouseenter={() => prevUrl && preloadData(prevUrl)}
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
</a>
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
</a>
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
onmouseenter={() => nextUrl && preloadData(nextUrl)}
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
</a>
</div>
<div class="divider my-0"></div>
<!-- Item metadata row -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
#{item.id}
</a>
{#if isMod}
<button
onclick={handleCycleRating}
disabled={ratingCycling}
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
title="Cycle rating: {ratingLabel}{isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
aria-label="Cycle rating"
>
{#if ratingCycling}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
{/if}
{ratingLabel}
</button>
{:else}
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
{/if}
<a href="/user/{item.username.toLowerCase()}"
class="font-medium hover:text-primary transition-colors"
style={item.author_color ? `color: ${item.author_color}` : ''}>
{item.author_display_name || item.username}
</a>
{#if isOc}
<span class="badge badge-sm badge-accent">OC</span>
{/if}
{#if isPinned}
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
{/if}
{#if item.primaryHall}
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
{item.primaryHall.name}
</a>
{/if}
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
{item.timestamp.timeago}
</time>
</div>
<!-- Action buttons -->
<div class="flex items-center flex-wrap gap-1 pt-1">
<!-- Favorite -->
<button
onclick={handleFav}
disabled={favLoading || !$session}
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
>
{#if favLoading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
{/if}
{#if favorites.length > 0}
<span class="text-xs">{favorites.length}</span>
{/if}
</button>
<!-- Subscribe (stub) -->
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
</button>
<!-- Link copy -->
<button
class="btn btn-ghost btn-xs"
title="Copy link"
aria-label="Copy link"
onclick={() => {
navigator.clipboard.writeText(location.href);
flash('Link copied!', 'success');
}}
>
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
</button>
<!-- Mod / owner actions -->
{#if canManage}
<div class="flex items-center gap-1 ml-auto">
<!-- Pin/Unpin -->
<button
onclick={handlePin}
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
title="{isPinned ? 'Unpin' : 'Pin'}"
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
>
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
</button>
<!-- Delete -->
<button
onclick={handleDelete}
class="btn btn-ghost btn-xs text-error"
title="Delete"
aria-label="Delete item"
>
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
</button>
</div>
{/if}
</div>
<!-- Favorites avatars -->
{#if favorites.length > 0}
<div class="flex flex-wrap gap-1 pt-1">
{#each favorites as fav (fav.user)}
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
<div class="avatar">
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
{#if fav.avatar_file}
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
{:else if fav.avatar}
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
{:else}
<img src="/a/default.png" alt={fav.user} />
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
</main>
</div>

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/types/item';
interface Props {
tags: Tag[];
canRemove?: boolean;
onRemove?: (tag: Tag) => void;
}
let { tags, canRemove = false, onRemove }: Props = $props();
// Map badge class names from the existing tag badge values
const badgeClass = (badge: string) => {
if (badge === 'badge-sfw') return 'badge-sfw';
if (badge === 'badge-nsfw') return 'badge-nsfw';
if (badge === 'badge-nsfl') return 'badge-nsfl';
return 'badge-neutral';
};
</script>
<div class="flex flex-wrap gap-1.5">
{#each tags as tag (tag.id)}
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
{tag.tag}
</a>
{#if canRemove && onRemove}
<button
class="hover:text-error ml-0.5 transition-colors"
onclick={() => onRemove?.(tag)}
aria-label="Remove tag {tag.tag}"
>
<i class="fa-solid fa-xmark text-[10px]"></i>
</button>
{/if}
</span>
{/each}
</div>

View File

@@ -1,100 +0,0 @@
<script lang="ts">
import { session, loggedIn } from '$lib/stores/session';
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
import { goto } from '$app/navigation';
import { getRandom } from '$lib/api/items';
import { flash } from '$lib/stores/flash';
import { get } from 'svelte/store';
async function goRandom() {
try {
const res = await getRandom();
if (res.success) goto(res.url);
} catch {
flash('Could not fetch a random item', 'error');
}
}
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
// OR just use them in the template with $ prefix — both work, but $ in templates
// is fine for Svelte writable stores even in runes mode.
</script>
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
<!-- Logo / Home -->
<div class="navbar-start gap-2">
<a href="/" class="flex items-center gap-2 group">
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
f0ckm
</span>
</a>
</div>
<!-- Center: actions -->
<div class="navbar-center gap-1 hidden sm:flex">
<!-- Random button -->
<button
class="btn btn-ghost btn-sm gap-1"
onclick={goRandom}
title="Random item"
aria-label="Random item"
>
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
<span class="hidden md:inline">Random</span>
</button>
<!-- Rating filter badge -->
<div class="dropdown dropdown-bottom">
<button
tabindex="0"
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
aria-label="Content filter"
>
{$filterLabel}
</button>
</div>
</div>
<!-- Right: user area -->
<div class="navbar-end gap-2">
{#if $loggedIn}
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
<span class="hidden sm:inline">Upload</span>
</a>
<!-- Avatar / user menu -->
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
{#if $session?.avatar_file}
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
{:else if $session?.avatar}
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
{:else}
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
{/if}
</div>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
{#if $session?.admin || $session?.is_moderator}
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
{/if}
<li class="divider my-1"></li>
<li>
<form method="post" action="/logout">
<button type="submit" class="text-error w-full text-left">
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
</button>
</form>
</li>
</ul>
</div>
{:else}
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
<a href="/register" class="btn btn-primary btn-sm">Register</a>
{/if}
</div>
</nav>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
interface Props {
src: string;
mime: string;
coverart?: string;
}
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
</script>
<div
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
>
<!-- Dark overlay over coverart -->
<div class="absolute inset-0 bg-black/60"></div>
<!-- svelte-ignore a11y-media-has-caption -->
<audio
{src}
loop
controls
preload="auto"
class="relative z-10 w-full max-w-md"
></audio>
</div>

View File

@@ -1,58 +0,0 @@
<script lang="ts">
interface Props {
src: string;
alt?: string;
}
let { src, alt = 'Image' }: Props = $props();
let expanded = $state(false);
let imgEl = $state<HTMLImageElement | undefined>();
function toggleExpand() {
expanded = !expanded;
}
// ESC to close modal
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded) expanded = false;
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
<img
bind:this={imgEl}
{src}
{alt}
loading="lazy"
decoding="async"
onclick={toggleExpand}
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
/>
<button
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
onclick={toggleExpand}
aria-label="Expand"
>
<i class="fa-solid fa-expand"></i>
</button>
</div>
<!-- Fullscreen modal -->
{#if expanded}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
onclick={toggleExpand}
>
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
<button
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
onclick={toggleExpand}
aria-label="Close"
>
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
{/if}

View File

@@ -1,36 +0,0 @@
<script lang="ts">
interface Props {
src: string;
mime: string;
autoplay?: boolean;
}
let { src, mime, autoplay = false }: Props = $props();
let videoEl = $state<HTMLVideoElement | undefined>();
// Keyboard shortcut: space to toggle play/pause
function onKeydown(e: KeyboardEvent) {
if (e.target !== document.body) return;
if (e.key === ' ') {
e.preventDefault();
if (videoEl?.paused) videoEl.play();
else videoEl?.pause();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={videoEl}
{src}
{autoplay}
loop
playsinline
controls
class="max-h-[80dvh] w-full object-contain"
preload="auto"
></video>
</div>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
interface Props {
videoId: string; // e.g. "dQw4w9WgXcQ"
}
let { videoId }: Props = $props();
</script>
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
<iframe
class="absolute inset-0 w-full h-full"
src="https://www.youtube.com/embed/{videoId}"
title="YouTube video"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
const iconMap: Record<FlashType, string> = {
info: 'fa-solid fa-circle-info',
success: 'fa-solid fa-circle-check',
warning: 'fa-solid fa-triangle-exclamation',
error: 'fa-solid fa-circle-xmark'
};
const alertMap: Record<FlashType, string> = {
info: 'alert-info',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error'
};
</script>
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
{#each $flashes as msg (msg.id)}
<div
role="alert"
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
animate-[slide-up_0.2s_ease-out]"
>
<i class="{iconMap[msg.type]}"></i>
<span class="flex-1 text-sm">{msg.text}</span>
<button
class="btn btn-ghost btn-xs"
onclick={() => dismiss(msg.id)}
aria-label="Dismiss"
></button>
</div>
{/each}
</div>
<style>
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -1,62 +0,0 @@
<script lang="ts">
import type { Pagination, ItemLink } from '$lib/types/item';
interface Props {
pagination: Pagination;
link: ItemLink;
}
let { pagination, link }: Props = $props();
const base = $derived(link.main + link.path);
const suffix = $derived(link.suffix ?? '');
</script>
{#if pagination.end > 1}
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
<!-- Prev -->
<a
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
aria-disabled={!pagination.prev}
aria-label="Previous page"
>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</a>
<!-- First page -->
{#if pagination.cheat[0] > 1}
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
{#if pagination.cheat[0] > 2}
<span class="px-1 opacity-40">…</span>
{/if}
{/if}
<!-- Page range -->
{#each pagination.cheat as p}
<a
href="{base}{p}{suffix}"
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
>
{p}
</a>
{/each}
<!-- Last page -->
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
<span class="px-1 opacity-40">…</span>
{/if}
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
{/if}
<!-- Next -->
<a
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
aria-disabled={!pagination.next}
aria-label="Next page"
>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
</nav>
{/if}

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,28 +0,0 @@
import { writable } from 'svelte/store';
// ── Flash message type ────────────────────────────────────────────────────────
export type FlashType = 'info' | 'success' | 'warning' | 'error';
export interface FlashMessage {
id: number;
text: string;
type: FlashType;
}
let _nextId = 0;
export const flashes = writable<FlashMessage[]>([]);
/**
* Show a flash message. Auto-dismisses after `duration` ms.
* Returns the message ID so callers can dismiss early if needed.
*/
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
const id = ++_nextId;
flashes.update((q) => [...q, { id, text, type }]);
setTimeout(() => dismiss(id), duration);
return id;
}
export function dismiss(id: number): void {
flashes.update((q) => q.filter((m) => m.id !== id));
}

View File

@@ -1,20 +0,0 @@
/**
* Lightweight broadcast store for pin/unpin events.
* ItemView fires it after a successful pin action;
* the main grid subscribes to patch the item card immediately.
*/
import { writable } from 'svelte/store';
export interface PinEvent {
itemId: number;
pinned: boolean;
}
// Null when idle, set to an event when pin/unpin succeeds
export const pinEvent = writable<PinEvent | null>(null);
export function broadcastPin(itemId: number, pinned: boolean) {
pinEvent.set({ itemId, pinned });
// Reset so subsequent identical events still trigger subscribers
setTimeout(() => pinEvent.set(null), 0);
}

View File

@@ -1,50 +0,0 @@
import { writable, derived, get } from 'svelte/store';
// ── Content mode ─────────────────────────────────────────────────────────────
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
export const activeMode = writable<number>(3);
// ── Multi-rating filter ───────────────────────────────────────────────────────
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
export const activeRatings = writable<Rating[]>([]);
// ── MIME filter ───────────────────────────────────────────────────────────────
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
export const activeMimes = writable<MimeFilter[]>([]);
// ── Derived: ratings query param string for API calls ─────────────────────────
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
// ── Derived: readable label for the filter badge ─────────────────────────────
export const filterLabel = derived(
[activeMode, activeRatings],
([$mode, $ratings]) => {
if ($ratings.length > 0) {
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
return $ratings.map((r) => abbr[r]).join('+');
}
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
return labels[$mode] ?? 'ALL';
}
);
// ── Persistence helpers ───────────────────────────────────────────────────────
// Call these after receiving the session from the server to hydrate from cookie/session.
export function initPreferences(opts: { mode: number; ratings?: string }) {
activeMode.set(opts.mode ?? 3);
if (opts.ratings) {
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
);
activeRatings.set(parsed);
}
}
/** Build a mode number → server mode number (for API calls when using multi-rating). */
export function getServerMode(): number {
const ratings = get(activeRatings);
if (ratings.length === 0) return get(activeMode);
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
}

View File

@@ -1,11 +0,0 @@
import { writable, derived } from 'svelte/store';
import type { SessionUser } from '$lib/types/session';
/** The current authenticated user, or null if not logged in. */
export const session = writable<SessionUser | null>(null);
/** Convenience derived — true when logged in. */
export const loggedIn = derived(session, ($s) => $s !== null);
/** True when the user has admin or moderator role. */
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));

View File

@@ -1,126 +0,0 @@
/**
* SSE store — connects to the backend's /api/notifications/stream endpoint.
*
* Emits typed events that components subscribe to with `onSSE()`.
* Auto-reconnects with exponential backoff on disconnect.
* Client-only: never runs during SSR.
*/
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
// ── Event types broadcast by the backend ─────────────────────────────────────
export interface SSEComment {
type: 'comment' | 'delete';
id?: number;
item_id: number;
comment_id?: number;
body?: string;
username?: string;
display_name?: string | null;
avatar?: string | null;
avatar_file?: string | null;
username_color?: string | null;
created_at?: string;
parent_id?: number | null;
xd_score?: number | null;
}
export interface SSEFavorites {
item_id: number;
favs: Array<{
user: string;
display_name: string | null;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
}>;
}
export interface SSETags {
item_id: number;
fresh: boolean;
tags: unknown[];
}
export interface SSENewItem {
id: number;
mime?: string;
}
export interface SSEDeleteItem {
id: number;
}
export interface SSEEvent {
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
}
// ── Connection state ──────────────────────────────────────────────────────────
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
export const sseState = writable<SSEState>('disconnected');
// ── Listener registry ─────────────────────────────────────────────────────────
type SSEListener = (event: SSEEvent) => void;
const listeners = new Set<SSEListener>();
/** Subscribe to SSE events. Returns an unsubscribe function. */
export function onSSE(fn: SSEListener): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// ── Internal connection state ─────────────────────────────────────────────────
let es: EventSource | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryDelay = 2000;
const MAX_RETRY = 30_000;
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
function dispatch(raw: SSEEvent) {
for (const fn of listeners) {
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
}
}
function scheduleRetry() {
if (retryTimer) clearTimeout(retryTimer);
retryTimer = setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
}
export function connect() {
if (!browser) return;
if (es) { es.close(); es = null; }
sseState.set('connecting');
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
es.onopen = () => {
sseState.set('connected');
retryDelay = 2000; // reset backoff on successful connect
};
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as SSEEvent;
dispatch(data);
} catch {
// ignore malformed frames
}
};
es.onerror = () => {
sseState.set('error');
es?.close();
es = null;
scheduleRetry();
};
}
export function disconnect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
es?.close();
es = null;
sseState.set('disconnected');
}

View File

@@ -1,81 +0,0 @@
import { writable } from 'svelte/store';
// ── WebSocket message types ───────────────────────────────────────────────────
export type WSMessage =
| { type: 'pong' }
| { type: 'notification'; count: number; item?: unknown }
| { type: 'comment:new'; itemId: number; comment: unknown }
| { type: 'comment:delete'; itemId: number; commentId: number }
| { type: 'item:tag_added'; itemId: number; tag: unknown }
| { type: 'item:favorited'; itemId: number; user: string }
| { type: 'global:activity'; payload: unknown };
// ── Store ─────────────────────────────────────────────────────────────────────
export const wsConnected = writable(false);
let _socket: WebSocket | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _reconnectDelay = 1000;
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
/** Connect to the WebSocket server. Call once from the root layout. */
export function wsConnect(): void {
if (typeof window === 'undefined') return; // SSR guard
if (_socket?.readyState === WebSocket.OPEN) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
_socket = new WebSocket(`${proto}://${location.host}/ws`);
_socket.onopen = () => {
wsConnected.set(true);
_reconnectDelay = 1000; // reset backoff on successful connect
_ping();
};
_socket.onmessage = (e) => {
try {
const msg = JSON.parse(e.data) as WSMessage;
// Dispatch to typed listeners
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
_listeners.get('*')?.forEach((fn) => fn(msg));
} catch {
// ignore malformed messages
}
};
_socket.onclose = () => {
wsConnected.set(false);
_socket = null;
// Exponential backoff reconnect
if (_reconnectTimer) clearTimeout(_reconnectTimer);
_reconnectTimer = setTimeout(() => {
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
wsConnect();
}, _reconnectDelay);
};
_socket.onerror = () => {
_socket?.close();
};
}
/** Send a typed message to the server. */
export function wsSend(msg: object): void {
if (_socket?.readyState === WebSocket.OPEN) {
_socket.send(JSON.stringify(msg));
}
}
/** Subscribe to a specific message type. Returns an unsubscribe function. */
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
if (!_listeners.has(type)) _listeners.set(type, new Set());
_listeners.get(type)!.add(fn);
return () => _listeners.get(type)?.delete(fn);
}
let _pingTimer: ReturnType<typeof setInterval> | null = null;
function _ping() {
if (_pingTimer) clearInterval(_pingTimer);
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
}

View File

@@ -1,21 +0,0 @@
export interface Comment {
id: number;
item_id: number;
username: string;
display_name: string | null;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
content: string; // may be HTML (server-rendered)
stamp: string; // human-readable relative time
stamp_full: string; // ISO timestamp
is_deleted: boolean;
reply_to: number | null;
poll?: unknown;
}
export interface CommentsResponse {
success: boolean;
comments: Comment[];
total?: number;
}

View File

@@ -1,137 +0,0 @@
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
export interface GridItem {
id: number;
mime: string;
dest: string;
username: string;
display_name: string | null;
is_pinned: boolean;
is_oc: boolean;
xd_score: number;
xd_tier: number;
xd_label: string;
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
tag_id: number | null;
thumb_size: 1 | 2;
has_notification: boolean;
}
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
export interface FullItem {
id: number;
mime: string;
dest: string;
username: string;
author_id: number | null;
author_color: string | null;
author_display_name: string | null;
author_avatar: string | null;
author_avatar_file: string | null;
title: string | null;
src: { long: string; short: string };
thumbnail: string;
og_thumbnail: string;
og_description: string;
coverart: string;
size: string;
checksum: string | null;
timestamp: { timeago: string; timefull: string };
favorites: FavoriteUser[];
tags: Tag[];
halls: Hall[];
user_halls: Hall[];
is_nsfw: boolean;
is_nsfl: boolean;
is_sfw: boolean;
is_pinned: boolean;
is_comments_locked: boolean;
is_oc: boolean;
is_repost: boolean;
reposts: RepostItem[];
width: number | null;
height: number | null;
original_filename: string | null;
xd_score: number;
xd_tier: number;
xd_label: string;
primaryHall: Hall | null;
otherHalls: Hall[];
}
// ── Pagination ───────────────────────────────────────────────────────────────
export interface Pagination {
prev: number | null;
next: number | null;
start: number;
end: number;
current: number;
page: number;
cheat: number[];
location?: string;
suffix?: string;
}
// ── Link context ─────────────────────────────────────────────────────────────
export interface ItemLink {
main: string;
mainDisplay?: string;
path: string;
suffix: string;
}
// ── Tags ─────────────────────────────────────────────────────────────────────
export interface Tag {
id: number;
tag: string;
normalized: string;
badge: string;
display_name: string | null;
user: string;
}
// ── Halls ────────────────────────────────────────────────────────────────────
export interface Hall {
name: string;
slug: string;
}
// ── Favorites ────────────────────────────────────────────────────────────────
export interface FavoriteUser {
user: string;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
display_name: string | null;
}
// ── Reposts ──────────────────────────────────────────────────────────────────
export interface RepostItem {
id: number;
username: string;
stamp: number;
match_type: 'checksum' | 'phash';
}
// ── API response wrappers ─────────────────────────────────────────────────────
export interface ItemsResponse {
success: boolean;
items: GridItem[];
pagination: Pagination;
total: number;
link: ItemLink;
}
export interface ItemResponse {
success: boolean;
item: FullItem | null;
pagination: Pagination;
link: ItemLink;
isSubscribed: boolean;
message?: string;
}
export interface RandomResponse {
success: boolean;
id: number;
url: string;
}

View File

@@ -1,27 +0,0 @@
export interface SessionUser {
id: number;
user: string;
login: string;
admin: boolean;
is_moderator: boolean;
mode: number;
theme: string;
avatar: string | null;
avatar_file: string | null;
username_color: string | null;
display_name: string | null;
use_new_layout: boolean;
excluded_tags: number[];
min_xd_score: number;
uploads_remaining: number | undefined;
pending_count?: number;
language: string | null;
disable_autoplay: boolean;
disable_swiping: boolean;
csrf_token: string;
}
export interface SessionResponse {
loggedIn: boolean;
user: SessionUser | null;
}

View File

@@ -1,23 +0,0 @@
/**
* Wrapper around fetch that automatically injects the CSRF token as a header
* on all state-changing requests (POST, PUT, PATCH, DELETE).
*
* The token is read from the session store at call time so it's always fresh.
*/
import { get } from 'svelte/store';
import { session } from '$lib/stores/session';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const method = (init.method ?? 'GET').toUpperCase();
if (SAFE_METHODS.has(method)) return fetch(input, init);
const token = get(session)?.csrf_token;
if (!token) return fetch(input, init); // not logged in — let backend return 401
const headers = new Headers(init.headers ?? {});
headers.set('X-CSRF-Token', token);
return fetch(input, { ...init, headers });
}

View File

@@ -1,32 +0,0 @@
/** Map a MIME type to a simple category string. */
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
if (mime === 'video/youtube') return 'youtube';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('image/')) return 'image';
if (mime === 'application/pdf') return 'pdf';
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
if (mime.startsWith('application/')) return 'archive';
return 'unknown';
}
/** Return a Font Awesome icon class for a MIME category. */
export function mimeIcon(mime: string): string {
const cat = mimeCategory(mime);
const map: Record<string, string> = {
youtube: 'fa-brands fa-youtube',
video: 'fa-solid fa-film',
audio: 'fa-solid fa-music',
image: 'fa-solid fa-image',
flash: 'fa-solid fa-bolt',
pdf: 'fa-solid fa-file-pdf',
archive: 'fa-solid fa-file-zipper',
unknown: 'fa-solid fa-file'
};
return map[cat] ?? map.unknown;
}
/** True if the MIME type represents inline-playable media. */
export function isPlayable(mime: string): boolean {
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
}

View File

@@ -1,45 +0,0 @@
/**
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
* relative time string, e.g. "3 hours ago".
*/
export function timeAgo(input: number | string | Date): string {
const date =
input instanceof Date
? input
: typeof input === 'number'
? new Date(input * 1000)
: new Date(input);
const now = Date.now();
const diff = now - date.getTime();
const epochs: [string, number][] = [
['year', 365 * 24 * 3600 * 1000],
['month', 30 * 24 * 3600 * 1000],
['week', 7 * 24 * 3600 * 1000],
['day', 24 * 3600 * 1000],
['hour', 3600 * 1000],
['minute', 60 * 1000],
['second', 1000]
];
for (const [label, ms] of epochs) {
const n = Math.floor(diff / ms);
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
}
return 'just now';
}
/**
* Format an ISO timestamp to a readable full date string.
*/
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@@ -1,8 +0,0 @@
import type { LayoutServerLoad } from './$types';
/** Pass session to all pages via PageData. */
export const load: LayoutServerLoad = ({ locals }) => {
return {
session: locals.session
};
};

View File

@@ -1,56 +0,0 @@
<script lang="ts">
import '../app.css';
import Navbar from '$lib/components/layout/Navbar.svelte';
import FlashMessages from '$lib/components/ui/FlashMessages.svelte';
import { session as sessionStore } from '$lib/stores/session';
import { initPreferences } from '$lib/stores/preferences';
import { connect as sseConnect, disconnect as sseDisconnect } from '$lib/stores/sse';
import { onMount, onDestroy } from 'svelte';
interface Props {
data: {
session: import('$lib/types/session').SessionUser | null;
};
children: import('svelte').Snippet;
}
let { data, children }: Props = $props();
// Hydrate session store from server-loaded data
$effect(() => {
sessionStore.set(data.session);
});
// Init user preferences from session
$effect(() => {
if (data.session) {
initPreferences({ mode: data.session.mode ?? 3 });
}
});
onMount(() => {
sseConnect();
});
onDestroy(() => {
sseDisconnect();
});
</script>
<svelte:head>
<title>f0ckm</title>
<meta name="description" content="f0ckm — media sharing" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
</svelte:head>
<div class="min-h-dvh flex flex-col" data-theme="f0ck">
<Navbar />
<div class="flex-1">
{@render children()}
</div>
<!-- Global flash messages -->
<FlashMessages />
</div>

View File

@@ -1,30 +0,0 @@
import type { PageServerLoad } from './$types';
import { getItems } from '$lib/api/items';
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const page = Number(url.searchParams.get('page') ?? 1);
const mode = locals.session?.mode ?? 3;
try {
const data = await getItems({ page, mode }, fetch);
if (data.success) {
return {
items: data.items,
pagination: data.pagination,
total: data.total,
link: data.link
};
}
} catch (e) {
// Backend not yet running or API route not registered — return empty state
// so the page renders rather than hard-erroring.
console.warn('[/] Failed to load items:', (e as Error).message);
}
return {
items: [],
pagination: null,
total: 0,
link: { main: '/', path: 'p/', suffix: '' }
};
};

View File

@@ -1,79 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import type { GridItem } from '$lib/types/item';
import ItemCard from '$lib/components/item/ItemCard.svelte';
import Pagination from '$lib/components/ui/Pagination.svelte';
import { onSSE } from '$lib/stores/sse';
import { pinEvent } from '$lib/stores/pin';
import { onDestroy } from 'svelte';
let { data }: { data: PageData } = $props();
// Local mutable copy of the items list — SSE patches it live
let items = $state<GridItem[]>(data.items);
let total = $state(data.total);
// Re-sync when server data changes (e.g. navigation / invalidation)
$effect(() => {
items = data.items;
total = data.total;
});
// ── Live grid updates via SSE ─────────────────────────────────────────────
const unsubSSE = onSSE((event) => {
if (event.type === 'new_item') {
const d = event.data as GridItem & { dest: string; username: string };
if (!items.some(i => i.id === d.id)) {
items = [d, ...items];
total += 1;
}
} else if (event.type === 'delete_item') {
const d = event.data as { id: number };
const before = items.length;
items = items.filter(i => i.id !== d.id);
if (items.length < before) total = Math.max(0, total - 1);
}
});
onDestroy(unsubSSE);
// ── Pin store: patch the grid card is_pinned live ─────────────────────────
const unsubPin = pinEvent.subscribe((evt) => {
if (!evt) return;
items = items.map(i => i.id === evt.itemId ? { ...i, is_pinned: evt.pinned } : i);
});
onDestroy(unsubPin);
</script>
<svelte:head>
<title>f0ckm — {total} items</title>
</svelte:head>
<div class="max-w-[1600px] mx-auto px-3 py-4">
<!-- Grid header -->
{#if total > 0}
<p class="text-xs opacity-40 mb-3 font-mono">{total.toLocaleString()} items</p>
{/if}
<!-- Thumbnail grid -->
{#if items.length > 0}
<div class="
grid gap-1.5
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
">
{#each items as item (item.id)}
<ItemCard {item} link={data.link} />
{/each}
</div>
{#if data.pagination}
<Pagination pagination={data.pagination} link={data.link} />
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
<i class="fa-solid fa-box-open text-5xl"></i>
<p class="text-sm">No items found.</p>
</div>
{/if}
</div>

View File

@@ -1,28 +0,0 @@
import type { PageServerLoad } from './$types';
import { getItem } from '$lib/api/items';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, fetch, locals, url }) => {
const id = params.itemid;
if (!id || !/^\d+$/.test(id)) throw error(404, 'Not found');
const mode = locals.session?.mode ?? 3;
try {
const data = await getItem(id, { mode }, fetch);
if (!data.success || !data.item) {
throw error(404, 'Item not found or not visible in current mode');
}
return {
item: data.item,
pagination: data.pagination,
link: data.link,
isSubscribed: data.isSubscribed
};
} catch (e) {
if (e instanceof Response) throw e;
throw error(500, 'Failed to load item');
}
};

View File

@@ -1,32 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import ItemView from '$lib/components/item/ItemView.svelte';
let { data }: { data: PageData } = $props();
const ogTitle = $derived(data.item.title ?? `#${data.item.id} — f0ckm`);
const ogImage = $derived(data.item.og_thumbnail);
const ogDesc = $derived(data.item.og_description ?? '');
</script>
<svelte:head>
<title>{ogTitle}</title>
<meta name="description" content={ogDesc} />
<!-- OG tags for embeds -->
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={ogDesc} />
<meta property="og:image" content={ogImage} />
<meta property="og:type" content="article" />
<!-- Twitter card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={ogDesc} />
<meta name="twitter:image" content={ogImage} />
</svelte:head>
<ItemView
item={data.item}
pagination={data.pagination}
link={data.link}
isSubscribed={data.isSubscribed}
/>

View File

@@ -1,9 +0,0 @@
// Stub page for /login — will be built in Sprint 2.
// For now redirect to the legacy login page so auth still works.
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// Temporary: redirect to legacy backend login until we build the Svelte login form
throw redirect(307, 'http://localhost:1337/login');
};

View File

@@ -1,16 +0,0 @@
import type { PageServerLoad } from './$types';
import { getRandom } from '$lib/api/items';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch }) => {
try {
const data = await getRandom(fetch);
if (data.success && data.url) {
throw redirect(307, data.url);
}
} catch (e) {
if (e instanceof Response) throw e;
}
// Fallback: go home if no random item found
throw redirect(307, '/');
};

View File

@@ -1,6 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
throw redirect(307, 'http://localhost:1337/register');
};

View File

@@ -1,31 +0,0 @@
import type { PageServerLoad } from './$types';
import { getItems } from '$lib/api/items';
export const load: PageServerLoad = async ({ fetch, url, params, locals }) => {
const tag = decodeURIComponent(params.tag);
const page = Number(url.searchParams.get('page') ?? 1);
const mode = locals.session?.mode ?? 3;
try {
const data = await getItems({ page, mode, tag }, fetch);
if (data.success) {
return {
tag,
items: data.items,
pagination: data.pagination,
total: data.total,
link: data.link
};
}
} catch (e) {
console.warn(`[/tag/${tag}] Failed to load items:`, (e as Error).message);
}
return {
tag,
items: [],
pagination: null,
total: 0,
link: { main: `/tag/${encodeURIComponent(tag)}/`, path: 'p/', suffix: '' }
};
};

View File

@@ -1,76 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import type { GridItem } from '$lib/types/item';
import ItemCard from '$lib/components/item/ItemCard.svelte';
import Pagination from '$lib/components/ui/Pagination.svelte';
import { onSSE } from '$lib/stores/sse';
import { onDestroy } from 'svelte';
let { data }: { data: PageData } = $props();
let items = $state<GridItem[]>(data.items);
let total = $state(data.total);
$effect(() => {
items = data.items;
total = data.total;
});
// Live: remove items that get deleted
// (we don't prepend new_item because it may not match this tag filter)
const unsubSSE = onSSE((event) => {
if (event.type === 'delete_item') {
const d = event.data as { id: number };
const before = items.length;
items = items.filter(i => i.id !== d.id);
if (items.length < before) total = Math.max(0, total - 1);
}
});
onDestroy(unsubSSE);
</script>
<svelte:head>
<title>#{data.tag} — f0ckm</title>
<meta name="description" content="Items tagged #{data.tag} on f0ckm" />
</svelte:head>
<div class="max-w-[1600px] mx-auto px-3 py-4">
<!-- Tag header -->
<div class="flex items-center gap-3 mb-4">
<div class="flex items-center gap-2">
<i class="fa-solid fa-tag text-primary opacity-70" aria-hidden="true"></i>
<h1 class="text-xl font-bold">{data.tag}</h1>
</div>
{#if total > 0}
<span class="badge badge-neutral font-mono text-xs">{total.toLocaleString()}</span>
{/if}
<a href="/" class="btn btn-ghost btn-xs ml-auto opacity-60 hover:opacity-100 gap-1">
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i> Clear
</a>
</div>
<!-- Grid -->
{#if items.length > 0}
<div class="
grid gap-1.5
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
">
{#each items as item (item.id)}
<ItemCard {item} link={data.link} />
{/each}
</div>
{#if data.pagination}
<Pagination pagination={data.pagination} link={data.link} />
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
<i class="fa-solid fa-tag text-5xl" aria-hidden="true"></i>
<p class="text-sm">No items found for <strong>#{data.tag}</strong>.</p>
</div>
{/if}
</div>

View File

@@ -1,27 +0,0 @@
import type { PageServerLoad } from './$types';
import { getItem } from '$lib/api/items';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch, params, locals }) => {
const tag = decodeURIComponent(params.tag);
const itemid = Number(params.itemid);
const mode = locals.session?.mode ?? 3;
if (!itemid) throw error(404, 'Not found');
try {
const data = await getItem(itemid, { mode, tag }, fetch);
if (!data.success || !data.item) throw error(404, data.message ?? 'Item not found');
return {
tag,
item: data.item,
pagination: data.pagination,
link: data.link,
isSubscribed: data.isSubscribed ?? false
};
} catch (e: unknown) {
if ((e as { status?: number })?.status) throw e;
throw error(500, 'Failed to load item');
}
};

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import ItemView from '$lib/components/item/ItemView.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>#{data.tag} — item {data.item.id} — f0ckm</title>
</svelte:head>
<!-- Tag breadcrumb -->
<div class="max-w-[1600px] mx-auto px-4 pt-2">
<div class="text-xs opacity-50 flex items-center gap-1.5">
<a href="/" class="hover:opacity-100 transition-opacity">f0ckm</a>
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
<a href="/tag/{encodeURIComponent(data.tag)}" class="hover:opacity-100 transition-opacity flex items-center gap-1">
<i class="fa-solid fa-tag text-[10px]" aria-hidden="true"></i>
{data.tag}
</a>
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
<span>#{data.item.id}</span>
</div>
</div>
<ItemView
item={data.item}
pagination={data.pagination}
link={data.link}
isSubscribed={data.isSubscribed}
/>

View File

@@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -1,21 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["node"]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -1,47 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
})
],
server: {
port: 5173,
proxy: {
// Proxy API + WebSocket to the existing f0ckm backend
'/api': {
target: 'http://localhost:1337',
changeOrigin: true
},
'/ajax': {
target: 'http://localhost:1337',
changeOrigin: true
},
'/ws': {
target: 'ws://localhost:1337',
ws: true
},
// Static media files — use exact prefix matching to avoid
// colliding with Vite's own /src/ module paths.
// Each rule anchored to exactly /X/ (with trailing slash).
'^/b/': { target: 'http://localhost:1337', changeOrigin: true },
'^/t/': { target: 'http://localhost:1337', changeOrigin: true },
'^/ca/': { target: 'http://localhost:1337', changeOrigin: true },
'^/a/': { target: 'http://localhost:1337', changeOrigin: true },
// /s/ — NOTE: must NOT match /src/ — regex anchors it
'^/s/': { target: 'http://localhost:1337', changeOrigin: true },
'^/memes/': { target: 'http://localhost:1337', changeOrigin: true },
// mod actions (pin, unpin, delete)
'^/mod/': { target: 'http://localhost:1337', changeOrigin: true }
}
}
});

View File

View File

@@ -0,0 +1,5 @@
-- Add favorites_private column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';

View File

@@ -0,0 +1,5 @@
-- Add hide_fav_badge column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';

View File

@@ -0,0 +1,38 @@
-- Add visibility and slug columns to items table
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
-- Populate missing slugs for existing items
DO $$
DECLARE
r RECORD;
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
new_slug text;
i integer;
BEGIN
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
LOOP
new_slug := '';
FOR i IN 1..11 LOOP
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
END LOOP;
BEGIN
UPDATE items SET slug = new_slug WHERE id = r.id;
EXIT;
EXCEPTION WHEN unique_violation THEN
END;
END LOOP;
END LOOP;
END $$;
-- Fix any items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;

View File

@@ -114,6 +114,7 @@ DROP INDEX IF EXISTS public.idx_items_username;
DROP INDEX IF EXISTS public.idx_items_pinned_id;
DROP INDEX IF EXISTS public.idx_items_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;
@@ -910,12 +911,18 @@ CREATE TABLE public.items (
original_filename text,
title text,
width integer,
height integer
height integer,
visibility smallint DEFAULT 0 NOT NULL,
slug character varying(16)
);
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
--
@@ -1498,12 +1505,25 @@ 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
force_comment_display_mode integer DEFAULT 0,
favorites_private boolean DEFAULT false,
hide_fav_badge boolean DEFAULT false,
default_upload_visibility smallint DEFAULT 0,
banner_file character varying(255) DEFAULT NULL::character varying,
banner_position character varying(50) DEFAULT 'center'::character varying,
banner_size character varying(50) DEFAULT 'cover'::character varying
);
ALTER TABLE public.user_options OWNER TO f0ckm;
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
--
@@ -2162,6 +2182,13 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
--
-- 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
--
@@ -2944,4 +2971,9 @@ ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
-- 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

View File

@@ -0,0 +1,2 @@
-- Fix items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;

View File

@@ -1237,7 +1237,7 @@ html[theme='95'] .item-main-content .metadata {
.notif-count {
position: absolute;
top: 0px;
top: 0;
right: -5px;
background: var(--badge-nsfw);
color: white;
@@ -1650,6 +1650,10 @@ 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 {
@@ -1984,7 +1988,7 @@ body.layout-modern .global-sidebar-right {
right: 0;
width: 100%;
height: 20px;
background: linear-gradient(to bottom, transparent, rgba(28, 27, 27, 0.8));
background: linear-gradient(to bottom, transparent, var(--bg));
pointer-events: none;
}
@@ -2119,43 +2123,29 @@ body.layout-modern .global-sidebar-right {
font-size: 1.5em;
}
/* Mobile Stacking for Legacy Mode (max-width: 999px) */
/* Mobile sidebar (max-width: 999px): sidebar fixed, capped to viewport width */
@media (max-width: 999px) {
.item-layout-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 5px;
}
.item-layout-container .item-main-content {
order: 1;
/* Main content (video) on top */
/* padding: 10px; */
}
.item-sidebar-left {
order: 2;
/* Comments/Tags on bottom */
/* padding: 10px; */
z-index: 1;
}
/* Mobile Overrides if necessary — most styles now universal */
/* Sidebar itself: cap width to viewport, always right-anchored */
.item-sidebar-right,
.index-sidebar-right,
.global-sidebar-right {
width: 280px !important;
width: min(280px, 80vw) !important;
right: 0 !important;
}
body.sidebar-right-hidden .item-sidebar-right,
body.sidebar-right-hidden .index-sidebar-right,
body.sidebar-right-hidden .global-sidebar-right {
right: -280px !important;
right: calc(-1 * min(280px, 80vw)) !important;
}
/* Mobile: sidebar is an overlay, so pagination always spans full viewport */
.pagination-container-fluid,
/* 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;
}
@@ -2166,6 +2156,99 @@ body.layout-modern .global-sidebar-right {
}
}
/* 600999px: 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 600999px.
Its CSS grid only activates at ≥1000px; without flex-column here
the layout falls back to broken block flow. */
body.layout-modern .item-layout-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 5px;
}
body.layout-modern .item-layout-container .item-main-content {
order: 1;
}
body.layout-modern .item-sidebar-left {
order: 2;
z-index: 1;
}
/* Legacy layout: 2-column grid with mobile sidebar width. */
body.layout-legacy .item-layout-container {
display: grid;
grid-template-columns: minmax(0, 1fr) min(280px, 80vw);
height: auto !important;
overflow: visible !important;
}
body.layout-legacy.sidebar-right-hidden .item-layout-container {
grid-template-columns: minmax(0, 1fr) 0px;
}
}
/* Very small screens (≤599px): sidebar is always a pure overlay — never reserve space for it.
Wrappers stay at full width regardless of sidebar state; no transitions needed here.
Item view also switches to stacked layout here (not at 999px). */
@media (max-width: 599px) {
/* Item view: stack content above comments/sidebar-left */
.item-layout-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 5px;
}
.item-layout-container .item-main-content {
order: 1;
}
.item-sidebar-left {
order: 2;
z-index: 1;
}
.index-layout-wrapper,
.item-layout-container,
.pagewrapper,
body.sidebar-right-hidden .index-layout-wrapper,
body.sidebar-right-hidden .item-layout-container,
body.sidebar-right-hidden .pagewrapper {
padding-right: 5px !important;
transition: none !important;
}
.pagination-container-fluid,
body.sidebar-right-hidden .pagination-container-fluid {
right: 0 !important;
transition: none !important;
}
}
.item-layout-container .item-main-content>._204863,
.item-layout-container .item-main-content>.item_title,
@@ -2958,7 +3041,8 @@ body.layout-legacy .scroll-nav-wrapper {
right: -45px;
width: 40px;
pointer-events: none;
z-index: 1000;
z-index: 0;
display: none;
}
body.layout-legacy .scroll-to-bottom {
@@ -3064,7 +3148,7 @@ body.layout-legacy .scroll-to-bottom svg {
color: #888;
display: flex;
justify-content: space-between;
align-items: flex-end;
align-items: flex-start;
line-height: normal;
flex-wrap: nowrap;
gap: 2px 10px;
@@ -3072,8 +3156,9 @@ body.layout-legacy .scroll-to-bottom svg {
.comment-header-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
gap: 2px 6px;
min-width: 0;
flex-shrink: 1;
}
@@ -3196,9 +3281,13 @@ body.layout-legacy .scroll-to-bottom svg {
.comment-author {
font-weight: bold;
color: var(--accent);
margin-right: 8px;
margin-right: 2px;
padding-right: 0 !important;
word-break: break-word;
white-space: nowrap;
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-time {
@@ -3256,6 +3345,8 @@ 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,
@@ -3319,10 +3410,7 @@ body.layout-legacy .scroll-to-bottom svg {
}
.comment-backlinks {
display: inline-block;
font-size: 0.85em;
opacity: 0.8;
margin-left: 10px;
display: contents;
}
.comments-header-actions {
@@ -3619,6 +3707,7 @@ 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;
@@ -3628,6 +3717,31 @@ 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 {
@@ -3739,8 +3853,8 @@ html[theme='f0ck95d'] {
html[theme="f0ck95d"] .item_title {
background: black;
border-left: inset 3px silver;
border-right: outset 3px silver;
border-left: 2px solid gray;
border-right: 2px solid gray;
}
html[theme="f0ck95d"] .err {
@@ -3755,15 +3869,35 @@ 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-left: inset 2px silver;
border-top: inset 2px silver;
border-right: outset 2px silver;
border-bottom: outset 2px silver;
border: 2px solid gray;
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 {
@@ -3772,15 +3906,13 @@ html[theme="f0ck95d"] .embed-responsive-image {
}
html[theme="f0ck95d"] .media-object {
border-left: inset 3px silver;
border-right: outset 3px silver;
border-left: 2px solid gray;
border-right: 2px solid gray;
background: black;
border-bottom: 2px solid gray;
}
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
background: black;
border-left: inset 1px silver;
border-right: outset 1px silver;
}
html[theme="f0ck95d"] .embed-responsive-image {
border: none;
@@ -3896,6 +4028,11 @@ 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;
@@ -4488,16 +4625,17 @@ 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;
@@ -4506,7 +4644,6 @@ body.layout-modern .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;
}
@@ -4719,7 +4856,7 @@ body.layout-modern .pagination-container-fluid {
min-width: 0;
}
@media (min-width: 1200px) {
@media (min-width: 1000px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
.index-layout-wrapper {
@@ -5273,6 +5410,10 @@ 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;
}
@@ -7196,6 +7337,11 @@ span#favs {
border-top: 1px solid var(--nav-border-color);
}
body.layout-modern span#favs {
border-top: none;
}
span#favs[hidden] {
display: none !important;
}
@@ -7702,6 +7848,7 @@ div.posts>a.thumb > p {
bottom: 5px;
left: 5px;
gap: 2px;
align-items: center;
}
/* Make individual item entry subtler and faster for infinite scroll */
@@ -9495,77 +9642,16 @@ 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 {
animation: newItemFlash 2.5s ease forwards;
border-left: 3px solid transparent;
box-shadow: inset 3px 0px 0px var(--accent);
}
.sidebar-activity .new-item-fade {
border-left: none;
animation: none;
box-shadow: 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;
@@ -9747,6 +9833,20 @@ html[theme="f0ck95d"] .badge-dark {
}
}
/* At 600999px: 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;
}
@@ -9888,14 +9988,6 @@ html[theme="orange"] .image-brand {
}
}
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
border-bottom: outset 2px silver;
}
html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
border-bottom: outset 2px silver;
}
/* Fade in video on page load */
@keyframes fadeIn {
@@ -9923,7 +10015,7 @@ html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
position: relative;
}
@media (min-width: 1200px) {
@media (min-width: 1000px) {
.pagewrapper {
padding-right: 300px;
}
@@ -9941,7 +10033,7 @@ html[theme="f0ck95"] .embed-responsive.embed-responsive-16by9 {
Since Index and Item pages have their own layout wrappers that handle space reservation,
we disable the generic pagewrapper padding on those pages to avoid "double padding" or broken expansion.
*/
@media (min-width: 1200px) {
@media (min-width: 1000px) {
.pagewrapper:has(.index-layout-wrapper),
.pagewrapper:has(.item-layout-container),
@@ -10090,7 +10182,7 @@ ol {
display: flex;
align-items: center;
gap: 20px;
padding: 0 15px;
padding: 0 5px;
flex-shrink: 0;
margin-left: auto;
}
@@ -10207,7 +10299,7 @@ ol {
}
/* ── Mobile: ≤768px ── */
@media (max-width: 768px) {
@media (max-width: 800px) {
/* Show hamburger */
.navbar-toggler {
@@ -11070,8 +11162,7 @@ div.posts>a::after {
.pin-indicator {
position: absolute;
top: 5px;
position: relative;
left: 5px;
font-size: 13px;
fill: var(--accent);
@@ -11393,11 +11484,12 @@ body.layout-legacy .navbar-header {
}
/* layout-modern: restore sidebar right-padding AND block-flow for generic pages
(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) {
(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 10001199px is covered. */
@media (min-width: 1000px) {
body.layout-modern>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
padding-right: 400px;
padding-right: 300px;
}
body.layout-modern.sidebar-right-hidden>.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)):not(:has(.meme-layout-wrapper)) {
@@ -12966,59 +13058,45 @@ body.layout-modern .tag-ac-input {
Full-height chat layout — conversation page
══════════════════════════════════════════════════════════ */
/* ── 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;
}
/* ── 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;
}
@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;
/* Desktop: 300px reserved space (both layouts) */
@media (min-width: 1000px) {
body:not(.sidebar-right-hidden) .messages-convo-page {
right: 300px !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;
/* 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;
}
}
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;
}
/* 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;
}
/* ── Shared: convo pane is a flex column ──────────────────
@@ -13028,7 +13106,8 @@ body.layout-modern .tag-ac-input {
overflow: hidden;
padding: 0;
max-width: 720px;
width: 100%;
/* Removed width: 100% here because it overconstrains the fixed element
against left:0 and right:300px, causing negative margins and overlap. */
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -16060,12 +16139,21 @@ 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 {
@@ -16082,6 +16170,26 @@ 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;

View File

@@ -13,8 +13,9 @@
/* Meme Creator Styles */
.meme-creator-container {
box-sizing: border-box !important;
padding: 20px;
max-width: 1200px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
position: relative;
@@ -111,6 +112,7 @@
.canvas-wrapper {
flex: 1;
max-width: 650px;
min-width: 0; /* Allow shrinking */
background: #000;
border: 2px solid var(--nav-bg, #2b2b2b);
@@ -238,22 +240,49 @@ canvas#memeCanvas {
color: var(--white, #fff);
}
/* Mobile Stacking - Simple 2-Row Layout */
/* Mobile Stacking - Clean Flexbox Column Layout */
@media (max-width: 950px) {
.meme-editor-layout {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 0.6fr auto !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 20px;
}
.meme-controls {
box-sizing: border-box !important;
width: 100% !important;
grid-row: 2;
max-width: 500px !important;
overflow: visible !important;
}
.canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
grid-row: 1;
max-width: 650px !important;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
}
}
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
This prevents the canvas from shrinking too much when the sidebar takes space. */
@media (max-width: 1250px) {
body:not(.sidebar-right-hidden) .meme-editor-layout {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 20px;
}
body:not(.sidebar-right-hidden) .meme-controls {
box-sizing: border-box !important;
width: 100% !important;
max-width: 500px !important;
overflow: visible !important;
}
body:not(.sidebar-right-hidden) .canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
max-width: 650px !important;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
@@ -294,20 +323,19 @@ canvas#memeCanvas {
/* Sidebar space reservation for meme pages */
.meme-layout-wrapper {
box-sizing: border-box !important;
width: 100%;
transition: padding-right 0.3s ease-in-out;
}
@media (min-width: 1200px) {
@media (min-width: 1000px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
.meme-layout-wrapper {
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
padding-right: 300px;
}
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0;
}
}
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0 !important;
}

View File

@@ -362,6 +362,7 @@
}
.upload-form.shitpost-mode-active .global-rating-section,
.upload-form.shitpost-mode-active .global-visibility-section,
.upload-form.shitpost-mode-active .global-comment-section,
.upload-form.shitpost-mode-active .global-tag-section {
display: none !important;
@@ -419,6 +420,26 @@
border-color: var(--badge-nsfl);
}
/* Visibility Container - Only checked option is colored, unchecked options are gray */
.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
color: #51cf66;
border-color: rgba(81, 207, 102, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
color: #ffbb33;
border-color: rgba(255, 187, 51, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border-color: rgba(255, 68, 68, 0.5);
}
.item-rating-label:hover {
@@ -662,6 +683,32 @@
opacity: 1;
}
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
border-color: #51cf66;
color: #51cf66;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
border-color: #ffbb33;
color: #ffbb33;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
border-color: #ff4444;
color: #ff4444;
box-shadow: none;
opacity: 1;
}
/* Tags */
.tag-input-container {
background: rgba(255, 255, 255, 0.05);

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="#242526"/>
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -1,18 +1,24 @@
(async () => {
// Helper to get dynamic context
const getContext = () => {
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const usernameEl = document.querySelector("a#a_username");
return {
postid: +idLink.innerText,
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
poster: (usernameEl?.dataset?.username || '').trim() || null,
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
};
};

View File

@@ -54,7 +54,7 @@ class CommentSystem {
document.body.classList.remove('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.remove('sidebar-hidden');
} else if (isHidden) {
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
this.container.classList.add('faded-out');
this.container.style.display = 'none';
document.body.classList.add('sidebar-left-hidden');
@@ -596,7 +596,7 @@ class CommentSystem {
if (cached) cached.content = fullContent;
}
contentEl.dataset.raw = this.escapeHtml(fullContent);
contentEl.dataset.raw = fullContent;
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl);
@@ -622,6 +622,7 @@ class CommentSystem {
const contentEl = el.querySelector('.comment-content');
if (contentEl) {
contentEl.dataset.raw = data.content;
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl);
@@ -857,26 +858,14 @@ class CommentSystem {
* border-right) is fully restored after the effect completes.
*
* @param {Element} el - The .comment element to animate.
* @param {boolean} entering - True to also add 'comment-entering' (own post);
* false for 'new-item-fade' only (live others).
* @param {boolean} entering - Unused (kept for compatibility).
*/
static _animateNewComment(el, entering = false) {
if (!el) return;
const classes = entering
? ['comment-entering', 'new-item-fade']
: ['new-item-fade'];
// Reset in case the element already has a stale animation
classes.forEach(c => el.classList.remove(c));
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
classes.forEach(c => el.classList.add(c));
const cleanup = () => {
classes.forEach(c => el.classList.remove(c));
};
el.addEventListener('animationend', cleanup, { once: true });
el.classList.add('new-item-fade');
setTimeout(() => {
el.classList.remove('new-item-fade');
}, 2500);
}
@@ -892,7 +881,6 @@ class CommentSystem {
if (el) {
// Remove highlight from any previously highlighted or newly-posted comment
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
document.querySelectorAll('.comment-entering').forEach(c => c.classList.remove('comment-entering'));
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
// Always smooth-scroll so there's no jarring jump
@@ -1038,7 +1026,7 @@ class CommentSystem {
preview.dataset.id = targetId;
// Remove temporary animation/highlight classes
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
preview.classList.remove('new-item-fade', 'comment-highlighted');
// Remove input forms or action buttons
const actions = preview.querySelector('.comment-actions');
@@ -1172,12 +1160,13 @@ class CommentSystem {
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
const contentEl = body.querySelector('.comment-content');
if (contentEl) {
const LINE_MAX = 200;
const rawText = (contentEl.dataset.raw || '').trim();
// Preserve all lines but cap any single line exceeding LINE_MAX chars
const lines = rawText.split('\n').map(line =>
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
);
let rawText = (contentEl.dataset.raw || '').trim();
if (rawText.includes('&gt;') || rawText.includes('&lt;') || rawText.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = rawText;
rawText = txt.value;
}
const lines = rawText.split('\n');
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
if (isNew) {
@@ -1632,17 +1621,25 @@ class CommentSystem {
syncLevel(roots, list, false);
}
// Maximum characters to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
// Maximum characters & lines to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 500; }
static get ITEM_VIEW_MAX_LINES() { return 6; }
renderCommentContent(content, commentId = null, bypassTruncation = false) {
if (!content) return '';
// Truncate extremely long comments before any processing
// Truncate long comments before any processing
let truncated = false;
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
truncated = true;
if (!bypassTruncation) {
const lines = content.split('\n');
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
truncated = true;
}
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
truncated = true;
}
}
if (typeof marked === 'undefined') {
@@ -1791,9 +1788,10 @@ class CommentSystem {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const renderedContent = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: quoteContent;
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${renderedContent}</span>`;
}
@@ -2199,7 +2197,7 @@ class CommentSystem {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
renderCommentAttachments(files, content = '') {
@@ -2407,10 +2405,50 @@ class CommentSystem {
}
}, { passive: true });
// Global click listener to close popups (useful for mobile dismissal)
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
document.addEventListener('click', (e) => {
const isLink = e.target.closest('.comment-context-link');
const isPopup = e.target.closest('.comment-preview-popup');
const target = e.target;
// Load full comment (expand truncated)
const loadFullBtn = target.closest('.load-full-comment-btn');
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.collapse-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
}
// Collapse full comment back to truncated view
const collapseBtn = target.closest('.collapse-comment-btn');
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.load-full-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
}
const isLink = target.closest('.comment-context-link');
const isPopup = target.closest('.comment-preview-popup');
if (!isLink && !isPopup) {
this.closePreviewsAboveLevel(-1);
@@ -2825,7 +2863,10 @@ class CommentSystem {
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
const fullContent = contentEl.dataset.raw;
if (contentEl.querySelector('.collapse-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
// Append "see less" button after full content
@@ -2833,6 +2874,7 @@ class CommentSystem {
contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
@@ -2843,9 +2885,13 @@ class CommentSystem {
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
const fullContent = contentEl.dataset.raw;
if (contentEl.querySelector('.load-full-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
@@ -3336,9 +3382,15 @@ class CommentSystem {
params.append('has_poll', '1');
}
const csrfToken = window.f0ckSession?.csrf_token || '';
if (csrfToken) params.append('csrf_token', csrfToken);
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: params
});
@@ -3604,9 +3656,9 @@ class CommentSystem {
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
// optimistic DOM insert to guarantee the button regardless of rendering path.
_ensureTruncationButton(commentEl, rawContent) {
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
console.log('[ensureBtn] below threshold, skipping');
if (!rawContent) return;
const lineCount = rawContent.split('\n').length;
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
return;
}
const contentEl = commentEl.querySelector('.comment-content');
@@ -3808,9 +3860,12 @@ class CommentSystem {
toggleComments() {
if (!this.container) return;
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
if (document.body.classList.contains('layout-modern')) {
if (window.innerWidth < 1000) return;
if (typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
}
}
const layout = this.container.closest('.item-layout-container');
@@ -4457,20 +4512,71 @@ class CommentSystem {
}, { passive: true });
};
// Helper: reliable touch handling for tab buttons on mobile
const addTabTouch = (btn, cb) => {
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastHoldTime = 0;
const addTabTouch = (btn, cb, onHold) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// Build tab buttons — Favorites first
@@ -4482,23 +4588,13 @@ class CommentSystem {
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
favTab.addEventListener('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
// Right-click on fav tab → set/unset as default
favTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
if (Date.now() - lastHoldTime < 1000) return;
toggleFavDefault(favTab);
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {

View File

@@ -965,7 +965,7 @@ window.cancelAnimFrame = (function () {
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal'
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal'
];
modalIds.forEach(id => {
// Don't close the filter modal during a background mime-filter reload
@@ -2307,9 +2307,49 @@ window.cancelAnimFrame = (function () {
hidden = hiddenValue === 'true';
}
document.body.classList.toggle('sidebar-right-hidden', hidden);
// Always force-hide on very small screens regardless of saved preference
if (window.innerWidth <= 599) {
document.body.classList.add('sidebar-right-hidden');
}
};
window.initSidebarRightToggle();
// Auto-hide sidebar on very small screens (≤599px), restore on wider
(() => {
const mq = window.matchMedia('(max-width: 599px)');
const applyBreakpoint = (e) => {
// Freeze transitions so the class change is instant (no sliding during resize)
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
const wrappers = document.querySelectorAll('.index-layout-wrapper, .item-layout-container, .pagination-container-fluid, .pagewrapper');
if (sidebar) sidebar.style.setProperty('transition', 'none', 'important');
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
if (e.matches) {
// Entering narrow: force-hide without touching localStorage
document.body.classList.add('sidebar-right-hidden');
} else {
// Leaving narrow: restore from saved preference
const saved = localStorage.getItem('sidebarRightHidden');
const hidden = saved === 'true';
document.body.classList.toggle('sidebar-right-hidden', hidden);
}
// Force reflow then restore transitions
if (sidebar) void sidebar.offsetWidth;
requestAnimationFrame(() => {
if (sidebar) sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
mq.addEventListener('change', applyBreakpoint);
applyBreakpoint(mq); // run once on init
})();
const loadPageAjax = async (url, replace = true, options = {}) => {
if (isNavigating) return;
@@ -2402,8 +2442,8 @@ window.cancelAnimFrame = (function () {
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
const parts = pathname.split('/').filter(Boolean);
const isItem = !pathname.match(/\/p\//) && (
pathname.match(/^\/\d+/) ||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1]))
pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
);
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
@@ -3316,19 +3356,19 @@ window.cancelAnimFrame = (function () {
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
// Split path, filter numeric, pop last.
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
// Clear and Hide navbar pagination for Item View (Never show grid pagination on item view)
// document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = ''); // Don't destroy content (cached grid needs it)
document.querySelectorAll('.pagination-container-fluid').forEach(el => el.style.display = 'none');
if (numericSegments.length === 0) {
console.warn("loadItemAjax: No ID match found in URL", url);
if (keySegments.length === 0) {
console.warn("loadItemAjax: No ID or slug match found in URL", url);
// fallback for weird/external links
window.location.href = url;
return;
}
const itemid = numericSegments.pop();
const itemid = keySegments.pop();
// Extract context from Target URL first
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
@@ -3511,18 +3551,19 @@ window.cancelAnimFrame = (function () {
}
const _hash = new URL(url, window.location.origin).hash;
let _pushUrl = `/${itemid}`;
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
const itemKey = _cachedItem.slug || itemid;
let _pushUrl = `/${itemKey}`;
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`;
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
if (_hash) _pushUrl += _hash;
if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl);
document.title = `${window.f0ckDomain} - ${itemid}`;
document.title = `${window.f0ckDomain} - ${itemKey}`;
if (navbar) navbar.classList.remove('pbwork');
if (!options.keepMedia) {
@@ -3547,14 +3588,14 @@ window.cancelAnimFrame = (function () {
.then(r => r.ok ? r.text() : null)
.then(freshText => {
if (!freshText) return;
try { JSON.parse(freshText); } catch(_) {}
// Re-parse to get html string
let freshHtml = freshText;
let freshSlug = null;
try {
const d = JSON.parse(freshText);
if (d && typeof d.html === 'string') freshHtml = d.html;
if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug;
} catch(_) {}
itemCacheMap.set(_itemCacheKey, { html: freshHtml, ts: Date.now() });
itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, ts: Date.now() });
window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey);
})
.catch(() => {});
@@ -3579,7 +3620,7 @@ window.cancelAnimFrame = (function () {
- Total Network: ${(tBody - tStart).toFixed(2)}ms
- Content Size: ${(rawText.length / 1024).toFixed(2)} KB`);
let html, paginationHtml;
let html, paginationHtml, responseSlug = null;
try {
// Optimistically try to parse as JSON first
@@ -3592,6 +3633,7 @@ window.cancelAnimFrame = (function () {
if (data && typeof data.html === 'string') {
html = data.html;
paginationHtml = data.pagination;
responseSlug = data.slug || data.item?.slug || null;
} else {
html = rawText;
}
@@ -3602,7 +3644,7 @@ window.cancelAnimFrame = (function () {
// ── Store in item cache (stale-while-revalidate) ───────────────────────
if (html && !options.skipCache) {
itemCacheMap.set(_itemCacheKey, { html, ts: Date.now() });
itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, ts: Date.now() });
if (itemCacheMap.size > ITEM_CACHE_MAX) {
// Evict oldest entry
itemCacheMap.delete(itemCacheMap.keys().next().value);
@@ -3709,26 +3751,25 @@ window.cancelAnimFrame = (function () {
// Construct proper History URL (Context Aware)
// If we inherited context, we should reflect it in the URL
const hash = new URL(url, window.location.origin).hash;
let pushUrl = `/${itemid}`;
const itemKey = responseSlug || itemid;
let pushUrl = `/${itemKey}`;
// Logic from ajax.mjs context reconstruction:
if (userHall && userHallOwner) {
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
} else if (user) {
pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`;
}
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`;
// Append tagger filter so item nav stays in tagger context
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) {
// If it already has itemid at the end, insert mime before it
pushUrl = pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
// If it already has itemKey at the end, insert mime before it
pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
}
// Re-append hash if present
if (hash) pushUrl += hash;
@@ -3748,8 +3789,7 @@ window.cancelAnimFrame = (function () {
if (window.initVisualizer) window.initVisualizer();
}
// Try to extract ID from response if possible or just use itemid
document.title = `${window.f0ckDomain} - ${itemid}`;
if (navbar) navbar.classList.remove("pbwork");
document.title = `${window.f0ckDomain} - ${itemKey}`;
window.f0ckDebug("AJAX load complete");
// Notify extensions — also triggers CommentSystem init which renders comments
@@ -3781,6 +3821,7 @@ window.cancelAnimFrame = (function () {
console.error("AJAX load failed:", err);
} finally {
isNavigating = false;
if (navbar) navbar.classList.remove("pbwork");
}
};
@@ -3959,15 +4000,16 @@ window.cancelAnimFrame = (function () {
fetch(randomUrl)
.then(r => r.json())
.then(data => {
if (data.success && data.items && data.items.id) {
if (data.success && data.items && (data.items.slug || data.items.id)) {
const targetKey = data.items.slug || data.items.id;
// Navigate in the same context (user hall, favs, tag, etc.)
if (wUserHall && wUserHallOwner) {
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${data.items.id}`, true);
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true);
} else if (wFavsUser) {
// Preserve /user/:name/favs/:id context so next/prev arrows stay within favs
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${data.items.id}`, true);
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true);
} else {
loadItemAjax(`/${data.items.id}`, true);
loadItemAjax(`/${targetKey}`, true);
}
} else {
window.location.href = link.href;
@@ -4039,7 +4081,7 @@ window.cancelAnimFrame = (function () {
return false;
}
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
e.preventDefault();
e.stopImmediatePropagation();
@@ -4052,9 +4094,10 @@ window.cancelAnimFrame = (function () {
const parts = pathname.split('/').filter(Boolean);
const isItemLink = !pathname.match(/\/p\//) && (
pathname.match(/^\/\d+/) ||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'h' && /^\d+$/.test(parts[parts.length - 1]))
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
);
if (isItemLink) {
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
@@ -4122,6 +4165,25 @@ window.cancelAnimFrame = (function () {
})
.catch(console.error);
} else if (target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn')) {
e.preventDefault();
const visTrigger = target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn');
const id = visTrigger.dataset.itemId || document.getElementById('visibility-item-id')?.value;
const visBtnMain = document.getElementById('a_visibility');
const rawVis = visTrigger.dataset.visibility ?? visBtnMain?.dataset.visibility ?? '0';
const currentVis = parseInt(rawVis, 10);
const modal = document.getElementById('visibility-modal');
if (modal) {
const inputId = document.getElementById('visibility-item-id');
if (inputId && id) inputId.value = id;
const radios = modal.querySelectorAll('input[name="visibility"]');
radios.forEach(r => {
r.checked = (parseInt(r.value, 10) === currentVis);
});
modal.style.display = 'flex';
document.body.classList.add('modal-open');
}
} else if (target.closest('#a_rethumb')) {
e.preventDefault();
const reBtn = target.closest('#a_rethumb');
@@ -4427,6 +4489,10 @@ window.cancelAnimFrame = (function () {
if (window.renderTags) {
window.renderTags(res.tags);
}
// Evict the cached item HTML so navigating back fetches fresh content
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
} else {
revert();
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
@@ -4486,8 +4552,10 @@ window.cancelAnimFrame = (function () {
const parts = p.split('/').filter(Boolean);
const isItem = !p.match(/\/p\//) && (
p.match(/^\/\d+/) ||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1]))
p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
);
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
@@ -6277,8 +6345,10 @@ window.cancelAnimFrame = (function () {
if (settled) return;
settled = true;
// Freeze all transitions (override CSS !important) so the class toggle
// doesn't trigger an additional right-property animation.
// Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger a second padding-right animation.
const wrappers = getSidebarWrappers();
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) {
@@ -6296,6 +6366,11 @@ window.cancelAnimFrame = (function () {
// Restore CSS-controlled transitions on the next frame
requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
swipeRT.isDraggingSidebar = false;
});
};
@@ -6533,6 +6608,10 @@ window.cancelAnimFrame = (function () {
const settle = () => {
if (settled) return;
settled = true;
// Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger a second padding-right animation.
const wrappers = getSidebarWrappers();
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) {
@@ -6545,6 +6624,11 @@ window.cancelAnimFrame = (function () {
sidebar.style.transform = '';
requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
@@ -6612,15 +6696,55 @@ window.cancelAnimFrame = (function () {
});
window.addEventListener('resize', syncEdgeZone, { passive: true });
// Touchend on edgeZone: instant tap-to-toggle (no 300ms synthetic click delay)
// Returns true if (clientX, clientY) is within the pill's hit area.
// The pill is #sidebar-drag-zone::after — 4×40px, centered on pointer:fine,
// right-aligned with padding-right:6px on pointer:coarse.
const isWithinPillArea = (clientX, clientY) => {
const rect = edgeZone.getBoundingClientRect();
const pillH = 40;
const hitPad = 12; // generous padding so it's easy to hit
// Vertical: pill is always centered in the drag zone
const pillCenterY = rect.top + rect.height / 2;
if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false;
// Horizontal: right-aligned on touch, centered on mouse
const isCoarse = window.matchMedia('(pointer: coarse)').matches;
const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2;
return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad;
};
// Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe
edgeZone.addEventListener('touchend', e => {
// Only if it was a clean tap (no significant swipe movement)
if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
e.preventDefault();
edgeZone.dispatchEvent(new Event('click'));
e.preventDefault(); // always prevent 300ms synthetic click
const touch = e.changedTouches[0];
if (touch && isWithinPillArea(touch.clientX, touch.clientY)) {
window.toggleSidebarRight();
}
}
}, { passive: false });
// Helper: all layout wrappers that have padding-right animated by sidebar state
const getSidebarWrappers = () => {
// At ≤999px: sidebar is a pure overlay — only pagination tracks it.
if (window.innerWidth <= 599) {
return document.querySelectorAll('.pagination-container-fluid');
}
// At ≥600px: exclude pagewrapper when a dedicated layout wrapper is present.
// CSS already zeroes pagewrapper.padding-right via :has on those pages,
// so including it here causes double-padding (pagewrapper + index-layout-wrapper both
// get 300px snapped, making the grid 600px too narrow, then jumping back on settle).
const hasDedicatedWrapper = !!(
document.querySelector('.index-layout-wrapper') ||
document.querySelector('.item-layout-container') ||
document.querySelector('.meme-layout-wrapper') ||
document.querySelector('.messages-convo-page')
);
const sel = hasDedicatedWrapper
? '.index-layout-wrapper, .meme-layout-wrapper, .messages-convo-page, .pagination-container-fluid'
: '.pagination-container-fluid, .pagewrapper';
return document.querySelectorAll(sel);
};
// Smoothly toggle sidebar open/closed
window.toggleSidebarRight = () => {
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
@@ -6630,6 +6754,29 @@ window.cancelAnimFrame = (function () {
const sidebarWidth = sidebar.offsetWidth || 300;
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
// At ≤599px the sidebar is a pure overlay — never animate wrapper padding
const animateWrappers = window.innerWidth > 599;
// Grid/pagewrapper: snap instantly in both directions (no animated reflow).
// Pagination always animates — it tracks sidebar position with no reflow cost.
const wrappers = getSidebarWrappers();
if (animateWrappers) {
wrappers.forEach(w => {
const isPagination = w.classList.contains('pagination-container-fluid');
const isMessages = w.classList.contains('messages-convo-page');
const prop = (isPagination || isMessages) ? 'right' : 'padding-right';
const targetVal = isHidden ? sidebarWidth + 'px' : '0px';
// Lightweight layouts (pagination, messages) animate smoothly.
// Heavy/reflow-intensive layouts snap instantly to avoid reflow lag.
const shouldAnimate = (isPagination || isMessages);
w.style.setProperty('transition', shouldAnimate ? `${prop} 0.3s ease-in-out` : 'none', 'important');
w.style.setProperty(prop, targetVal, 'important');
});
void sidebar.offsetWidth; // settle layout before sidebar transform begins
}
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
sidebar.style.transform = `translateX(${targetTranslate}px)`;
@@ -6637,13 +6784,22 @@ window.cancelAnimFrame = (function () {
const settle = () => {
if (settled) return;
settled = true;
// Freeze wrapper transitions so the class commit doesn't re-trigger them
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important');
const toHidden = !isHidden;
document.body.classList.toggle('sidebar-right-hidden', toHidden);
localStorage.setItem('sidebarRightHidden', toHidden);
void sidebar.offsetWidth;
sidebar.style.transform = '';
requestAnimationFrame(() => sidebar.style.removeProperty('transition'));
requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
const onEnd = ev => {
@@ -6655,9 +6811,11 @@ window.cancelAnimFrame = (function () {
setTimeout(settle, 350);
};
// Click on edgeZone: smoothly toggle sidebar open/closed
edgeZone.addEventListener('click', () => {
window.toggleSidebarRight();
// Click on edgeZone: only toggle when the click lands on the pill
edgeZone.addEventListener('click', (e) => {
if (isWithinPillArea(e.clientX, e.clientY)) {
window.toggleSidebarRight();
}
});
// </mouse-sidebar-drag>
@@ -6677,7 +6835,9 @@ window.cancelAnimFrame = (function () {
});
const isLSActive = () =>
document.body.classList.contains('layout-modern') && !!document.querySelector('.item-sidebar-left');
document.body.classList.contains('layout-modern') &&
!!document.querySelector('.item-sidebar-left') &&
window.innerWidth >= 1000;
// Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX.
// OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
@@ -6936,6 +7096,7 @@ window.cancelAnimFrame = (function () {
};
window.toggleSidebarLeft = () => {
if (window.innerWidth < 1000) return;
const { sb, layout } = getLS();
if (!sb || !layout) return;
const isHidden = layout.classList.contains('sidebar-hidden');
@@ -7430,29 +7591,48 @@ class NotificationSystem {
// Helper: apply a display name change to all relevant DOM nodes
this.applyDisplayNameUpdate = (display_name, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
// Capture old value before overwriting — needed for thumb matching below
const oldDisplayName = window.f0ckSession?.display_name || null;
if (window.f0ckSession) window.f0ckSession.display_name = display_name || null;
const oldDisplayName = isSelf ? (window.f0ckSession?.display_name || null) : null;
if (isSelf && window.f0ckSession) {
window.f0ckSession.display_name = display_name || null;
}
const userName = user || window.f0ckSession?.user;
// Update ALL nav-display-name instances (handles the profile page having a duplicate ID)
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
// preserve the badges (Admin, Mod)
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
let node = nameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
break;
// Update navbar display name only if this update is for the logged-in user
if (isSelf) {
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
let node = nameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
break;
}
}
});
}
// Update profile header display name if currently viewing this user's profile
const profileBox = document.getElementById('live-profile-preview-box');
if (profileBox && profileBox.dataset.bannerUser && userName && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
const profileNameSpan = document.getElementById('profile-display-name');
if (profileNameSpan) {
for (let i = profileNameSpan.childNodes.length - 1; i >= 0; i--) {
let node = profileNameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
break;
}
}
}
});
const bracket = document.getElementById('username-bracket');
if (bracket) {
const bracket = document.getElementById('username-bracket');
if (bracket) {
bracket.style.display = display_name ? 'inline' : 'none';
}
}
if (userName) {
@@ -7479,6 +7659,7 @@ class NotificationSystem {
// Helper: apply a username color change to relevant DOM nodes
this.applyUsernameColorUpdate = (username_color, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
const userName = user || window.f0ckSession?.user;
if (!userName) return;
@@ -7525,22 +7706,21 @@ class NotificationSystem {
}
});
// Special case for settings page preview block
if (window.f0ckSession && userName === window.f0ckSession.user) {
const previewBlock = document.getElementById('live-profile-preview-box');
if (previewBlock) {
// Profile header display name color (if currently viewing this user's profile)
const profileBox = document.getElementById('live-profile-preview-box');
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
const profileNameSpan = document.getElementById('profile-display-name');
if (profileNameSpan) {
if (username_color) {
previewBlock.style.setProperty('--author-accent', username_color);
previewBlock.style.setProperty('--author-border', username_color);
profileNameSpan.style.color = username_color;
} else {
previewBlock.style.setProperty('--author-accent', 'var(--accent)');
previewBlock.style.setProperty('--author-border', 'var(--accent)');
profileNameSpan.style.removeProperty('color');
}
}
}
// Handle the nav-display-name span elements (profile header and top navbar)
if (window.f0ckSession && userName === window.f0ckSession.user) {
// Navbar display name color (only for current user)
if (isSelf) {
document.querySelectorAll('#nav-display-name').forEach(el => {
if (username_color) {
el.style.color = username_color;
@@ -7553,14 +7733,21 @@ class NotificationSystem {
// Helper: apply an avatar update to relevant DOM nodes
this.applyAvatarUpdate = (avatar_file, user) => {
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
const userName = user || window.f0ckSession?.user;
if (!userName) return;
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
// Update Nav Avatar & profile header Avatar (only for current user)
if (window.f0ckSession && userName === window.f0ckSession.user) {
document.querySelectorAll('.nav-avatar-img, .profile_head_avatar img').forEach(img => img.src = newSrc);
// Update Nav Avatar only for current user
if (isSelf) {
document.querySelectorAll('.nav-avatar-img').forEach(img => img.src = newSrc);
}
// Update profile header avatar if currently viewing this user's profile
const profileBox = document.getElementById('live-profile-preview-box');
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
document.querySelectorAll('.profile_head_avatar img').forEach(img => img.src = newSrc);
}
// Update globally (comments, sidebar, polls, rank, infoboxes, favs)
@@ -7940,7 +8127,7 @@ class NotificationSystem {
}
}
if (typeof window.loadItemAjax === 'function' && href.match(/^\/\d+/)) {
if (typeof window.loadItemAjax === 'function' && (href.match(/^\/\d+/) || href.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || href.match(/\/(?:user|tag|h)\/.*?\/(?:\d+|[a-zA-Z0-9_-]{11})/))) {
window.loadItemAjax(href, false);
} else if (typeof window.loadPageAjax === 'function') {
window.loadPageAjax(href, true);
@@ -8226,7 +8413,8 @@ class NotificationSystem {
}
renderHistoryItem(n) {
let link = `/${n.item_id}`;
const itemKey = n.item_slug || n.slug || n.item_id;
let link = `/${itemKey}`;
let msg = '';
let user = n.from_display_name || n.from_user || 'System';
@@ -8235,14 +8423,14 @@ class NotificationSystem {
const isDeleted = n.type === 'item_deleted';
const label = isDeleted ? (i18n.notif_upload_deleted || 'A moderator deleted your upload') : (i18n.notif_upload_denied || 'Your Upload was denied');
const userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System');
const itemLink = `/${n.item_id}`;
const itemLink = `/${itemKey}`;
return `
<a href="${itemLink}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
<div class="notif-content">
<div class="notif-user"><strong>${userLabel}</strong></div>
<div class="notif-msg">
<strong>${label} #${n.item_id}</strong>
<strong>${label} #${itemKey}</strong>
<div class="notif-reason">${i18n.notif_reason_label || 'Reason:'} ${n.reason || (i18n.notif_no_reason || 'No reason provided')}</div>
</div>
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
@@ -8262,7 +8450,7 @@ class NotificationSystem {
if (n.data?.msg) msg += ` <br><span style="color: #ff6060; font-size: 0.9em;">${n.data.msg}</span>`;
if (n.data?.url) msg += ` <br><small style="opacity: 0.6; word-break: break-all;">${n.data.url}</small>`;
user = (window.f0ckI18n && window.f0ckI18n.notif_system) || 'System';
link = n.item_id ? `/${n.item_id}` : '#';
link = n.item_id ? `/${itemKey}` : '#';
} else if (n.type === 'admin_pending') {
link = '/mod/approve';
user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin';
@@ -8278,7 +8466,7 @@ class NotificationSystem {
if (n.reason) msg += `<br><div class="notif-reason" style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${n.reason}</div>`;
} else {
// Comment notification
link = `/${n.item_id}#c${n.comment_id || n.reference_id}`;
link = `/${itemKey}#c${n.comment_id || n.reference_id}`;
if (n.type === 'comment_reply') msg = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
else if (n.type === 'subscription') msg = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
else if (n.type === 'mention') msg = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
@@ -8319,8 +8507,9 @@ class NotificationSystem {
}
renderItem(n) {
const itemKey = n.item_slug || n.slug || n.item_id;
if (n.type === 'approve') {
const link = `/${n.item_id}`;
const link = `/${itemKey}`;
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}"></div>
@@ -8335,7 +8524,7 @@ class NotificationSystem {
}
if (n.type === 'upload_success') {
const link = `/${n.item_id}`;
const link = `/${itemKey}`;
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none';"></div>
@@ -8354,7 +8543,7 @@ class NotificationSystem {
const errMsg = n.data?.msg || '';
const errDisplay = errMsg ? `<div style="color: #ffbaba; font-size: 0.85em; margin-top: 2px;">${errMsg}</div>` : '';
const urlDisplay = url ? `<div style="font-size: 0.8em; opacity: 0.7; margin-top: 4px; word-break: break-all; max-height: 3.2em; overflow: hidden;">${url}</div>` : '';
const link = n.item_id ? `/${n.item_id}` : '#';
const link = n.item_id ? `/${itemKey}` : '#';
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
<div class="notif-content">
@@ -8378,7 +8567,7 @@ class NotificationSystem {
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
<div class="notif-content">
<div>
<strong>${label} #${n.item_id}</strong>
<strong>${label} #${itemKey}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${(window.f0ckI18n && window.f0ckI18n.notif_click_reason) || 'Click to see reason'}</div>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
@@ -8437,10 +8626,10 @@ class NotificationSystem {
if (n.type === 'comment_reply') typeText = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
else if (n.type === 'subscription') typeText = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
else if (n.type === 'mention') typeText = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${n.item_id}`;
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${itemKey}`;
const cid = n.comment_id || n.reference_id;
const link = `/${n.item_id}#c${cid}`;
const link = `/${itemKey}#c${cid}`;
const thumb = n.item_id ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none'"></div>` : '';
return `
@@ -8605,23 +8794,40 @@ class NotificationSystem {
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
const fragment = document.createDocumentFragment();
data.favs.forEach(fav => {
const avatarUrl = fav.avatar_file
? `/a/${fav.avatar_file}`
: (fav.avatar ? `/t/${fav.avatar}.webp` : '/a/default.png');
const isSelf = window.f0ckSession && window.f0ckSession.user && (window.f0ckSession.user === fav.user);
if (fav.hide_fav_badge && !isSelf) {
const a = document.createElement('a');
a.className = 'ghost-fav';
a.setAttribute('tooltip', '?');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
const a = document.createElement('a');
a.href = `/user/${fav.user.toLowerCase()}`;
a.setAttribute('tooltip', fav.display_name || fav.user);
a.setAttribute('flow', 'up');
const img = document.createElement('img');
img.src = '/s/img/ghost_fav.svg';
img.style.height = '32px';
img.style.width = '32px';
const img = document.createElement('img');
img.src = avatarUrl;
img.style.height = '32px';
img.style.width = '32px';
if (fav.username_color) img.style.borderColor = fav.username_color;
a.appendChild(img);
fragment.appendChild(a);
} else {
const avatarUrl = fav.avatar_file
? `/a/${fav.avatar_file}`
: (fav.avatar ? `/t/${fav.avatar}.webp` : '/a/default.png');
a.appendChild(img);
fragment.appendChild(a);
const a = document.createElement('a');
a.href = `/user/${fav.user.toLowerCase()}`;
a.setAttribute('tooltip', fav.display_name || fav.user);
a.setAttribute('flow', 'up');
const img = document.createElement('img');
img.src = avatarUrl;
img.style.height = '32px';
img.style.width = '32px';
if (fav.username_color) img.style.borderColor = fav.username_color;
a.appendChild(img);
fragment.appendChild(a);
}
});
favsContainer.innerHTML = '';
@@ -8705,12 +8911,14 @@ class NotificationSystem {
if (!isOwnUpload) return; // Silently drop as before for other users' uploads
const itemKey = data.slug || data.id;
// Don't add duplicates
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
// Determine the link prefix from existing items
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
// Build filter-reason label
const i18n = window.f0ckI18n || {};
@@ -8725,7 +8933,7 @@ class NotificationSystem {
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
const ghost = document.createElement('a');
ghost.href = `${linkBase}${data.id}`;
ghost.href = `${linkBase}${itemKey}`;
ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded';
ghost.dataset.file = data.dest;
ghost.dataset.mime = data.mime;
@@ -8756,19 +8964,21 @@ class NotificationSystem {
}
}
const itemKey = data.slug || data.id;
// Don't add duplicates
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
// Determine the link prefix from existing items
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
// Respect mode filter
const nsflId = window.f0ckSession?.nsfl_tag_id;
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
const thumb = document.createElement('a');
thumb.href = `${linkBase}${data.id}`;
thumb.href = `${linkBase}${itemKey}`;
thumb.className = 'thumb lazy-thumb';
thumb.dataset.file = data.dest;
thumb.dataset.mime = data.mime;
@@ -8950,10 +9160,11 @@ class NotificationSystem {
</div>`;
}
const itemKey = c.item_slug || c.slug || c.item_id;
itemPreview = `
<div class="item-preview" style="margin-top: 10px; display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.2); padding: 5px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05);">
<a href="/${c.item_id}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context &raquo;</a>
<a href="/${itemKey}">${mediaHtml}</a>
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context &raquo;</a>
</div>`;
}
@@ -11123,6 +11334,83 @@ document.addEventListener('click', (e) => {
return;
}
const visCancelBtn = e.target.closest('#visibility-modal-cancel');
if (visCancelBtn || e.target.id === 'visibility-modal') {
const modal = document.getElementById('visibility-modal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
return;
}
const visSaveBtn = e.target.closest('#visibility-modal-save');
if (visSaveBtn) {
e.preventDefault();
const modal = document.getElementById('visibility-modal');
const inputId = document.getElementById('visibility-item-id');
const selectedRadio = modal ? modal.querySelector('input[name="visibility"]:checked') : null;
if (!inputId || !selectedRadio) return;
const id = inputId.value;
const nextVis = parseInt(selectedRadio.value, 10);
visSaveBtn.disabled = true;
const origText = visSaveBtn.textContent;
visSaveBtn.textContent = 'Saving...';
fetch('/api/v2/item/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams({ postid: id, id: id, visibility: nextVis })
})
.then(r => r.json())
.then(data => {
visSaveBtn.disabled = false;
visSaveBtn.textContent = origText;
if (data.success) {
const visVal = parseInt(data.visibility, 10);
const titles = ['Public', 'Unlisted', 'Private'];
const icons = ['fa-globe', 'fa-link', 'fa-lock'];
const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)'];
const visBtn = document.getElementById('a_visibility');
if (visBtn) {
visBtn.dataset.visibility = visVal;
visBtn.setAttribute('title', `Visibility: ${titles[visVal]} (Click to change)`);
}
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
if (infoVisBtn) {
infoVisBtn.dataset.visibility = visVal;
}
const infoVisLabel = document.getElementById('info-visibility-label');
if (infoVisLabel) {
infoVisLabel.innerHTML = `<i class="fa-solid ${icons[visVal]}" style="color: ${colors[visVal]};"></i> ${titles[visVal]}`;
}
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
if (window.flashMessage) window.flashMessage(`VISIBILITY SET TO ${titles[visVal].toUpperCase()}`);
} else {
if (window.flashError) window.flashError(data.msg || 'Failed to update visibility');
}
})
.catch(err => {
visSaveBtn.disabled = false;
visSaveBtn.textContent = origText;
console.error('Error changing visibility:', err);
if (window.flashError) window.flashError('Network error');
});
return;
}
// Title save button
const saveBtn = e.target.closest('#info-title-save');
if (saveBtn) {

View File

@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
dmGridArea = document.createElement('div');
dmGridArea.className = 'emoji-picker-grid';
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastDmHoldTime = 0;
const addDmTabTouch = (btn, cb, onHold) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastDmHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleDmFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// ── Favorites tab ──
const favsTab = document.createElement('button');
favsTab.className = 'ep-tab ep-tab-favs';
@@ -1985,22 +2052,13 @@ if (window.__dmLoaded) {
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
favsTab.addEventListener('mousedown', e => e.preventDefault());
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
// Right-click on fav tab → set/unset as default
favsTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favsTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favsTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
if (Date.now() - lastDmHoldTime < 1000) return;
toggleDmFavDefault(favsTab);
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
}
tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
dmTabBar.appendChild(tab);
});

View File

@@ -1000,9 +1000,10 @@
e.stopPropagation();
const itemId = slide.dataset.id;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
const data = await resp.json();
if (data.success) pill.remove();
@@ -1157,10 +1158,14 @@
if (nowFaved) flashFav(slide);
}
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/togglefav', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `postid=${id}`
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
});
const data = await resp.json();
// Sync count to server truth (handles race conditions)
@@ -1582,9 +1587,10 @@
e.stopPropagation();
const itemId = slide.dataset.id;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
const data = await resp.json();
if (data.success) pill.remove();
@@ -1850,11 +1856,12 @@
else if (item.external_board === 'gif') rating = 'nsfw';
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/scroller/rehost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-csrf-token': window.scrollerCsrf || ''
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: new URLSearchParams({
url: item.external_media_url || item.dest,
@@ -1966,9 +1973,10 @@
showShareToast(info.toast);
}
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
})
.then(r => r.json())
.then(data => {
@@ -2119,7 +2127,12 @@
const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) return;
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
if (raw.includes('&gt;') || raw.includes('&lt;') || raw.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = raw;
raw = txt.value;
}
const lines = raw.split('\n');
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
@@ -2787,11 +2800,16 @@
if (!content || !commentsItemId || commentsPosting) return;
commentsPosting = true; commentSendBtn.disabled = true;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
const resp = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: postBody
});
const data = await resp.json();
@@ -2899,10 +2917,14 @@
if (addTagSendBtn) addTagSendBtn.disabled = true;
closeSugg();
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${targetId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `tagname=${encodeURIComponent(tag)}`
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
});
const data = await resp.json();
if (data.success) {
@@ -3658,7 +3680,11 @@
if (sMarkAll) {
sMarkAll.addEventListener('click', async () => {
try {
await fetch('/api/notifications/read', { method: 'POST' });
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read', {
method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs);
@@ -3673,7 +3699,12 @@
if (!item) return;
const nid = item.dataset.id;
if (nid && item.classList.contains('unread')) {
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/notifications/${nid}/read`, {
method: 'POST',
keepalive: true,
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}).catch(() => {});
item.classList.remove('unread');
// Update cache
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));

View File

@@ -1119,6 +1119,97 @@
});
}
const favsPrivateToggle = document.getElementById('favorites_private_toggle');
if (favsPrivateToggle) {
favsPrivateToggle.addEventListener('change', async () => {
const favorites_private = favsPrivateToggle.checked;
try {
const res = await fetch('/api/v2/settings/favorites_private', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ favorites_private })
});
const data = await res.json();
if (data.success) {
showStatus('Favorites privacy preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.favorites_private = favorites_private;
}
} else {
alert(data.msg || 'Error saving preference');
favsPrivateToggle.checked = !favorites_private; // Revert
}
} catch (err) {
console.error('Update Favorites Privacy error:', err);
alert('Connection error');
favsPrivateToggle.checked = !favorites_private; // Revert
}
});
}
const hideFavBadgeToggle = document.getElementById('hide_fav_badge_toggle');
if (hideFavBadgeToggle) {
hideFavBadgeToggle.addEventListener('change', async function() {
const hide_fav_badge = hideFavBadgeToggle.checked;
try {
const res = await fetch('/api/v2/settings/hide_fav_badge', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ hide_fav_badge })
});
const data = await res.json();
if (data.success) {
showStatus('Hide favorite badge preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.hide_fav_badge = hide_fav_badge;
}
} else {
alert(data.msg || 'Error saving preference');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
} catch (err) {
console.error('Update Hide Fav Badge error:', err);
alert('Connection error');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
});
}
const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select');
if (defaultUploadVisSelect) {
defaultUploadVisSelect.addEventListener('change', async function() {
const vis = parseInt(defaultUploadVisSelect.value, 10);
try {
const res = await fetch('/api/v2/settings/default_upload_visibility', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ default_upload_visibility: vis })
});
const data = await res.json();
if (data.success) {
showStatus('Default upload visibility updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.default_upload_visibility = vis;
}
} else {
alert(data.msg || 'Error saving preference');
}
} catch (err) {
console.error('Update Default Upload Visibility error:', err);
alert('Connection error');
}
});
}
// New Dual Column Layout Toggle
const layoutToggle = document.getElementById('use_new_layout_toggle');
if (layoutToggle) {

View File

@@ -47,7 +47,7 @@
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};
const playSidebarEmojiVideos = (container) => {
@@ -153,12 +153,6 @@
const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
// Truncate extremely long comments before any processing to keep the sidebar
// fast and the DOM lean, regardless of markdown / regex complexity.
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
}
if (typeof marked === 'undefined') {
return escapeHtml(content)
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
@@ -268,9 +262,10 @@
// Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: quoteContent;
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
}
@@ -484,8 +479,9 @@
};
const renderActivityItem = (c) => {
const itemKey = c.item_slug || c.slug || c.item_id;
const rawContent = c.content || c.body || '';
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
@@ -502,7 +498,7 @@
}
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
// Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png';
@@ -547,8 +543,8 @@
itemPreview = `
<div class="item-preview">
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
</div>`;
}

View File

@@ -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: '', tags: [], comment: '', title: '', is_oc: false });
selectedFiles.push({ type: 'url', url, rating: '', visibility: '', 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: '', tags: [], comment: '', title: '', is_oc: false });
selectedFiles.push({ type: 'url', url: val, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
}
urlInput.value = '';
if (urlBadge) urlBadge.style.display = 'none';
@@ -1177,7 +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: '', tags: [], comment: '', title: '', is_oc: false });
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
} else {
selectedFiles.push(file); // Legacy single file mode uses raw File
}
@@ -1490,6 +1490,7 @@ window.initUploadForm = (selector) => {
infoRow.className = 'file-meta-row-small';
let ratingSwitch = '';
let visibilitySwitch = '';
let tagsUI = '';
let ocUI = '';
let commentUI = '';
@@ -1517,6 +1518,31 @@ 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 tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
tagsUI = `
@@ -1560,19 +1586,27 @@ window.initUploadForm = (selector) => {
</div>
${titleUI}
${ratingSwitch}
${visibilitySwitch}
${tagsUI}
${commentUI}
`;
if (isShitpost) {
// Handle Rating
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
radio.onchange = () => {
item.rating = radio.value;
updateSubmitButton();
};
});
// Handle Visibility
infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => {
radio.onchange = () => {
item.visibility = radio.value;
};
});
// Handle Comment
const commentInput = infoRow.querySelector('.item-comment-input');
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
@@ -2436,6 +2470,8 @@ window.initUploadForm = (selector) => {
}
try {
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const visibilityVal = globalVisEl ? globalVisEl.value : '0';
const resp = await fetch('/api/v2/upload-url', {
method: 'POST',
headers: {
@@ -2445,7 +2481,8 @@ window.initUploadForm = (selector) => {
},
body: JSON.stringify({
url,
rating: globalRatingEl.value,
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
visibility: visibilityVal,
tags: tags.join(','),
comment: comment,
is_oc: isOc,
@@ -2552,9 +2589,11 @@ window.initUploadForm = (selector) => {
for (let i = 0; i < selectedFiles.length; i++) {
const item = selectedFiles[i];
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
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 fileTags = isShitpost ? item.tags : tags;
const fileComment = isShitpost ? item.comment : comment;
const fileTitle = isShitpost ? (item.title || '') : titleVal;
@@ -2571,6 +2610,7 @@ window.initUploadForm = (selector) => {
formData.append('file', file);
}
formData.append('rating', fileRating);
formData.append('visibility', fileVisibility);
formData.append('tags', fileTags.join(','));
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
if (isShitpost) formData.append('is_shitpost', 'true');
@@ -2622,6 +2662,7 @@ window.initUploadForm = (selector) => {
xhr.send(JSON.stringify({
url: item.url,
rating: fileRating,
visibility: fileVisibility,
tags: fileTags.join(','),
is_oc: (isShitpost ? item.is_oc : isOc),
comment: fileComment,
@@ -2738,17 +2779,14 @@ 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) {
// 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.
const targetUrl = (lastData && (lastData.visibility > 0 || lastData.redirect || lastData.slug))
? (lastData.redirect || `/${lastData.slug || lastData.itemid}`)
: '/';
if (typeof window.loadPageAjax === 'function') {
await window.loadPageAjax('/', true, { bypassCache: true });
injectNewItem();
await window.loadPageAjax(targetUrl, true, { bypassCache: true });
if (targetUrl === '/') injectNewItem();
} else {
window.location.href = '/';
window.location.href = targetUrl;
}
}
} else {

View File

@@ -1,14 +1,20 @@
(async () => {
// Helper to get dynamic context from the DOM
const getContext = () => {
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
return {
postid: +idLink.innerText,
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
poster: document.querySelector("a#a_username")?.innerText,
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
};
};

View File

@@ -306,7 +306,12 @@ if (!window.UserCommentSystem) {
const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
return `<span class="greentext">&gt;${quoteContent}</span>`;
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
}
// Per-line limit
@@ -405,11 +410,12 @@ if (!window.UserCommentSystem) {
}
renderComment(c) {
const itemKey = c.item_slug || c.slug || c.item_id;
const timeAgo = this.timeAgo(c.created_at);
const fullDate = new Date(c.created_at).toISOString();
const content = this.renderCommentContent(c.content, c.item_id);
const content = this.renderCommentContent(c.content, itemKey);
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
}
startLiveTimestamps() {
@@ -450,7 +456,7 @@ if (!window.UserCommentSystem) {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
}
}

View File

@@ -38,6 +38,16 @@ export default new class {
.replace(/'/g, "&#039;");
}
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];
};
@@ -460,4 +470,15 @@ export default new class {
console.error(`[ERROR REF ${errId}] ${context}:`, err);
return `Internal Error. Reference: ${errId}`;
}
isOnionRequest(req) {
if (!req || !req.headers) return false;
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
if (!rawHost) return false;
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
const firstHost = hostStr.split(',')[0].trim();
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
};

View File

@@ -165,6 +165,12 @@
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -535,6 +541,7 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:",
@@ -794,6 +801,12 @@
"delete_confirm": "Diesen Einladungstoken löschen?",
"slot_refreshes_on": "Slot erneuert sich am {date}",
"slot_refreshed": "Slot erneuert",
"admin_desc": "Du bist Admin, leg los."
"admin_desc": "Du bist Admin, leg los.",
"visibility": {
"public": "Öffentlich",
"unlisted": "Nicht gelistet",
"private": "Privat",
"change_visibility": "Sichtbarkeit ändern"
}
}
}

View File

@@ -165,6 +165,12 @@
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
"disable_swiping": "Disable Swiping",
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
"favorites_private": "Private Favorites",
"favorites_private_hint": "Only you and administrators can view your favorites list.",
"hide_fav_badge": "Hide Favorite Badge Avatar",
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
"default_upload_visibility": "Default Upload Visibility",
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
"image_expand_on_click": "Expand images inline on click",
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
"enable_bg_blur": "Enable Background blur",
@@ -539,6 +545,7 @@
"subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found",
"no_favs": "no favorites",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User",
"ban_modal_reason": "Reason:",
@@ -796,6 +803,12 @@
"delete_confirm": "Delete this invite token?",
"slot_refreshes_on": "slot refreshes on {date}",
"slot_refreshed": "slot refreshed",
"admin_desc": "You are an admin, go ahead."
"admin_desc": "You are an admin, go ahead.",
"visibility": {
"public": "Public",
"unlisted": "Unlisted",
"private": "Private",
"change_visibility": "Change Visibility"
}
}
}

View File

@@ -165,6 +165,10 @@
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
"disable_swiping": "Swipen uitschakelen",
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
"favorites_private": "Privé favorieten",
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
"hide_fav_badge": "Favoriet-badge avatar verbergen",
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
"enable_bg_blur": "Achtergrondvervaging inschakelen",
@@ -535,6 +539,7 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen",
"ban_modal_reason": "Reden:",

View File

@@ -165,6 +165,10 @@
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -536,6 +540,7 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:",

View File

@@ -136,8 +136,60 @@ const xdScoreMeta = (score) => {
return { tier: 5, label: 'xDDDDD+' };
};
async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) {
if (!rawUser) return { isPrivate: false, isAllowed: true };
const decodedUser = decodeURI(rawUser);
const targetUserRows = await db`
select u.id, u."user", u.admin, uo.favorites_private
from "user" u
left join user_options uo on uo.user_id = u.id
where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser}
limit 1
`;
if (!targetUserRows.length || !targetUserRows[0].favorites_private) {
return { isPrivate: false, isAllowed: true };
}
const targetUser = targetUserRows[0];
let reqUserId = user_id;
let reqIsAdmin = is_admin;
if (typeof session === 'object' && session !== null) {
if (reqUserId === undefined) reqUserId = session.id;
if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin;
}
let isAllowed = false;
if (reqUserId) {
if (+reqUserId === +targetUser.id) {
isAllowed = true;
} else if (reqIsAdmin === true) {
isAllowed = true;
} else if (reqIsAdmin === undefined) {
const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`;
if (reqUserRows.length > 0 && reqUserRows[0].admin) {
isAllowed = true;
}
}
}
return { isPrivate: true, isAllowed };
}
export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -250,6 +302,9 @@ 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);
@@ -261,6 +316,7 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -303,6 +359,7 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -333,6 +390,8 @@ export default {
const rows = (await db`
select
items.id,
items.slug,
items.visibility,
items.mime,
items.dest,
items.username as username,
@@ -428,7 +487,18 @@ export default {
view_mode: fav ? 'favs' : 'uploads'
};
},
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -458,7 +528,17 @@ export default {
if (uhData.length) userHallObj = uhData[0];
}
const mime = (rawMime ?? "");
const itemid = rawItemid ? +rawItemid : null;
const rawIdOrSlug = rawItemid ?? null;
if (rawIdOrSlug === null || rawIdOrSlug === undefined || rawIdOrSlug === '') {
return {
success: false,
message: "404 - upload not found"
};
}
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
@@ -472,19 +552,12 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const effMode = Number(mode ?? 0);
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
const modequery = multiRatingSQL ?? lib.getMode(effMode);
if (itemid === null) {
return {
success: false,
message: "404 - upload not found"
};
}
let tagFilter = db``;
let titleFilter = db``;
if (isTitleSearch && titleQuery) {
@@ -525,6 +598,7 @@ export default {
return db`
${db.unsafe(modequery)}
and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter}
${titleFilter}
${hallFilter}
@@ -538,11 +612,9 @@ export default {
};
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
// 1. Fetch the main item
// We only apply the active check and global NSFW filter (for guests) here.
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
const items = await db`
select distinct on (items.id)
items.*,
@@ -566,7 +638,7 @@ export default {
left join "user_options" uo on uo.user_id = author_u.id
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where
items.id = ${itemid} and
${itemLookup} and
items.active = true
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
limit 1
@@ -574,7 +646,41 @@ export default {
const actitem = items[0];
if (actitem && user_id) {
if (!actitem) {
return {
success: false,
message: "404 - upload not found"
};
}
const itemid = actitem.id;
// Check visibility permissions:
const isOwnerOrAdmin = session && (
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
session.admin || session.is_moderator
);
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
// Block numeric enumeration unless viewer is owner/admin
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
};
}
// If item is Private (visibility === 2):
// Direct link only allowed for owner/admin
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
return {
success: false,
is_private: true,
message: "403 - private upload"
};
}
if (user_id) {
db`
insert into user_video_views (user_id, video_id, view_count, last_viewed)
values (${user_id}, ${itemid}, 1, now())
@@ -583,34 +689,26 @@ export default {
last_viewed = now()
`.catch(e => console.error('Failed to track view:', e));
}
if (!actitem) {
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
if (!session && getGlobalfilter()) {
// Guest global filter check if item was filtered out
if (!session && getGlobalfilter() && !actitem) {
const unfilteredItem = await db`
select id from items where id = ${itemid} and active = true limit 1
select id from items where ${itemLookup} and active = true limit 1
`;
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,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`,
id: unfilteredItem[0].id,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${unfilteredItem[0].id}_blur.webp`,
og_url: hallSlug
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
: `https://${cfg.main.url.domain}/${itemid}`,
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${unfilteredItem[0].id}`
: `https://${cfg.main.url.domain}/${unfilteredItem[0].id}`,
og_description: `Content not visible in current mode`
}
};
}
}
return {
success: false,
message: "Sorry, this post is currently not visible."
};
}
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
@@ -622,7 +720,7 @@ export default {
const baseQuery = (whereClause, orderBy, limit = 1) => {
return db`
select items.id
select items.id, items.slug
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
@@ -633,7 +731,7 @@ export default {
where
${buildConditions()}
${whereClause}
group by items.id
group by items.id, items.slug
${orderBy}
limit ${limit}
`;
@@ -648,7 +746,7 @@ export default {
const checkFilter = !session && nsfpIds.length > 0;
const query = db`
SELECT ta.item_id as id
SELECT ta.item_id as id, items.slug
FROM tags_assign ta
INNER JOIN items ON items.id = ta.item_id
${checkFilter
@@ -657,6 +755,7 @@ export default {
}
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
AND items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -705,7 +804,7 @@ export default {
link.suffix = '';
}
const favorites = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
@@ -718,24 +817,24 @@ export default {
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
const baseChecksum = actitem.checksum.split('_bypass_')[0];
const repostRows = await db`
SELECT id, username, stamp FROM items
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
} else if (actitem.checksum) {
// Even without bypass, check if other bypass-entries exist with this same hash
const baseChecksum = actitem.checksum;
const repostRows = await db`
SELECT id, username, stamp FROM items
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND checksum LIKE ${baseChecksum + '_bypass_%'}
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
}
// Also find visually-similar items via phash, merging with checksum results
@@ -745,7 +844,7 @@ export default {
const existingIds = new Set(repostItems.map(r => r.id));
for (const pm of phashMatches) {
if (!existingIds.has(pm.id)) {
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
existingIds.add(pm.id);
}
}
@@ -780,7 +879,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) {
if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
@@ -808,6 +907,8 @@ export default {
},
item: {
id: actitem.id,
slug: actitem.slug || null,
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
username: actitem.username,
author_id: actitem.author_id,
author_color: actitem.author_color,
@@ -870,13 +971,13 @@ export default {
height: actitem.height || null,
original_filename: actitem.original_filename || null
},
title: `${actitem.id} - ${cfg.websrv.domain}`,
title: `${(cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: {
end: endItem[0]?.id || itemid,
start: startItem[0]?.id || itemid,
next: nextItem[0]?.id || null,
prev: prevItem[0]?.id || null,
page: actitem.id,
end: (cfg.enable_item_slugs !== false && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
start: (cfg.enable_item_slugs !== false && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
next: (cfg.enable_item_slugs !== false && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
prev: (cfg.enable_item_slugs !== false && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
page: (cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id,
cheat: cheat
},
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
@@ -884,7 +985,14 @@ export default {
tmp
};
return data;
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
},
getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return null;
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const hall = rawHall || null;
@@ -938,6 +1046,7 @@ export default {
WHERE
${db.unsafe(modequery)}
AND items.active = true
AND coalesce(items.visibility, 0) = 0
AND items.title ILIKE ${'%' + titleQuery + '%'}
AND items.title IS NOT NULL
${mimeSQL}
@@ -960,6 +1069,7 @@ export default {
${db.unsafe(modequery)}
and "user".user ilike ${user}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
group by items.id
@@ -999,6 +1109,7 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter}
${user ? db`and items.username ilike ${user}` : db``}
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
@@ -1021,6 +1132,7 @@ export default {
${db.unsafe(modequery)}
and h.slug = ${hall}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -1037,6 +1149,7 @@ export default {
${db.unsafe(modequery)}
and uha.hall_id = ${userHallId}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random()
@@ -1063,6 +1176,7 @@ export default {
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
WHERE items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}

View File

@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -661,6 +661,7 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
cleanup_start_date: getCleanupStartDate(),
cleanup_end_date: getCleanupEndDate(),
cleanup_include_engaged: getCleanupIncludeEngaged(),
totals: await lib.countf0cks(),
tmp: null
};
@@ -674,15 +675,18 @@ export default (router, tpl) => {
try {
const cleanup_start_date = req.post.cleanup_start_date || '';
const cleanup_end_date = req.post.cleanup_end_date || '';
const cleanup_include_engaged = req.post.cleanup_include_engaged === 'true' || req.post.cleanup_include_engaged === 'on' || req.post.cleanup_include_engaged === '1';
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_include_engaged', ${cleanup_include_engaged ? 'true' : 'false'}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
setCleanupStartDate(cleanup_start_date);
setCleanupEndDate(cleanup_end_date);
setCleanupIncludeEngaged(cleanup_include_engaged);
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() });
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), cleanup_include_engaged: getCleanupIncludeEngaged() });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -701,17 +705,23 @@ export default (router, tpl) => {
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
try {
// Ensure settings are synced from DB before execution
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`;
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date', 'cleanup_include_engaged')`;
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
const isEnabled = settingsMap['enable_cleanup'] === 'true';
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
const startDate = settingsMap['cleanup_start_date'] || '';
const endDate = settingsMap['cleanup_end_date'] || '';
const reqEngaged = req.post?.include_engaged;
const includeEngaged = reqEngaged !== undefined
? (reqEngaged === 'true' || reqEngaged === true || reqEngaged === 'on' || reqEngaged === '1')
: (settingsMap['cleanup_include_engaged'] === 'true' || getCleanupIncludeEngaged());
// Update memory state
setEnableCleanup(isEnabled);
setCleanupStartDate(startDate);
setCleanupEndDate(endDate);
setCleanupIncludeEngaged(includeEngaged);
if (!isEnabled) {
throw new Error('Cleanup is disabled in settings.');
@@ -721,7 +731,7 @@ export default (router, tpl) => {
throw new Error('Please select both a Start Date and an End Date.');
}
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`);
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate} (includeEngaged: ${includeEngaged})...`);
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
@@ -737,7 +747,7 @@ export default (router, tpl) => {
AND i.is_pinned = false
AND i.stamp >= ${start_stamp}
AND i.stamp <= ${end_stamp}
AND (
${includeEngaged ? db`` : db`AND (
-- Case 1: Active posts with no engagement (ignoring automatic subscriptions)
(i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id))
OR
@@ -746,7 +756,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})`;
@@ -1428,8 +1438,8 @@ export default (router, tpl) => {
const userId = newUser[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {

View File

@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
export default (router, tpl) => {
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
router.get(/\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
if (typeof req.url === 'string') {
@@ -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\/(\d+)/)?.[1];
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: !!req.session,
session: req.session,
url: contextUrl,
user: query.user,
tag: query.tag,
@@ -170,7 +170,8 @@ export default (router, tpl) => {
html: itemHtml,
pagination: paginationHtml,
title: data.title,
id: itemid
id: itemid,
slug: data.item?.slug || null
})
});
});
@@ -218,9 +219,10 @@ export default (router, tpl) => {
mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: !!req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
fav: query.fav === 'true',
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
@@ -231,6 +233,19 @@ export default (router, tpl) => {
});
if (!data.success) {
if (data.is_private) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
is_private: true,
html: privateHtml,
hasMore: false
})
});
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -594,10 +594,12 @@ export default router => {
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
exclude: req.session?.excluded_tags || []
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
});
if (!data.itemid) {
if (!data || !data.itemid) {
return res.json({
success: false,
items: []
@@ -636,6 +638,7 @@ export default router => {
items: {
...safeItem,
id: item.id,
slug: item.slug || null,
dest: relativeDest,
url: directUrl,
direct_url: directUrl
@@ -1038,7 +1041,24 @@ export default router => {
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
const postid = +req.post.postid;
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
if (rawPostid === undefined || rawPostid === null) {
return res.json({ success: false, msg: 'Missing postid' }, 400);
}
// Support both numeric item ID and string slug
const isNumeric = /^\d+$/.test(String(rawPostid));
const itemRow = await db`
SELECT id FROM items
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!itemRow.length) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const postid = itemRow[0].id;
// Check if already faved by this user — compare as numbers to avoid type mismatch
const existing = await db`
@@ -1066,7 +1086,7 @@ export default router => {
}
const favs = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
@@ -1168,6 +1188,47 @@ 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(/\/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);

View File

@@ -315,6 +315,68 @@ export default router => {
}
});
// Update Favorites Privacy preference
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
try {
await db`
update user_options
set favorites_private = ${favorites_private}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.favorites_private = favorites_private;
return res.json({ success: true, favorites_private }, 200);
} catch (e) {
console.error('Update Favorites Privacy pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Hide Fav Badge preference
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
try {
await db`
update user_options
set hide_fav_badge = ${hide_fav_badge}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
return res.json({ success: true, hide_fav_badge }, 200);
} catch (e) {
console.error('Update Hide Fav Badge pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
const vis = parseInt(req.post.default_upload_visibility, 10);
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
}
try {
await db`
update user_options
set default_upload_visibility = ${vis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.default_upload_visibility = vis;
return res.json({ success: true, default_upload_visibility: vis }, 200);
} catch (e) {
console.error('Update Default Upload Visibility pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post;

View File

@@ -1,212 +0,0 @@
/**
* f0ckm API v2 — JSON endpoints for the new SvelteKit frontend.
*
* These routes live alongside the existing flummpress routes and are
* purely additive — nothing in the old app is changed or removed.
*
* Mounted at: /api/v2/
*/
import f0cklib from '../../routeinc/f0cklib.mjs';
import cfg from '../../config.mjs';
const json = (res, body, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
export default (router, _tpl) => {
// ── GET /api/v2/session ───────────────────────────────────────────────────
router.get('/api/v2/session', async (req, res) => {
if (!req.session) {
return json(res, { loggedIn: false, user: null });
}
return json(res, {
loggedIn: true,
user: {
id: req.session.id,
user: req.session.user,
login: req.session.login,
admin: !!req.session.admin,
is_moderator: !!req.session.is_moderator,
mode: req.session.mode ?? 3,
theme: req.session.theme ?? 'f0ck',
avatar: req.session.avatar ?? null,
avatar_file: req.session.avatar_file ?? null,
username_color: req.session.username_color ?? null,
display_name: req.session.display_name ?? null,
use_new_layout: req.session.use_new_layout ?? true,
excluded_tags: req.session.excluded_tags ?? [],
min_xd_score: req.session.min_xd_score ?? 0,
uploads_remaining: req.session.uploads_remaining,
pending_count: req.session.pending_count,
language: req.session.language ?? null,
disable_autoplay: !!req.session.disable_autoplay,
disable_swiping: !!req.session.disable_swiping,
csrf_token: req.session.csrf_token ?? ''
}
});
});
// ── GET /api/v2/items ─────────────────────────────────────────────────────
router.get('/api/v2/items', async (req, res) => {
const q = req.url.qs ?? {};
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
const ratingsArr = ratingsRaw
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
: null;
const mode = q.mode !== undefined ? +q.mode : req.mode;
try {
const data = await f0cklib.getf0cks({
page: q.page ? +q.page : 1,
mode,
ratings: ratingsArr,
mime: q.mime ?? (req.cookies?.mime ?? null),
tag: q.tag ?? null,
hall: q.hall ?? null,
user: q.user ?? null,
userHall: q.userHall ?? null,
userHallOwner: q.userHallOwner ?? null,
fav: q.fav === 'true',
random: q.random === '1' || req.cookies?.random_mode === '1',
strict: q.strict === '1' || q.strict === 'true' || !!req.session?.strict_mode,
session: !!req.session,
user_id: req.session?.id ?? null,
exclude: req.session?.excluded_tags ?? [],
minXdScore: req.session?.min_xd_score ?? 0,
newer: q.newer ?? null,
tagger: q.tagger ?? null
});
if (!data.success) {
return json(res, { success: false, items: [], pagination: null, total: 0, link: null });
}
return json(res, {
success: true,
items: data.items,
pagination: data.pagination,
total: data.total,
link: data.link
});
} catch (e) {
console.error('[API v2] /items error:', e);
return json(res, { success: false, error: 'Internal error' }, 500);
}
});
// ── GET /api/v2/items/:id ─────────────────────────────────────────────────
router.get(/^\/api\/v2\/items\/(?<itemid>\d+)$/, async (req, res) => {
const itemid = req.params.itemid;
const q = req.url.qs ?? {};
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
const ratingsArr = ratingsRaw
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
: null;
const mode = q.mode !== undefined ? +q.mode : req.mode;
try {
const data = await f0cklib.getf0ck({
itemid,
mode,
ratings: ratingsArr,
session: !!req.session,
user_id: req.session?.id ?? null,
user: q.user ?? null,
tag: q.tag ?? null,
hall: q.hall ?? null,
userHall: q.userHall ?? null,
userHallOwner: q.userHallOwner ?? null,
mime: q.mime ?? (req.cookies?.mime ?? null),
fav: q.fav === 'true',
random: q.random === '1',
strict: q.strict === '1' || !!req.session?.strict_mode,
exclude: req.session?.excluded_tags ?? [],
lang: req.lang ?? 'en'
});
if (!data.success) {
return json(res, { success: false, item: null, message: data.message }, 404);
}
// Precompute hall display data (mirrors existing index route logic)
if (data.item?.halls?.length) {
const currentHallSlug = data.tmp?.hall
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
: null;
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
} else if (data.item) {
data.item.primaryHall = null;
data.item.otherHalls = [];
}
// Subscription status
const isSubscribed = req.session
? await f0cklib.getSubscriptionStatus(req.session.id, itemid).catch(() => false)
: false;
// xD Score
if (req.session || !cfg.main.hide_comments_from_public) {
const commentsForScore = await f0cklib.getComments(itemid, 'old', false);
const xdScore = f0cklib.computeXdScore(commentsForScore);
const xdMeta = f0cklib.xdScoreMeta(xdScore);
data.item.xd_score = xdScore;
data.item.xd_tier = xdMeta.tier;
data.item.xd_label = xdMeta.label;
} else {
data.item.xd_score = 0;
data.item.xd_tier = 0;
data.item.xd_label = '';
}
// Notify read
if (req.session?.id) {
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
}
return json(res, {
success: true,
item: data.item,
pagination: data.pagination,
link: data.link,
isSubscribed
});
} catch (e) {
console.error('[API v2] /items/:id error:', e);
return json(res, { success: false, error: 'Internal error' }, 500);
}
});
// ── GET /api/v2/random ────────────────────────────────────────────────────
router.get('/api/v2/random', async (req, res) => {
const ratingsRaw = req.cookies?.ratings ?? null;
const ratingsArr = ratingsRaw
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
: null;
try {
const data = await f0cklib.getRandom({
mode: req.mode,
ratings: ratingsArr,
session: !!req.session,
mime: req.cookies?.mime ?? null
});
if (!data.success) {
return json(res, { success: false, message: data.message }, 404);
}
const url = encodeURI(`${data.link.main}${data.link.path ?? ''}${data.itemid}${data.link.suffix ?? ''}`);
return json(res, { success: true, id: data.itemid, url });
} catch (e) {
console.error('[API v2] /random error:', e);
return json(res, { success: false, error: 'Internal error' }, 500);
}
});
return router;
};

View File

@@ -25,7 +25,8 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
if (!req.params.postid || !req.post.tagname) {
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
return res.json({
success: false,
msg: 'missing postid or tag'
@@ -33,7 +34,7 @@ export default router => {
}
const postid = +req.params.postid;
const tagname = req.post.tagname?.trim();
const tagname = rawTagname.trim();
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
if (protectedTags.includes(tagname.toLowerCase())) {

View File

@@ -137,6 +137,29 @@ 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;
};
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
@@ -369,6 +392,9 @@ 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 = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
@@ -383,8 +409,10 @@ export default router => {
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
title: title,
visibility: targetVisibility,
slug: itemSlug
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')}
RETURNING id
`;
@@ -437,6 +465,9 @@ export default router => {
});
} else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
const session = {
id: req.session.id,
user: req.session.user,
@@ -689,8 +720,10 @@ export default router => {
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
title: title,
visibility: targetVisibility,
slug: itemSlug
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')}
RETURNING id
`;
@@ -725,8 +758,8 @@ export default router => {
}
}
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
// Broadcast new_item event for live grid updates (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
@@ -742,8 +775,8 @@ export default router => {
}
}
// Push to Matrix Channel (only if auto-approved)
if (!isApprovalRequired) {
// Push to Matrix Channel (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
try {
const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');

View File

@@ -174,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
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false
@@ -570,6 +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,
(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[])
@@ -592,6 +593,7 @@ export default (router, tpl) => {
type: 'comment',
id: commentId,
item_id: item_id,
item_slug: itemQuery[0]?.slug || null,
parent_id: parent_id || null,
body: notifyBody,
username: req.session.user,
@@ -1003,6 +1005,7 @@ 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[])

View File

@@ -73,7 +73,7 @@ export default (router, tpl) => {
ratings: ratingsArr,
mime: mime,
fav: false,
session: !!req.session,
session: req.session,
user_id: req.session?.id,
random: isRandom
});
@@ -99,8 +99,9 @@ export default (router, tpl) => {
ratings: ratingsArr,
mime: mime,
fav: true,
session: !!req.session,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
random: isRandom
});
if (favs && 'items' in favs) {
@@ -236,8 +237,9 @@ export default (router, tpl) => {
fav: req.params.mode == 'favs',
mode: req.mode,
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
session: !!req.session,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
url: decodeURIComponent(req.url.pathname || req.url),
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
@@ -251,6 +253,20 @@ export default (router, tpl) => {
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) {
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
code: 403,
body: tpl.render('error', {
message: tErr('profile.private_favorites'),
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false,
error_filter_hint: null,
error_filter_hint_link: null
}, req)
});
}
// For index/grid views with zero items (empty DB), render an empty grid instead of error
if (mode !== 'item') {
data.items = [];
@@ -398,11 +414,11 @@ export default (router, tpl) => {
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids)
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
/* </routing-refactor> */
router.get(/^\/(about)$/, (req, res) => {

View File

@@ -114,7 +114,7 @@ export default (router, tpl) => {
}
const f0ck = await db`
select i.dest, i.mime, i.username, i.id, ta.tag_id
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
from "items" i
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
where i.id = ${id} and i.active = false
@@ -156,65 +156,69 @@ export default (router, tpl) => {
console.error('[MOD APPROVE] Failed to notify user:', err);
}
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
const isPublic = (f0ck[0].visibility || 0) === 0;
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
if (isPublic) {
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
}
}
}

View File

@@ -122,7 +122,7 @@ db.listen('activity', async (payload) => {
// We need the username, avatar, and item mime for the preview
// trigger only gave us user_id and item_id
const [details] = await db`
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, i.slug as item_slug,
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
FROM "user" u
LEFT JOIN user_options uo ON u.id = uo.user_id
@@ -138,6 +138,7 @@ db.listen('activity', async (payload) => {
data.username_color = details.username_color;
data.display_name = details.display_name || null;
data.tag_id = details.tag_id;
data.item_slug = details.item_slug;
} else {
data.username = 'System';
}
@@ -210,6 +211,7 @@ db.listen('motd', (payload) => {
db.listen('new_item', (payload) => {
try {
const data = JSON.parse(payload);
if (data.visibility && data.visibility !== 0) return;
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'new_item', data });
@@ -367,7 +369,7 @@ export default (router, tpl) => {
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const notifications = typeFilter
? await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
@@ -392,7 +394,7 @@ export default (router, tpl) => {
OFFSET ${offset}
`
: await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
@@ -443,7 +445,7 @@ export default (router, tpl) => {
try {
const notifications = await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,

View File

@@ -97,11 +97,11 @@ export default (router, tpl) => {
`;
const favotop = await db`
select favorites.item_id, count(*) favs
select favorites.item_id as id, items.slug, count(*) favs
from favorites
join items on items.id = favorites.item_id
where items.active = true
group by favorites.item_id
group by favorites.item_id, items.slug
having count(*) > 1
order by favs desc
limit 10
@@ -110,7 +110,7 @@ export default (router, tpl) => {
let xdtop = [];
if (config.websrv.enable_xd_score) {
const xdRows = await db`
select id, xd_score
select id, slug, xd_score
from items
where active = true and is_deleted = false and xd_score > 0
order by xd_score desc

View File

@@ -48,7 +48,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() }, req)
});
}
@@ -58,7 +58,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() }, req)
});
};
@@ -67,7 +67,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() }, req)
});
};
@@ -88,8 +88,9 @@ export default (router, tpl) => {
return renderError("Passwords do not match.");
}
// reCAPTCHA verification
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try {
@@ -167,8 +168,8 @@ export default (router, tpl) => {
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);

View File

@@ -268,6 +268,7 @@ export default (router, tpl) => {
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}
@@ -292,6 +293,7 @@ export default (router, tpl) => {
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}

Some files were not shown because too many files have changed in this diff Show More