Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7414e4b21 | |||
| 1262b15a5b | |||
| 2cd8bd91ff | |||
| 7b186dcdb2 | |||
| 973753d231 | |||
| c30b3535ae | |||
| eed8b993e1 | |||
| 9c239769e6 | |||
| 6e07194b27 | |||
| 866a8003fe | |||
| 5ab1a9614a | |||
| e45df6a769 | |||
| 99eb9735a1 | |||
| b651621cee | |||
| d3ce2e38f0 | |||
| 096786516e | |||
| ec895a285b | |||
| 20aaf183d8 | |||
| 25ab4ba949 | |||
| 6a52445987 | |||
| 63c39ebe90 | |||
| 0a21b6c8f8 | |||
| a7aabac956 | |||
| 9f5510bc04 | |||
| 54e2e8871b | |||
| c99f3e11d8 | |||
| d14feaf3eb | |||
| 5e35fd48d4 | |||
| a07c21f22b | |||
| 65fb96f675 | |||
| 20a6ffcb04 | |||
| 8518e87c8d | |||
| a3148848f2 | |||
| abedf7a789 | |||
| e61e24dfe8 | |||
| 44af6ae061 | |||
| 0739d72334 | |||
| 848055446a | |||
| 88e6b6efb9 | |||
| eea70ed62e | |||
| b6c906636c | |||
| 55b5a0ba9f | |||
| 5d60d80fc9 | |||
| 4dfb80b149 | |||
| b319f21cd4 | |||
| 28ed8d985c | |||
| 0f5e795793 | |||
| 78d14a1b56 | |||
| 3f149531da | |||
| 7a4432277d | |||
| 84eee7e13d | |||
| 2206b3eb86 | |||
| 5529724982 | |||
| 9c6070afff | |||
| 5bfa336773 | |||
| 517444f9ce | |||
| edb92ffa2a |
@@ -1,9 +1,9 @@
|
||||
POSTGRES_USER=f0ckm
|
||||
POSTGRES_DB=f0ckm
|
||||
POSTGRES_PASSWORD=f0ckm
|
||||
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
||||
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
||||
# COMPOSE_PROFILES=f0ckm-nginx
|
||||
# --- Nginx & Tor Profiles (Optional) ---
|
||||
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
|
||||
# COMPOSE_PROFILES=tor
|
||||
#
|
||||
# VIRTUAL_HOST=yourdomain.com
|
||||
# LETSENCRYPT_HOST=yourdomain.com
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ public/tag_cache
|
||||
config.json
|
||||
bundle.css
|
||||
public/s/fonts/impact.woff
|
||||
f0ck-svelte/*
|
||||
20
README.md
20
README.md
@@ -78,3 +78,23 @@ now visit http://localhost:1337 in your browser, you can develop without needing
|
||||
## NGINX
|
||||
|
||||
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
||||
|
||||
## Tor Hidden Service (.onion)
|
||||
|
||||
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
|
||||
|
||||
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
|
||||
|
||||
```bash
|
||||
cat ./f0ckm-data/tor/f0ckm_hs/hostname
|
||||
```
|
||||
|
||||
### Automatic Onion-Location Header
|
||||
|
||||
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
|
||||
|
||||
```json
|
||||
"main": {
|
||||
"onion": "http://yourgeneratedaddress.onion"
|
||||
}
|
||||
```
|
||||
|
||||
9
config/tor/torrc
Normal file
9
config/tor/torrc
Normal file
@@ -0,0 +1,9 @@
|
||||
# Tor configuration for f0ckm
|
||||
|
||||
# Enable SOCKS proxy for internal container & external connections
|
||||
SocksPort 0.0.0.0:9050
|
||||
|
||||
# Tor Hidden Service (v3 Onion Service) configuration
|
||||
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
|
||||
HiddenServiceDir /var/lib/tor/f0ckm_hs/
|
||||
HiddenServicePort 80 f0ckm:1337
|
||||
@@ -5,6 +5,7 @@
|
||||
"domain": "example.com",
|
||||
"regex": "example\\.com"
|
||||
},
|
||||
"onion": "http://your-onion-address.onion",
|
||||
"socks": "socks5://127.0.0.1:9050",
|
||||
"mail": "admin@example.com",
|
||||
"maxfilesize": 104857600,
|
||||
@@ -61,6 +62,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 +134,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",
|
||||
|
||||
@@ -49,9 +49,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 +104,8 @@ services:
|
||||
container_name: f0ckm-nginx
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
23
f0ck-svelte/.gitignore
vendored
23
f0ck-svelte/.gitignore
vendored
@@ -1,23 +0,0 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -1 +0,0 @@
|
||||
engine-strict=true
|
||||
3
f0ck-svelte/.vscode/extensions.json
vendored
3
f0ck-svelte/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.16.3 create --template minimal --types ts --install npm .
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
2486
f0ck-svelte/package-lock.json
generated
2486
f0ck-svelte/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "f0ck-svelte",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"daisyui": "^5.6.18",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes: f0ck --default, light;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* f0ck dark theme — AMOLED-inspired */
|
||||
--color-base-100: oklch(10% 0.02 265);
|
||||
--color-base-200: oklch(13% 0.02 265);
|
||||
--color-base-300: oklch(16% 0.02 265);
|
||||
--color-base-content: oklch(92% 0.01 265);
|
||||
|
||||
--color-primary: oklch(55% 0.22 265);
|
||||
--color-primary-content: oklch(98% 0.01 265);
|
||||
|
||||
--color-secondary: oklch(60% 0.18 300);
|
||||
--color-secondary-content: oklch(98% 0.01 265);
|
||||
|
||||
--color-accent: oklch(72% 0.15 290);
|
||||
--color-accent-content: oklch(10% 0.02 265);
|
||||
|
||||
--color-neutral: oklch(20% 0.02 265);
|
||||
--color-neutral-content: oklch(85% 0.01 265);
|
||||
|
||||
--color-info: oklch(70% 0.15 220);
|
||||
--color-success: oklch(70% 0.15 160);
|
||||
--color-warning: oklch(78% 0.18 80);
|
||||
--color-error: oklch(63% 0.22 25);
|
||||
|
||||
/* Rating colors */
|
||||
--color-sfw: oklch(70% 0.15 160);
|
||||
--color-nsfw: oklch(65% 0.20 25);
|
||||
--color-nsfl: oklch(60% 0.22 0);
|
||||
}
|
||||
|
||||
/* ── Global resets & base styles ───────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: oklch(30% 0.02 265); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: oklch(40% 0.02 265); }
|
||||
|
||||
/* ── Rating badge utility classes ─────────────────────────────────────── */
|
||||
.badge-sfw { background: oklch(70% 0.15 160 / 20%); color: oklch(70% 0.15 160); border-color: oklch(70% 0.15 160 / 40%); }
|
||||
.badge-nsfw { background: oklch(65% 0.20 25 / 20%); color: oklch(65% 0.20 25); border-color: oklch(65% 0.20 25 / 40%); }
|
||||
.badge-nsfl { background: oklch(55% 0.22 0 / 20%); color: oklch(55% 0.22 0); border-color: oklch(55% 0.22 0 / 40%); }
|
||||
.badge-unt { background: oklch(55% 0.02 265 / 20%); color: oklch(65% 0.02 265); border-color: oklch(55% 0.02 265 / 40%); }
|
||||
|
||||
/* ── Grid thumb skeleton pulse ─────────────────────────────────────────── */
|
||||
@keyframes thumb-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
.thumb-skeleton { animation: thumb-pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
/* ── Fade-in for navigations ───────────────────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.page-enter { animation: fade-in 0.15s ease-out; }
|
||||
21
f0ck-svelte/src/app.d.ts
vendored
21
f0ck-svelte/src/app.d.ts
vendored
@@ -1,21 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
import type { SessionUser } from '$lib/types/session';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
interface Locals {
|
||||
session: SessionUser | null;
|
||||
}
|
||||
interface PageData {
|
||||
session: SessionUser | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Server-side hook: read session from the existing f0ckm backend
|
||||
* by forwarding the session cookie on every SSR request.
|
||||
*
|
||||
* This keeps auth as a single source of truth in the backend.
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Forward cookies to the backend session endpoint
|
||||
const cookieHeader = event.request.headers.get('cookie') ?? '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiBase(event)}/api/v2/session`, {
|
||||
headers: { cookie: cookieHeader }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
event.locals.session = data.loggedIn ? data.user : null;
|
||||
} else {
|
||||
event.locals.session = null;
|
||||
}
|
||||
} catch {
|
||||
// Backend not reachable (dev without backend running)
|
||||
event.locals.session = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the API base URL for server-side fetches.
|
||||
* In production the SvelteKit server and the API backend run on separate ports.
|
||||
* The VITE_API_URL env var (or API_URL for SSR) sets the backend address.
|
||||
*/
|
||||
function getApiBase(event: Parameters<Handle>[0]['event']): string {
|
||||
// SSR: use internal backend address (avoids going through Nginx proxy)
|
||||
const internal = process.env.API_URL ?? 'http://localhost:1337';
|
||||
return internal;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
import { csrfFetch } from '$lib/utils/csrfFetch';
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CommentsResponse {
|
||||
success: boolean;
|
||||
comments: Comment[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
itemId: number,
|
||||
fetchFn = fetch
|
||||
): Promise<CommentsResponse> {
|
||||
const res = await fetchFn(`/api/comments/${itemId}`);
|
||||
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function postComment(payload: {
|
||||
itemId: number;
|
||||
content: string;
|
||||
replyTo?: number | null;
|
||||
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
item_id: String(payload.itemId),
|
||||
content: payload.content,
|
||||
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
|
||||
});
|
||||
const res = await csrfFetch('/api/comments', { method: 'POST', body });
|
||||
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
commentId: number
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
// Backend uses POST /api/comments/:id/delete (not DELETE method)
|
||||
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FavResponse {
|
||||
success: boolean;
|
||||
itemid: number;
|
||||
favs: Array<{
|
||||
user: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function toggleFav(itemId: number): Promise<FavResponse> {
|
||||
const body = new URLSearchParams({ postid: String(itemId) });
|
||||
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
|
||||
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagsResponse {
|
||||
success: boolean;
|
||||
tags: import('$lib/types/item').Tag[];
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||
const body = new URLSearchParams({ tagname });
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
|
||||
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CycleRatingResponse {
|
||||
success: boolean;
|
||||
rating_tag_id: number;
|
||||
rating_label: 'SFW' | 'NSFW' | 'NSFL';
|
||||
rating_class: 'sfw' | 'nsfw' | 'nsfl';
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
|
||||
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
|
||||
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) throw new Error('suggestTags failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Item management (mod/admin) ───────────────────────────────────────────────
|
||||
|
||||
export async function deleteItem(
|
||||
itemId: number,
|
||||
reason = ''
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
const body = new URLSearchParams({ postid: String(itemId), reason });
|
||||
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
|
||||
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||
// Backend reads id from query string: req.url.qs.id
|
||||
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { SessionResponse } from '$lib/types/session';
|
||||
|
||||
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
|
||||
const res = await fetchFn('/api/v2/session');
|
||||
if (!res.ok) return { loggedIn: false, user: null };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
kmsi = false
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'include'
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch('/logout', { method: 'POST', credentials: 'include' });
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
|
||||
|
||||
export interface GetItemsParams {
|
||||
page?: number;
|
||||
mode?: number;
|
||||
ratings?: string;
|
||||
mime?: string;
|
||||
tag?: string;
|
||||
hall?: string;
|
||||
user?: string;
|
||||
fav?: boolean;
|
||||
random?: boolean;
|
||||
strict?: boolean;
|
||||
min_xd?: number;
|
||||
newer?: number;
|
||||
tagger?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of grid items.
|
||||
* Called from +page.ts (server-side) and from the client for infinite-scroll.
|
||||
*/
|
||||
export async function getItems(
|
||||
params: GetItemsParams,
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<ItemsResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||
if (params.ratings) qs.set('ratings', params.ratings);
|
||||
if (params.mime) qs.set('mime', params.mime);
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.hall) qs.set('hall', params.hall);
|
||||
if (params.user) qs.set('user', params.user);
|
||||
if (params.fav) qs.set('fav', 'true');
|
||||
if (params.random) qs.set('random', '1');
|
||||
if (params.strict) qs.set('strict', '1');
|
||||
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
|
||||
if (params.newer) qs.set('newer', String(params.newer));
|
||||
if (params.tagger) qs.set('tagger', params.tagger);
|
||||
|
||||
const res = await fetchFn(`/api/v2/items?${qs}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface GetItemParams {
|
||||
mode?: number;
|
||||
ratings?: string;
|
||||
tag?: string;
|
||||
hall?: string;
|
||||
user?: string;
|
||||
fav?: boolean;
|
||||
random?: boolean;
|
||||
strict?: boolean;
|
||||
mime?: string;
|
||||
userHall?: string;
|
||||
userHallOwner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single item with its neighbours and metadata.
|
||||
*/
|
||||
export async function getItem(
|
||||
id: number | string,
|
||||
params: GetItemParams = {},
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<ItemResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||
if (params.ratings) qs.set('ratings', params.ratings);
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.hall) qs.set('hall', params.hall);
|
||||
if (params.user) qs.set('user', params.user);
|
||||
if (params.fav) qs.set('fav', 'true');
|
||||
if (params.random) qs.set('random', '1');
|
||||
if (params.strict) qs.set('strict', '1');
|
||||
if (params.mime) qs.set('mime', params.mime);
|
||||
if (params.userHall) qs.set('userHall', params.userHall);
|
||||
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
|
||||
|
||||
const suffix = qs.toString() ? `?${qs}` : '';
|
||||
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random item ID (optionally within a context).
|
||||
* Returns the JSON response; caller should navigate to response.url.
|
||||
*/
|
||||
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
|
||||
const res = await fetchFn('/api/v2/random');
|
||||
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getComments, postComment, deleteComment } from '$lib/api/actions';
|
||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { timeAgo, formatDate } from '$lib/utils/timeago';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
|
||||
interface Props {
|
||||
itemId: number;
|
||||
}
|
||||
let { itemId }: Props = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<Comment[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let text = $state('');
|
||||
let replyTo = $state<Comment | null>(null);
|
||||
let submitting = $state(false);
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await getComments(itemId);
|
||||
comments = data.comments ?? [];
|
||||
} catch {
|
||||
error = 'Failed to load comments.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run on mount and when itemId changes
|
||||
$effect(() => { load(); });
|
||||
|
||||
// ── Live updates via SSE ──────────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type !== 'comments') return;
|
||||
const d = event.data as { item_id: number; type?: string };
|
||||
if (d.item_id !== itemId) return;
|
||||
// Reload the full comment list on any comment activity for this item
|
||||
load();
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
// ── Post comment ──────────────────────────────────────────────────────────
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await postComment({
|
||||
itemId,
|
||||
content: text.trim(),
|
||||
replyTo: replyTo?.id ?? null
|
||||
});
|
||||
if (res.success) {
|
||||
text = '';
|
||||
replyTo = null;
|
||||
await load();
|
||||
flash('Comment posted', 'success');
|
||||
} else {
|
||||
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete comment ────────────────────────────────────────────────────────
|
||||
async function handleDelete(comment: Comment) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
try {
|
||||
const res = await deleteComment(comment.id);
|
||||
if (res.success) {
|
||||
await load();
|
||||
flash('Deleted', 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to delete', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function canDelete(comment: Comment) {
|
||||
return $session && ($isModOrAdmin || $session.user === comment.username);
|
||||
}
|
||||
|
||||
function avatarSrc(comment: Comment): string {
|
||||
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
|
||||
if (comment.avatar) return `/t/${comment.avatar}.webp`;
|
||||
return '/a/default.png';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
|
||||
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
|
||||
</span>
|
||||
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
|
||||
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment form -->
|
||||
{#if $session}
|
||||
<form onsubmit={submit} class="flex flex-col gap-2">
|
||||
{#if replyTo}
|
||||
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
|
||||
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
|
||||
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
|
||||
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
placeholder="Write a comment…"
|
||||
rows="2"
|
||||
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
|
||||
disabled={submitting}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm self-end"
|
||||
disabled={submitting || !text.trim()}
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="text-xs opacity-40">
|
||||
<a href="/login" class="link link-primary">Log in</a> to comment.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Comments list -->
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-4">
|
||||
<span class="loading loading-dots loading-sm opacity-40"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-xs text-error opacity-70">{error}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each comments as comment (comment.id)}
|
||||
<li
|
||||
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
|
||||
id="comment-{comment.id}"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
|
||||
<div class="avatar">
|
||||
<div class="w-7 rounded-full border border-base-300">
|
||||
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-1.5 flex-wrap">
|
||||
<a
|
||||
href="/user/{comment.username?.toLowerCase()}"
|
||||
class="font-semibold text-xs hover:text-primary transition-colors"
|
||||
style={comment.username_color ? `color: ${comment.username_color}` : ''}
|
||||
>
|
||||
{comment.display_name || comment.username}
|
||||
</a>
|
||||
<time
|
||||
datetime={comment.stamp_full}
|
||||
title={formatDate(comment.stamp_full)}
|
||||
class="text-xs opacity-40"
|
||||
>
|
||||
{comment.stamp}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if comment.is_deleted}
|
||||
<p class="text-xs italic opacity-50">— deleted —</p>
|
||||
{:else}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
|
||||
{@html comment.content}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !comment.is_deleted}
|
||||
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if $session}
|
||||
<button
|
||||
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
|
||||
class="btn btn-ghost btn-xs gap-1 text-xs"
|
||||
>
|
||||
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
|
||||
</button>
|
||||
{/if}
|
||||
{#if canDelete(comment)}
|
||||
<button
|
||||
onclick={() => handleDelete(comment)}
|
||||
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import type { ItemLink } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
item: GridItem;
|
||||
link: ItemLink;
|
||||
}
|
||||
let { item, link }: Props = $props();
|
||||
|
||||
// Build the item URL respecting the current filter context
|
||||
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
|
||||
const thumbUrl = $derived(`/t/${item.id}.webp`);
|
||||
|
||||
// Rating badge
|
||||
const ratingClass = $derived(
|
||||
item.tag === 'NSFL' ? 'badge-nsfl' :
|
||||
item.tag === 'NSFW' ? 'badge-nsfw' :
|
||||
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={itemUrl}
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="
|
||||
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
|
||||
hover:border-primary/50 transition-all duration-200
|
||||
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
|
||||
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
|
||||
"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-video w-full overflow-hidden bg-base-300">
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt="Item {item.id}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Overlay on hover -->
|
||||
<div class="
|
||||
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||
flex flex-col justify-end p-2 gap-1
|
||||
">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if item.is_oc}
|
||||
<span class="badge badge-xs badge-accent">OC</span>
|
||||
{/if}
|
||||
{#if item.is_pinned}
|
||||
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
|
||||
{/if}
|
||||
{#if item.xd_tier > 0}
|
||||
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if item.display_name || item.username}
|
||||
<span class="text-xs text-base-content/60 truncate">
|
||||
{item.display_name || item.username}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rating badge (always visible) -->
|
||||
{#if item.tag}
|
||||
<div class="absolute top-1.5 right-1.5">
|
||||
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notification dot -->
|
||||
{#if item.has_notification}
|
||||
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -1,503 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
|
||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
|
||||
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
|
||||
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
|
||||
import TagList from '$lib/components/item/TagList.svelte';
|
||||
import Comments from '$lib/components/item/Comments.svelte';
|
||||
import { mimeCategory } from '$lib/utils/mimeHelpers';
|
||||
import { formatDate } from '$lib/utils/timeago';
|
||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||
import { goto, preloadData } from '$app/navigation';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { broadcastPin } from '$lib/stores/pin';
|
||||
import {
|
||||
toggleFav, addTag, deleteTag,
|
||||
deleteItem, pinItem, unpinItem, cycleRating
|
||||
} from '$lib/api/actions';
|
||||
|
||||
interface Props {
|
||||
item: FullItem;
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
isSubscribed?: boolean;
|
||||
}
|
||||
let { item, pagination, link, isSubscribed = false }: Props = $props();
|
||||
|
||||
// ── Reactive local state (mutable copies updated after actions) ───────────
|
||||
let favorites = $state(item.favorites);
|
||||
let tags = $state(item.tags);
|
||||
let isPinned = $state(item.is_pinned);
|
||||
let isOc = $state(item.is_oc);
|
||||
let isSfw = $state(item.is_sfw);
|
||||
let isNsfw = $state(item.is_nsfw);
|
||||
let isNsfl = $state(item.is_nsfl);
|
||||
|
||||
// Sync when item prop changes (navigation)
|
||||
$effect(() => {
|
||||
favorites = item.favorites;
|
||||
tags = item.tags;
|
||||
isPinned = item.is_pinned;
|
||||
isOc = item.is_oc;
|
||||
isSfw = item.is_sfw;
|
||||
isNsfw = item.is_nsfw;
|
||||
isNsfl = item.is_nsfl;
|
||||
});
|
||||
|
||||
// ── Live SSE updates ──────────────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'favorites') {
|
||||
const d = event.data as { item_id: number; favs: FavoriteUser[] };
|
||||
if (d.item_id === item.id) favorites = d.favs;
|
||||
} else if (event.type === 'tags') {
|
||||
const d = event.data as { item_id: number; tags: Tag[] };
|
||||
if (d.item_id === item.id) {
|
||||
tags = d.tags;
|
||||
// Re-derive rating state from the fresh tag list
|
||||
const SFW_ID = 1, NSFW_ID = 2;
|
||||
const ids = d.tags.map(t => t.id);
|
||||
isSfw = ids.includes(SFW_ID);
|
||||
isNsfw = ids.includes(NSFW_ID);
|
||||
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
|
||||
}
|
||||
} else if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
if (d.id === item.id) goto('/');
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
const cat = $derived(mimeCategory(item.mime));
|
||||
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
|
||||
|
||||
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
|
||||
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
|
||||
|
||||
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
|
||||
|
||||
// Rating derived from local mutable state (so cycle updates instantly)
|
||||
const ratingClass = $derived(
|
||||
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
|
||||
);
|
||||
const ratingLabel = $derived(
|
||||
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
|
||||
);
|
||||
|
||||
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
|
||||
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
|
||||
|
||||
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
|
||||
let ratingCycling = $state(false);
|
||||
async function handleCycleRating() {
|
||||
if (!isMod) return;
|
||||
ratingCycling = true;
|
||||
try {
|
||||
const res = await cycleRating(item.id);
|
||||
if (res.success) {
|
||||
isSfw = res.rating_class === 'sfw';
|
||||
isNsfw = res.rating_class === 'nsfw';
|
||||
isNsfl = res.rating_class === 'nsfl';
|
||||
flash(`Rating → ${res.rating_label}`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to cycle rating', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
ratingCycling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard navigation ───────────────────────────────────────────────────
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
|
||||
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
|
||||
}
|
||||
|
||||
// ── Tag input state ───────────────────────────────────────────────────────
|
||||
let tagInput = $state('');
|
||||
let tagAdding = $state(false);
|
||||
let showTagInput = $state(false);
|
||||
let suggestions = $state<string[]>([]);
|
||||
|
||||
async function submitTag(e: Event) {
|
||||
e.preventDefault();
|
||||
const name = tagInput.trim();
|
||||
if (!name) return;
|
||||
tagAdding = true;
|
||||
try {
|
||||
const res = await addTag(item.id, name);
|
||||
if (res.success) {
|
||||
tags = res.tags;
|
||||
tagInput = '';
|
||||
showTagInput = false;
|
||||
flash(`Tag "${name}" added`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to add tag', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
tagAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTag(tag: Tag) {
|
||||
try {
|
||||
const res = await deleteTag(item.id, tag.tag);
|
||||
if (res.success) {
|
||||
tags = res.tags;
|
||||
flash(`Tag "${tag.tag}" removed`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to remove tag', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favorite ──────────────────────────────────────────────────────────────
|
||||
let favLoading = $state(false);
|
||||
async function handleFav() {
|
||||
if (!$session) { flash('Log in to favorite', 'warning'); return; }
|
||||
favLoading = true;
|
||||
try {
|
||||
const res = await toggleFav(item.id);
|
||||
if (res.success) favorites = res.favs;
|
||||
} catch {
|
||||
flash('Failed to update favorite', 'error');
|
||||
} finally {
|
||||
favLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function handleDelete() {
|
||||
const reason = prompt('Reason for deletion (optional):') ?? '';
|
||||
if (reason === null) return; // cancelled
|
||||
try {
|
||||
const res = await deleteItem(item.id, reason);
|
||||
if (res.success) {
|
||||
flash('Item deleted', 'success');
|
||||
goto('/');
|
||||
} else {
|
||||
flash(res.msg ?? 'Delete failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pin ───────────────────────────────────────────────────────────────────
|
||||
async function handlePin() {
|
||||
try {
|
||||
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
|
||||
if (res.success) {
|
||||
isPinned = res.pinned;
|
||||
broadcastPin(item.id, res.pinned); // patch grid card live
|
||||
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
|
||||
} else {
|
||||
flash('Pin failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
|
||||
|
||||
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
|
||||
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
|
||||
|
||||
<!-- Tags card -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3 gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
|
||||
{#if $session}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => showTagInput = !showTagInput}
|
||||
aria-label="Add tag"
|
||||
>
|
||||
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tag input -->
|
||||
{#if showTagInput}
|
||||
<form onsubmit={submitTag} class="flex gap-1">
|
||||
<input
|
||||
bind:value={tagInput}
|
||||
type="text"
|
||||
placeholder="tag name…"
|
||||
class="input input-xs input-bordered flex-1"
|
||||
disabled={tagAdding}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-xs btn-primary"
|
||||
disabled={tagAdding || !tagInput.trim()}
|
||||
>
|
||||
{#if tagAdding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
OK
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if tags.length > 0}
|
||||
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
|
||||
{:else}
|
||||
<p class="text-xs opacity-40">No tags yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- xD Score -->
|
||||
{#if item.xd_tier > 0}
|
||||
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
|
||||
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
|
||||
{item.xd_label}
|
||||
<span class="opacity-70 text-xs">{item.xd_score}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3">
|
||||
<Comments itemId={item.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
|
||||
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
|
||||
|
||||
{#if item.title}
|
||||
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Media + prev/next arrows -->
|
||||
<div class="relative group">
|
||||
<!-- Prev -->
|
||||
<a
|
||||
href={prevUrl ?? '#'}
|
||||
aria-disabled={!prevUrl}
|
||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
{!prevUrl ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="rounded-lg overflow-hidden bg-base-300">
|
||||
{#if cat === 'youtube'}
|
||||
<YouTubeEmbed videoId={ytId} />
|
||||
{:else if cat === 'video'}
|
||||
<VideoPlayer src={item.dest} mime={item.mime} />
|
||||
{:else if cat === 'audio'}
|
||||
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
|
||||
{:else if cat === 'image'}
|
||||
<ImageViewer src={item.dest} alt="Item {item.id}" />
|
||||
{:else if cat === 'pdf'}
|
||||
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center p-12 gap-4">
|
||||
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
|
||||
<a href={item.dest} download class="btn btn-primary gap-2">
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next -->
|
||||
<a
|
||||
href={nextUrl ?? '#'}
|
||||
aria-disabled={!nextUrl}
|
||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
{!nextUrl ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Metadata / action bar -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3 gap-2">
|
||||
|
||||
<!-- Prev / Random / Next -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
|
||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
|
||||
</a>
|
||||
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
|
||||
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
|
||||
</a>
|
||||
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
|
||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- Item metadata row -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
|
||||
#{item.id}
|
||||
</a>
|
||||
{#if isMod}
|
||||
<button
|
||||
onclick={handleCycleRating}
|
||||
disabled={ratingCycling}
|
||||
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
|
||||
title="Cycle rating: {ratingLabel} → {isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
|
||||
aria-label="Cycle rating"
|
||||
>
|
||||
{#if ratingCycling}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
|
||||
{/if}
|
||||
{ratingLabel}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
|
||||
{/if}
|
||||
<a href="/user/{item.username.toLowerCase()}"
|
||||
class="font-medium hover:text-primary transition-colors"
|
||||
style={item.author_color ? `color: ${item.author_color}` : ''}>
|
||||
{item.author_display_name || item.username}
|
||||
</a>
|
||||
{#if isOc}
|
||||
<span class="badge badge-sm badge-accent">OC</span>
|
||||
{/if}
|
||||
{#if isPinned}
|
||||
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
|
||||
{/if}
|
||||
{#if item.primaryHall}
|
||||
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
|
||||
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
|
||||
{item.primaryHall.name}
|
||||
</a>
|
||||
{/if}
|
||||
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
|
||||
{item.timestamp.timeago}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center flex-wrap gap-1 pt-1">
|
||||
<!-- Favorite -->
|
||||
<button
|
||||
onclick={handleFav}
|
||||
disabled={favLoading || !$session}
|
||||
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
|
||||
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
|
||||
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
|
||||
>
|
||||
{#if favLoading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
|
||||
{/if}
|
||||
{#if favorites.length > 0}
|
||||
<span class="text-xs">{favorites.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Subscribe (stub) -->
|
||||
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
|
||||
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Link copy -->
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Copy link"
|
||||
aria-label="Copy link"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(location.href);
|
||||
flash('Link copied!', 'success');
|
||||
}}
|
||||
>
|
||||
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mod / owner actions -->
|
||||
{#if canManage}
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<!-- Pin/Unpin -->
|
||||
<button
|
||||
onclick={handlePin}
|
||||
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
|
||||
title="{isPinned ? 'Unpin' : 'Pin'}"
|
||||
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
|
||||
>
|
||||
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorites avatars -->
|
||||
{#if favorites.length > 0}
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
{#each favorites as fav (fav.user)}
|
||||
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
|
||||
<div class="avatar">
|
||||
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
|
||||
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
|
||||
{#if fav.avatar_file}
|
||||
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
|
||||
{:else if fav.avatar}
|
||||
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
|
||||
{:else}
|
||||
<img src="/a/default.png" alt={fav.user} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
canRemove?: boolean;
|
||||
onRemove?: (tag: Tag) => void;
|
||||
}
|
||||
let { tags, canRemove = false, onRemove }: Props = $props();
|
||||
|
||||
// Map badge class names from the existing tag badge values
|
||||
const badgeClass = (badge: string) => {
|
||||
if (badge === 'badge-sfw') return 'badge-sfw';
|
||||
if (badge === 'badge-nsfw') return 'badge-nsfw';
|
||||
if (badge === 'badge-nsfl') return 'badge-nsfl';
|
||||
return 'badge-neutral';
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each tags as tag (tag.id)}
|
||||
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
|
||||
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
|
||||
{tag.tag}
|
||||
</a>
|
||||
{#if canRemove && onRemove}
|
||||
<button
|
||||
class="hover:text-error ml-0.5 transition-colors"
|
||||
onclick={() => onRemove?.(tag)}
|
||||
aria-label="Remove tag {tag.tag}"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-[10px]"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { session, loggedIn } from '$lib/stores/session';
|
||||
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getRandom } from '$lib/api/items';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
async function goRandom() {
|
||||
try {
|
||||
const res = await getRandom();
|
||||
if (res.success) goto(res.url);
|
||||
} catch {
|
||||
flash('Could not fetch a random item', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
|
||||
// OR just use them in the template with $ prefix — both work, but $ in templates
|
||||
// is fine for Svelte writable stores even in runes mode.
|
||||
</script>
|
||||
|
||||
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
|
||||
<!-- Logo / Home -->
|
||||
<div class="navbar-start gap-2">
|
||||
<a href="/" class="flex items-center gap-2 group">
|
||||
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
|
||||
f0ckm
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: actions -->
|
||||
<div class="navbar-center gap-1 hidden sm:flex">
|
||||
<!-- Random button -->
|
||||
<button
|
||||
class="btn btn-ghost btn-sm gap-1"
|
||||
onclick={goRandom}
|
||||
title="Random item"
|
||||
aria-label="Random item"
|
||||
>
|
||||
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
|
||||
<span class="hidden md:inline">Random</span>
|
||||
</button>
|
||||
|
||||
<!-- Rating filter badge -->
|
||||
<div class="dropdown dropdown-bottom">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
|
||||
aria-label="Content filter"
|
||||
>
|
||||
{$filterLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: user area -->
|
||||
<div class="navbar-end gap-2">
|
||||
{#if $loggedIn}
|
||||
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
|
||||
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
|
||||
<span class="hidden sm:inline">Upload</span>
|
||||
</a>
|
||||
<!-- Avatar / user menu -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
|
||||
{#if $session?.avatar_file}
|
||||
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
|
||||
{:else if $session?.avatar}
|
||||
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
|
||||
{:else}
|
||||
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
|
||||
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
|
||||
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
|
||||
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
|
||||
{#if $session?.admin || $session?.is_moderator}
|
||||
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
|
||||
{/if}
|
||||
<li class="divider my-1"></li>
|
||||
<li>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="text-error w-full text-left">
|
||||
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
|
||||
<a href="/register" class="btn btn-primary btn-sm">Register</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
mime: string;
|
||||
coverart?: string;
|
||||
}
|
||||
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
|
||||
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
|
||||
>
|
||||
<!-- Dark overlay over coverart -->
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<audio
|
||||
{src}
|
||||
loop
|
||||
controls
|
||||
preload="auto"
|
||||
class="relative z-10 w-full max-w-md"
|
||||
></audio>
|
||||
</div>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
}
|
||||
let { src, alt = 'Image' }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let imgEl = $state<HTMLImageElement | undefined>();
|
||||
|
||||
function toggleExpand() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
// ESC to close modal
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded) expanded = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
{src}
|
||||
{alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onclick={toggleExpand}
|
||||
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
|
||||
/>
|
||||
<button
|
||||
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
|
||||
onclick={toggleExpand}
|
||||
aria-label="Expand"
|
||||
>
|
||||
<i class="fa-solid fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen modal -->
|
||||
{#if expanded}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
|
||||
onclick={toggleExpand}
|
||||
>
|
||||
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
|
||||
<button
|
||||
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
|
||||
onclick={toggleExpand}
|
||||
aria-label="Close"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
mime: string;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
let { src, mime, autoplay = false }: Props = $props();
|
||||
|
||||
let videoEl = $state<HTMLVideoElement | undefined>();
|
||||
|
||||
// Keyboard shortcut: space to toggle play/pause
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.target !== document.body) return;
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
else videoEl?.pause();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
{src}
|
||||
{autoplay}
|
||||
loop
|
||||
playsinline
|
||||
controls
|
||||
class="max-h-[80dvh] w-full object-contain"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
videoId: string; // e.g. "dQw4w9WgXcQ"
|
||||
}
|
||||
let { videoId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
|
||||
<iframe
|
||||
class="absolute inset-0 w-full h-full"
|
||||
src="https://www.youtube.com/embed/{videoId}"
|
||||
title="YouTube video"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
|
||||
|
||||
const iconMap: Record<FlashType, string> = {
|
||||
info: 'fa-solid fa-circle-info',
|
||||
success: 'fa-solid fa-circle-check',
|
||||
warning: 'fa-solid fa-triangle-exclamation',
|
||||
error: 'fa-solid fa-circle-xmark'
|
||||
};
|
||||
|
||||
const alertMap: Record<FlashType, string> = {
|
||||
info: 'alert-info',
|
||||
success: 'alert-success',
|
||||
warning: 'alert-warning',
|
||||
error: 'alert-error'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
||||
{#each $flashes as msg (msg.id)}
|
||||
<div
|
||||
role="alert"
|
||||
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
|
||||
animate-[slide-up_0.2s_ease-out]"
|
||||
>
|
||||
<i class="{iconMap[msg.type]}"></i>
|
||||
<span class="flex-1 text-sm">{msg.text}</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => dismiss(msg.id)}
|
||||
aria-label="Dismiss"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Pagination, ItemLink } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
}
|
||||
let { pagination, link }: Props = $props();
|
||||
|
||||
const base = $derived(link.main + link.path);
|
||||
const suffix = $derived(link.suffix ?? '');
|
||||
</script>
|
||||
|
||||
{#if pagination.end > 1}
|
||||
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
|
||||
<!-- Prev -->
|
||||
<a
|
||||
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
|
||||
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
|
||||
aria-disabled={!pagination.prev}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
<!-- First page -->
|
||||
{#if pagination.cheat[0] > 1}
|
||||
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
|
||||
{#if pagination.cheat[0] > 2}
|
||||
<span class="px-1 opacity-40">…</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Page range -->
|
||||
{#each pagination.cheat as p}
|
||||
<a
|
||||
href="{base}{p}{suffix}"
|
||||
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Last page -->
|
||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
|
||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
|
||||
<span class="px-1 opacity-40">…</span>
|
||||
{/if}
|
||||
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
|
||||
{/if}
|
||||
|
||||
<!-- Next -->
|
||||
<a
|
||||
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
|
||||
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
|
||||
aria-disabled={!pagination.next}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,28 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── Flash message type ────────────────────────────────────────────────────────
|
||||
export type FlashType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface FlashMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: FlashType;
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
export const flashes = writable<FlashMessage[]>([]);
|
||||
|
||||
/**
|
||||
* Show a flash message. Auto-dismisses after `duration` ms.
|
||||
* Returns the message ID so callers can dismiss early if needed.
|
||||
*/
|
||||
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
|
||||
const id = ++_nextId;
|
||||
flashes.update((q) => [...q, { id, text, type }]);
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismiss(id: number): void {
|
||||
flashes.update((q) => q.filter((m) => m.id !== id));
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Lightweight broadcast store for pin/unpin events.
|
||||
* ItemView fires it after a successful pin action;
|
||||
* the main grid subscribes to patch the item card immediately.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface PinEvent {
|
||||
itemId: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
// Null when idle, set to an event when pin/unpin succeeds
|
||||
export const pinEvent = writable<PinEvent | null>(null);
|
||||
|
||||
export function broadcastPin(itemId: number, pinned: boolean) {
|
||||
pinEvent.set({ itemId, pinned });
|
||||
// Reset so subsequent identical events still trigger subscribers
|
||||
setTimeout(() => pinEvent.set(null), 0);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
|
||||
// ── Content mode ─────────────────────────────────────────────────────────────
|
||||
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
|
||||
export const activeMode = writable<number>(3);
|
||||
|
||||
// ── Multi-rating filter ───────────────────────────────────────────────────────
|
||||
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
|
||||
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
|
||||
export const activeRatings = writable<Rating[]>([]);
|
||||
|
||||
// ── MIME filter ───────────────────────────────────────────────────────────────
|
||||
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
|
||||
export const activeMimes = writable<MimeFilter[]>([]);
|
||||
|
||||
// ── Derived: ratings query param string for API calls ─────────────────────────
|
||||
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
|
||||
|
||||
// ── Derived: readable label for the filter badge ─────────────────────────────
|
||||
export const filterLabel = derived(
|
||||
[activeMode, activeRatings],
|
||||
([$mode, $ratings]) => {
|
||||
if ($ratings.length > 0) {
|
||||
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
|
||||
return $ratings.map((r) => abbr[r]).join('+');
|
||||
}
|
||||
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
|
||||
return labels[$mode] ?? 'ALL';
|
||||
}
|
||||
);
|
||||
|
||||
// ── Persistence helpers ───────────────────────────────────────────────────────
|
||||
// Call these after receiving the session from the server to hydrate from cookie/session.
|
||||
export function initPreferences(opts: { mode: number; ratings?: string }) {
|
||||
activeMode.set(opts.mode ?? 3);
|
||||
if (opts.ratings) {
|
||||
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
|
||||
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
|
||||
);
|
||||
activeRatings.set(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a mode number → server mode number (for API calls when using multi-rating). */
|
||||
export function getServerMode(): number {
|
||||
const ratings = get(activeRatings);
|
||||
if (ratings.length === 0) return get(activeMode);
|
||||
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
|
||||
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { SessionUser } from '$lib/types/session';
|
||||
|
||||
/** The current authenticated user, or null if not logged in. */
|
||||
export const session = writable<SessionUser | null>(null);
|
||||
|
||||
/** Convenience derived — true when logged in. */
|
||||
export const loggedIn = derived(session, ($s) => $s !== null);
|
||||
|
||||
/** True when the user has admin or moderator role. */
|
||||
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* SSE store — connects to the backend's /api/notifications/stream endpoint.
|
||||
*
|
||||
* Emits typed events that components subscribe to with `onSSE()`.
|
||||
* Auto-reconnects with exponential backoff on disconnect.
|
||||
* Client-only: never runs during SSR.
|
||||
*/
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// ── Event types broadcast by the backend ─────────────────────────────────────
|
||||
export interface SSEComment {
|
||||
type: 'comment' | 'delete';
|
||||
id?: number;
|
||||
item_id: number;
|
||||
comment_id?: number;
|
||||
body?: string;
|
||||
username?: string;
|
||||
display_name?: string | null;
|
||||
avatar?: string | null;
|
||||
avatar_file?: string | null;
|
||||
username_color?: string | null;
|
||||
created_at?: string;
|
||||
parent_id?: number | null;
|
||||
xd_score?: number | null;
|
||||
}
|
||||
|
||||
export interface SSEFavorites {
|
||||
item_id: number;
|
||||
favs: Array<{
|
||||
user: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SSETags {
|
||||
item_id: number;
|
||||
fresh: boolean;
|
||||
tags: unknown[];
|
||||
}
|
||||
|
||||
export interface SSENewItem {
|
||||
id: number;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
export interface SSEDeleteItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
|
||||
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Connection state ──────────────────────────────────────────────────────────
|
||||
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
export const sseState = writable<SSEState>('disconnected');
|
||||
|
||||
// ── Listener registry ─────────────────────────────────────────────────────────
|
||||
type SSEListener = (event: SSEEvent) => void;
|
||||
const listeners = new Set<SSEListener>();
|
||||
|
||||
/** Subscribe to SSE events. Returns an unsubscribe function. */
|
||||
export function onSSE(fn: SSEListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// ── Internal connection state ─────────────────────────────────────────────────
|
||||
let es: EventSource | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryDelay = 2000;
|
||||
const MAX_RETRY = 30_000;
|
||||
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
|
||||
|
||||
function dispatch(raw: SSEEvent) {
|
||||
for (const fn of listeners) {
|
||||
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connect, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
|
||||
}
|
||||
|
||||
export function connect() {
|
||||
if (!browser) return;
|
||||
if (es) { es.close(); es = null; }
|
||||
|
||||
sseState.set('connecting');
|
||||
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
|
||||
|
||||
es.onopen = () => {
|
||||
sseState.set('connected');
|
||||
retryDelay = 2000; // reset backoff on successful connect
|
||||
};
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as SSEEvent;
|
||||
dispatch(data);
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
sseState.set('error');
|
||||
es?.close();
|
||||
es = null;
|
||||
scheduleRetry();
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
es?.close();
|
||||
es = null;
|
||||
sseState.set('disconnected');
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── WebSocket message types ───────────────────────────────────────────────────
|
||||
export type WSMessage =
|
||||
| { type: 'pong' }
|
||||
| { type: 'notification'; count: number; item?: unknown }
|
||||
| { type: 'comment:new'; itemId: number; comment: unknown }
|
||||
| { type: 'comment:delete'; itemId: number; commentId: number }
|
||||
| { type: 'item:tag_added'; itemId: number; tag: unknown }
|
||||
| { type: 'item:favorited'; itemId: number; user: string }
|
||||
| { type: 'global:activity'; payload: unknown };
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
export const wsConnected = writable(false);
|
||||
|
||||
let _socket: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _reconnectDelay = 1000;
|
||||
|
||||
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
|
||||
|
||||
/** Connect to the WebSocket server. Call once from the root layout. */
|
||||
export function wsConnect(): void {
|
||||
if (typeof window === 'undefined') return; // SSR guard
|
||||
if (_socket?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_socket = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
|
||||
_socket.onopen = () => {
|
||||
wsConnected.set(true);
|
||||
_reconnectDelay = 1000; // reset backoff on successful connect
|
||||
_ping();
|
||||
};
|
||||
|
||||
_socket.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data) as WSMessage;
|
||||
// Dispatch to typed listeners
|
||||
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
|
||||
_listeners.get('*')?.forEach((fn) => fn(msg));
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
_socket.onclose = () => {
|
||||
wsConnected.set(false);
|
||||
_socket = null;
|
||||
// Exponential backoff reconnect
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
||||
wsConnect();
|
||||
}, _reconnectDelay);
|
||||
};
|
||||
|
||||
_socket.onerror = () => {
|
||||
_socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a typed message to the server. */
|
||||
export function wsSend(msg: object): void {
|
||||
if (_socket?.readyState === WebSocket.OPEN) {
|
||||
_socket.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to a specific message type. Returns an unsubscribe function. */
|
||||
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
|
||||
if (!_listeners.has(type)) _listeners.set(type, new Set());
|
||||
_listeners.get(type)!.add(fn);
|
||||
return () => _listeners.get(type)?.delete(fn);
|
||||
}
|
||||
|
||||
let _pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function _ping() {
|
||||
if (_pingTimer) clearInterval(_pingTimer);
|
||||
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface Comment {
|
||||
id: number;
|
||||
item_id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
content: string; // may be HTML (server-rendered)
|
||||
stamp: string; // human-readable relative time
|
||||
stamp_full: string; // ISO timestamp
|
||||
is_deleted: boolean;
|
||||
reply_to: number | null;
|
||||
poll?: unknown;
|
||||
}
|
||||
|
||||
export interface CommentsResponse {
|
||||
success: boolean;
|
||||
comments: Comment[];
|
||||
total?: number;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
|
||||
export interface GridItem {
|
||||
id: number;
|
||||
mime: string;
|
||||
dest: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
is_pinned: boolean;
|
||||
is_oc: boolean;
|
||||
xd_score: number;
|
||||
xd_tier: number;
|
||||
xd_label: string;
|
||||
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
|
||||
tag_id: number | null;
|
||||
thumb_size: 1 | 2;
|
||||
has_notification: boolean;
|
||||
}
|
||||
|
||||
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
|
||||
export interface FullItem {
|
||||
id: number;
|
||||
mime: string;
|
||||
dest: string;
|
||||
username: string;
|
||||
author_id: number | null;
|
||||
author_color: string | null;
|
||||
author_display_name: string | null;
|
||||
author_avatar: string | null;
|
||||
author_avatar_file: string | null;
|
||||
title: string | null;
|
||||
src: { long: string; short: string };
|
||||
thumbnail: string;
|
||||
og_thumbnail: string;
|
||||
og_description: string;
|
||||
coverart: string;
|
||||
size: string;
|
||||
checksum: string | null;
|
||||
timestamp: { timeago: string; timefull: string };
|
||||
favorites: FavoriteUser[];
|
||||
tags: Tag[];
|
||||
halls: Hall[];
|
||||
user_halls: Hall[];
|
||||
is_nsfw: boolean;
|
||||
is_nsfl: boolean;
|
||||
is_sfw: boolean;
|
||||
is_pinned: boolean;
|
||||
is_comments_locked: boolean;
|
||||
is_oc: boolean;
|
||||
is_repost: boolean;
|
||||
reposts: RepostItem[];
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
original_filename: string | null;
|
||||
xd_score: number;
|
||||
xd_tier: number;
|
||||
xd_label: string;
|
||||
primaryHall: Hall | null;
|
||||
otherHalls: Hall[];
|
||||
}
|
||||
|
||||
// ── Pagination ───────────────────────────────────────────────────────────────
|
||||
export interface Pagination {
|
||||
prev: number | null;
|
||||
next: number | null;
|
||||
start: number;
|
||||
end: number;
|
||||
current: number;
|
||||
page: number;
|
||||
cheat: number[];
|
||||
location?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
// ── Link context ─────────────────────────────────────────────────────────────
|
||||
export interface ItemLink {
|
||||
main: string;
|
||||
mainDisplay?: string;
|
||||
path: string;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
// ── Tags ─────────────────────────────────────────────────────────────────────
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tag: string;
|
||||
normalized: string;
|
||||
badge: string;
|
||||
display_name: string | null;
|
||||
user: string;
|
||||
}
|
||||
|
||||
// ── Halls ────────────────────────────────────────────────────────────────────
|
||||
export interface Hall {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||
export interface FavoriteUser {
|
||||
user: string;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
// ── Reposts ──────────────────────────────────────────────────────────────────
|
||||
export interface RepostItem {
|
||||
id: number;
|
||||
username: string;
|
||||
stamp: number;
|
||||
match_type: 'checksum' | 'phash';
|
||||
}
|
||||
|
||||
// ── API response wrappers ─────────────────────────────────────────────────────
|
||||
export interface ItemsResponse {
|
||||
success: boolean;
|
||||
items: GridItem[];
|
||||
pagination: Pagination;
|
||||
total: number;
|
||||
link: ItemLink;
|
||||
}
|
||||
|
||||
export interface ItemResponse {
|
||||
success: boolean;
|
||||
item: FullItem | null;
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
isSubscribed: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RandomResponse {
|
||||
success: boolean;
|
||||
id: number;
|
||||
url: string;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
user: string;
|
||||
login: string;
|
||||
admin: boolean;
|
||||
is_moderator: boolean;
|
||||
mode: number;
|
||||
theme: string;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
display_name: string | null;
|
||||
use_new_layout: boolean;
|
||||
excluded_tags: number[];
|
||||
min_xd_score: number;
|
||||
uploads_remaining: number | undefined;
|
||||
pending_count?: number;
|
||||
language: string | null;
|
||||
disable_autoplay: boolean;
|
||||
disable_swiping: boolean;
|
||||
csrf_token: string;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
loggedIn: boolean;
|
||||
user: SessionUser | null;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Wrapper around fetch that automatically injects the CSRF token as a header
|
||||
* on all state-changing requests (POST, PUT, PATCH, DELETE).
|
||||
*
|
||||
* The token is read from the session store at call time so it's always fresh.
|
||||
*/
|
||||
import { get } from 'svelte/store';
|
||||
import { session } from '$lib/stores/session';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
if (SAFE_METHODS.has(method)) return fetch(input, init);
|
||||
|
||||
const token = get(session)?.csrf_token;
|
||||
if (!token) return fetch(input, init); // not logged in — let backend return 401
|
||||
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
headers.set('X-CSRF-Token', token);
|
||||
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/** Map a MIME type to a simple category string. */
|
||||
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
|
||||
if (mime === 'video/youtube') return 'youtube';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime === 'application/pdf') return 'pdf';
|
||||
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
|
||||
if (mime.startsWith('application/')) return 'archive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Return a Font Awesome icon class for a MIME category. */
|
||||
export function mimeIcon(mime: string): string {
|
||||
const cat = mimeCategory(mime);
|
||||
const map: Record<string, string> = {
|
||||
youtube: 'fa-brands fa-youtube',
|
||||
video: 'fa-solid fa-film',
|
||||
audio: 'fa-solid fa-music',
|
||||
image: 'fa-solid fa-image',
|
||||
flash: 'fa-solid fa-bolt',
|
||||
pdf: 'fa-solid fa-file-pdf',
|
||||
archive: 'fa-solid fa-file-zipper',
|
||||
unknown: 'fa-solid fa-file'
|
||||
};
|
||||
return map[cat] ?? map.unknown;
|
||||
}
|
||||
|
||||
/** True if the MIME type represents inline-playable media. */
|
||||
export function isPlayable(mime: string): boolean {
|
||||
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
|
||||
* relative time string, e.g. "3 hours ago".
|
||||
*/
|
||||
export function timeAgo(input: number | string | Date): string {
|
||||
const date =
|
||||
input instanceof Date
|
||||
? input
|
||||
: typeof input === 'number'
|
||||
? new Date(input * 1000)
|
||||
: new Date(input);
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
|
||||
const epochs: [string, number][] = [
|
||||
['year', 365 * 24 * 3600 * 1000],
|
||||
['month', 30 * 24 * 3600 * 1000],
|
||||
['week', 7 * 24 * 3600 * 1000],
|
||||
['day', 24 * 3600 * 1000],
|
||||
['hour', 3600 * 1000],
|
||||
['minute', 60 * 1000],
|
||||
['second', 1000]
|
||||
];
|
||||
|
||||
for (const [label, ms] of epochs) {
|
||||
const n = Math.floor(diff / ms);
|
||||
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp to a readable full date string.
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
/** Pass session to all pages via PageData. */
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/layout/Navbar.svelte';
|
||||
import FlashMessages from '$lib/components/ui/FlashMessages.svelte';
|
||||
import { session as sessionStore } from '$lib/stores/session';
|
||||
import { initPreferences } from '$lib/stores/preferences';
|
||||
import { connect as sseConnect, disconnect as sseDisconnect } from '$lib/stores/sse';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
session: import('$lib/types/session').SessionUser | null;
|
||||
};
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
// Hydrate session store from server-loaded data
|
||||
$effect(() => {
|
||||
sessionStore.set(data.session);
|
||||
});
|
||||
|
||||
// Init user preferences from session
|
||||
$effect(() => {
|
||||
if (data.session) {
|
||||
initPreferences({ mode: data.session.mode ?? 3 });
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
sseConnect();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
sseDisconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>f0ckm</title>
|
||||
<meta name="description" content="f0ckm — media sharing" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh flex flex-col" data-theme="f0ck">
|
||||
<Navbar />
|
||||
<div class="flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<!-- Global flash messages -->
|
||||
<FlashMessages />
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItems } from '$lib/api/items';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
const page = Number(url.searchParams.get('page') ?? 1);
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
try {
|
||||
const data = await getItems({ page, mode }, fetch);
|
||||
if (data.success) {
|
||||
return {
|
||||
items: data.items,
|
||||
pagination: data.pagination,
|
||||
total: data.total,
|
||||
link: data.link
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not yet running or API route not registered — return empty state
|
||||
// so the page renders rather than hard-erroring.
|
||||
console.warn('[/] Failed to load items:', (e as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
items: [],
|
||||
pagination: null,
|
||||
total: 0,
|
||||
link: { main: '/', path: 'p/', suffix: '' }
|
||||
};
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { pinEvent } from '$lib/stores/pin';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Local mutable copy of the items list — SSE patches it live
|
||||
let items = $state<GridItem[]>(data.items);
|
||||
let total = $state(data.total);
|
||||
|
||||
// Re-sync when server data changes (e.g. navigation / invalidation)
|
||||
$effect(() => {
|
||||
items = data.items;
|
||||
total = data.total;
|
||||
});
|
||||
|
||||
// ── Live grid updates via SSE ─────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'new_item') {
|
||||
const d = event.data as GridItem & { dest: string; username: string };
|
||||
if (!items.some(i => i.id === d.id)) {
|
||||
items = [d, ...items];
|
||||
total += 1;
|
||||
}
|
||||
} else if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
const before = items.length;
|
||||
items = items.filter(i => i.id !== d.id);
|
||||
if (items.length < before) total = Math.max(0, total - 1);
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
// ── Pin store: patch the grid card is_pinned live ─────────────────────────
|
||||
const unsubPin = pinEvent.subscribe((evt) => {
|
||||
if (!evt) return;
|
||||
items = items.map(i => i.id === evt.itemId ? { ...i, is_pinned: evt.pinned } : i);
|
||||
});
|
||||
onDestroy(unsubPin);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>f0ckm — {total} items</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
||||
<!-- Grid header -->
|
||||
{#if total > 0}
|
||||
<p class="text-xs opacity-40 mb-3 font-mono">{total.toLocaleString()} items</p>
|
||||
{/if}
|
||||
|
||||
<!-- Thumbnail grid -->
|
||||
{#if items.length > 0}
|
||||
<div class="
|
||||
grid gap-1.5
|
||||
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
||||
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
||||
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
||||
">
|
||||
{#each items as item (item.id)}
|
||||
<ItemCard {item} link={data.link} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.pagination}
|
||||
<Pagination pagination={data.pagination} link={data.link} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
||||
<i class="fa-solid fa-box-open text-5xl"></i>
|
||||
<p class="text-sm">No items found.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItem } from '$lib/api/items';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch, locals, url }) => {
|
||||
const id = params.itemid;
|
||||
if (!id || !/^\d+$/.test(id)) throw error(404, 'Not found');
|
||||
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
try {
|
||||
const data = await getItem(id, { mode }, fetch);
|
||||
|
||||
if (!data.success || !data.item) {
|
||||
throw error(404, 'Item not found or not visible in current mode');
|
||||
}
|
||||
|
||||
return {
|
||||
item: data.item,
|
||||
pagination: data.pagination,
|
||||
link: data.link,
|
||||
isSubscribed: data.isSubscribed
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Response) throw e;
|
||||
throw error(500, 'Failed to load item');
|
||||
}
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ItemView from '$lib/components/item/ItemView.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const ogTitle = $derived(data.item.title ?? `#${data.item.id} — f0ckm`);
|
||||
const ogImage = $derived(data.item.og_thumbnail);
|
||||
const ogDesc = $derived(data.item.og_description ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{ogTitle}</title>
|
||||
<meta name="description" content={ogDesc} />
|
||||
<!-- OG tags for embeds -->
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:description" content={ogDesc} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:type" content="article" />
|
||||
<!-- Twitter card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={ogDesc} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
</svelte:head>
|
||||
|
||||
<ItemView
|
||||
item={data.item}
|
||||
pagination={data.pagination}
|
||||
link={data.link}
|
||||
isSubscribed={data.isSubscribed}
|
||||
/>
|
||||
@@ -1,9 +0,0 @@
|
||||
// Stub page for /login — will be built in Sprint 2.
|
||||
// For now redirect to the legacy login page so auth still works.
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Temporary: redirect to legacy backend login until we build the Svelte login form
|
||||
throw redirect(307, 'http://localhost:1337/login');
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getRandom } from '$lib/api/items';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
try {
|
||||
const data = await getRandom(fetch);
|
||||
if (data.success && data.url) {
|
||||
throw redirect(307, data.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Response) throw e;
|
||||
}
|
||||
// Fallback: go home if no random item found
|
||||
throw redirect(307, '/');
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
throw redirect(307, 'http://localhost:1337/register');
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItems } from '$lib/api/items';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, params, locals }) => {
|
||||
const tag = decodeURIComponent(params.tag);
|
||||
const page = Number(url.searchParams.get('page') ?? 1);
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
try {
|
||||
const data = await getItems({ page, mode, tag }, fetch);
|
||||
if (data.success) {
|
||||
return {
|
||||
tag,
|
||||
items: data.items,
|
||||
pagination: data.pagination,
|
||||
total: data.total,
|
||||
link: data.link
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[/tag/${tag}] Failed to load items:`, (e as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
tag,
|
||||
items: [],
|
||||
pagination: null,
|
||||
total: 0,
|
||||
link: { main: `/tag/${encodeURIComponent(tag)}/`, path: 'p/', suffix: '' }
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let items = $state<GridItem[]>(data.items);
|
||||
let total = $state(data.total);
|
||||
|
||||
$effect(() => {
|
||||
items = data.items;
|
||||
total = data.total;
|
||||
});
|
||||
|
||||
// Live: remove items that get deleted
|
||||
// (we don't prepend new_item because it may not match this tag filter)
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
const before = items.length;
|
||||
items = items.filter(i => i.id !== d.id);
|
||||
if (items.length < before) total = Math.max(0, total - 1);
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{data.tag} — f0ckm</title>
|
||||
<meta name="description" content="Items tagged #{data.tag} on f0ckm" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
||||
|
||||
<!-- Tag header -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-tag text-primary opacity-70" aria-hidden="true"></i>
|
||||
<h1 class="text-xl font-bold">{data.tag}</h1>
|
||||
</div>
|
||||
{#if total > 0}
|
||||
<span class="badge badge-neutral font-mono text-xs">{total.toLocaleString()}</span>
|
||||
{/if}
|
||||
<a href="/" class="btn btn-ghost btn-xs ml-auto opacity-60 hover:opacity-100 gap-1">
|
||||
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
{#if items.length > 0}
|
||||
<div class="
|
||||
grid gap-1.5
|
||||
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
||||
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
||||
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
||||
">
|
||||
{#each items as item (item.id)}
|
||||
<ItemCard {item} link={data.link} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.pagination}
|
||||
<Pagination pagination={data.pagination} link={data.link} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
||||
<i class="fa-solid fa-tag text-5xl" aria-hidden="true"></i>
|
||||
<p class="text-sm">No items found for <strong>#{data.tag}</strong>.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getItem } from '$lib/api/items';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params, locals }) => {
|
||||
const tag = decodeURIComponent(params.tag);
|
||||
const itemid = Number(params.itemid);
|
||||
const mode = locals.session?.mode ?? 3;
|
||||
|
||||
if (!itemid) throw error(404, 'Not found');
|
||||
|
||||
try {
|
||||
const data = await getItem(itemid, { mode, tag }, fetch);
|
||||
if (!data.success || !data.item) throw error(404, data.message ?? 'Item not found');
|
||||
|
||||
return {
|
||||
tag,
|
||||
item: data.item,
|
||||
pagination: data.pagination,
|
||||
link: data.link,
|
||||
isSubscribed: data.isSubscribed ?? false
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
if ((e as { status?: number })?.status) throw e;
|
||||
throw error(500, 'Failed to load item');
|
||||
}
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ItemView from '$lib/components/item/ItemView.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{data.tag} — item {data.item.id} — f0ckm</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Tag breadcrumb -->
|
||||
<div class="max-w-[1600px] mx-auto px-4 pt-2">
|
||||
<div class="text-xs opacity-50 flex items-center gap-1.5">
|
||||
<a href="/" class="hover:opacity-100 transition-opacity">f0ckm</a>
|
||||
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||
<a href="/tag/{encodeURIComponent(data.tag)}" class="hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
<i class="fa-solid fa-tag text-[10px]" aria-hidden="true"></i>
|
||||
{data.tag}
|
||||
</a>
|
||||
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||
<span>#{data.item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ItemView
|
||||
item={data.item}
|
||||
pagination={data.pagination}
|
||||
link={data.link}
|
||||
isSubscribed={data.isSubscribed}
|
||||
/>
|
||||
@@ -1,3 +0,0 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
runes: ({ filename }) =>
|
||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
adapter: adapter()
|
||||
})
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Proxy API + WebSocket to the existing f0ckm backend
|
||||
'/api': {
|
||||
target: 'http://localhost:1337',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ajax': {
|
||||
target: 'http://localhost:1337',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:1337',
|
||||
ws: true
|
||||
},
|
||||
// Static media files — use exact prefix matching to avoid
|
||||
// colliding with Vite's own /src/ module paths.
|
||||
// Each rule anchored to exactly /X/ (with trailing slash).
|
||||
'^/b/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/t/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/ca/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/a/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
// /s/ — NOTE: must NOT match /src/ — regex anchors it
|
||||
'^/s/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
'^/memes/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||
// mod actions (pin, unpin, delete)
|
||||
'^/mod/': { target: 'http://localhost:1337', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
5
migrations/add_favorites_private.sql
Normal file
5
migrations/add_favorites_private.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add favorites_private column to user_options table
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';
|
||||
5
migrations/add_hide_fav_badge.sql
Normal file
5
migrations/add_hide_fav_badge.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add hide_fav_badge column to user_options table
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';
|
||||
@@ -1498,7 +1498,9 @@ 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
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/* 600–999px: sidebar is attached to the grid, same as desktop but with mobile width.
|
||||
Grid reserves space for the sidebar so content is never hidden behind it. */
|
||||
@media (min-width: 600px) and (max-width: 999px) {
|
||||
.index-layout-wrapper {
|
||||
padding-right: min(280px, 80vw);
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden .index-layout-wrapper {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Generic pagewrapper (non-index, non-item pages) also reserves space */
|
||||
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
|
||||
padding-right: min(280px, 80vw);
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden
|
||||
.pagewrapper:not(:has(.index-layout-wrapper)):not(:has(.item-layout-container)) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Restore layout-modern item stacking at 600–999px.
|
||||
Its CSS grid only activates at ≥1000px; without flex-column here
|
||||
the layout falls back to broken block flow. */
|
||||
body.layout-modern .item-layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
body.layout-modern .item-layout-container .item-main-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
body.layout-modern .item-sidebar-left {
|
||||
order: 2;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Legacy layout: 2-column grid with mobile sidebar width. */
|
||||
body.layout-legacy .item-layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) min(280px, 80vw);
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.layout-legacy.sidebar-right-hidden .item-layout-container {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (≤599px): sidebar is always a pure overlay — never reserve space for it.
|
||||
Wrappers stay at full width regardless of sidebar state; no transitions needed here.
|
||||
Item view also switches to stacked layout here (not at 999px). */
|
||||
@media (max-width: 599px) {
|
||||
/* Item view: stack content above comments/sidebar-left */
|
||||
.item-layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 600–999px: center the button in the free space left of the sidebar.
|
||||
Shift left by half the sidebar width so transform:translateX(-50%) lands
|
||||
at the center of the available area rather than the full viewport. */
|
||||
@media (min-width: 600px) and (max-width: 999px) {
|
||||
.mobile-scroll-to-top {
|
||||
left: calc(50% - min(140px, 40vw));
|
||||
transition: left 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
body.sidebar-right-hidden .mobile-scroll-to-top {
|
||||
left: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.steuerung.steuerung-icon .fa-solid {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -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 1000–1199px 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,10 +13058,8 @@ 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) {
|
||||
/* ── 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;
|
||||
@@ -12982,26 +13072,21 @@ body.layout-modern .tag-ac-input {
|
||||
transition: right 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
/* Desktop: 300px reserved space (both layouts) */
|
||||
@media (min-width: 1000px) {
|
||||
body:not(.sidebar-right-hidden) .messages-convo-page {
|
||||
right: 300px !important;
|
||||
}
|
||||
body.layout-modern:not(.sidebar-right-hidden) .messages-convo-page {
|
||||
right: 400px !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Kill the layout-legacy pagewrapper min-height:100vh that causes body overflow */
|
||||
body:has(.messages-convo-page) .pagewrapper {
|
||||
min-height: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── MOBILE (≤ 768px): position:fixed pagewrapper ─────────
|
||||
Works on mobile; locks the pagewrapper to the viewport. */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* 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%;
|
||||
@@ -13009,16 +13094,9 @@ body.layout-modern .tag-ac-input {
|
||||
}
|
||||
|
||||
body:has(.messages-convo-page) .pagewrapper {
|
||||
position: fixed;
|
||||
top: var(--navbar-h, 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-height: 0 !important;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
/* Meme Creator Styles */
|
||||
|
||||
.meme-creator-container {
|
||||
box-sizing: border-box !important;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
@@ -111,6 +112,7 @@
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
max-width: 650px;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
background: #000;
|
||||
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||
@@ -238,22 +240,49 @@ canvas#memeCanvas {
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
|
||||
/* Mobile Stacking - Simple 2-Row Layout */
|
||||
/* Mobile Stacking - Clean Flexbox Column Layout */
|
||||
@media (max-width: 950px) {
|
||||
.meme-editor-layout {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 0.6fr auto !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 20px;
|
||||
}
|
||||
.meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
grid-row: 2;
|
||||
max-width: 500px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
grid-row: 1;
|
||||
max-width: 650px !important;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
|
||||
This prevents the canvas from shrinking too much when the sidebar takes space. */
|
||||
@media (max-width: 1250px) {
|
||||
body:not(.sidebar-right-hidden) .meme-editor-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 20px;
|
||||
}
|
||||
body:not(.sidebar-right-hidden) .meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 500px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
body:not(.sidebar-right-hidden) .canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 650px !important;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
@@ -294,20 +323,19 @@ canvas#memeCanvas {
|
||||
|
||||
/* Sidebar space reservation for meme pages */
|
||||
.meme-layout-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100%;
|
||||
transition: padding-right 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
@media (min-width: 1000px) {
|
||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||
.meme-layout-wrapper {
|
||||
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
|
||||
padding-right: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapse reserved space when sidebar is hidden */
|
||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||
padding-right: 0;
|
||||
}
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
5
public/s/img/ghost_fav.svg
Normal file
5
public/s/img/ghost_fav.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#242526"/>
|
||||
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
|
||||
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -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('>') || rawText.includes('<') || rawText.includes('&')) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = rawText;
|
||||
rawText = txt.value;
|
||||
}
|
||||
const lines = rawText.split('\n');
|
||||
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
if (isNew) {
|
||||
@@ -1632,18 +1621,26 @@ class CommentSystem {
|
||||
syncLevel(roots, list, false);
|
||||
}
|
||||
|
||||
// Maximum characters to fully render in the item view per comment
|
||||
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
|
||||
// Maximum characters & lines to fully render in the item view per comment
|
||||
static get ITEM_VIEW_MAX_CHARS() { return 500; }
|
||||
static get ITEM_VIEW_MAX_LINES() { return 6; }
|
||||
|
||||
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
||||
if (!content) return '';
|
||||
|
||||
// Truncate extremely long comments before any processing
|
||||
// Truncate long comments before any processing
|
||||
let truncated = false;
|
||||
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
|
||||
if (!bypassTruncation) {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
|
||||
truncated = true;
|
||||
}
|
||||
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
console.warn('Marked.js not loaded, falling back to plain text');
|
||||
@@ -1791,9 +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, '>');
|
||||
const renderedContent = quoteEmojis
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${renderedContent}</span>`;
|
||||
}
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
renderCommentAttachments(files, content = '') {
|
||||
@@ -3336,9 +3334,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 +3608,9 @@ class CommentSystem {
|
||||
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
|
||||
// optimistic DOM insert to guarantee the button regardless of rendering path.
|
||||
_ensureTruncationButton(commentEl, rawContent) {
|
||||
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
|
||||
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||
console.log('[ensureBtn] below threshold, skipping');
|
||||
if (!rawContent) return;
|
||||
const lineCount = rawContent.split('\n').length;
|
||||
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
|
||||
return;
|
||||
}
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
@@ -3808,10 +3812,13 @@ class CommentSystem {
|
||||
toggleComments() {
|
||||
if (!this.container) return;
|
||||
|
||||
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
|
||||
if (document.body.classList.contains('layout-modern')) {
|
||||
if (window.innerWidth < 1000) return;
|
||||
if (typeof window.toggleSidebarLeft === 'function') {
|
||||
window.toggleSidebarLeft();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const layout = this.container.closest('.item-layout-container');
|
||||
const sidebar = this.container.closest('.item-sidebar-left');
|
||||
@@ -4457,20 +4464,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 +4540,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__') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4427,6 +4467,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');
|
||||
@@ -6277,8 +6321,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 +6342,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 +6584,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 +6600,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 +6672,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 +6730,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 +6760,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 +6787,11 @@ window.cancelAnimFrame = (function () {
|
||||
setTimeout(settle, 350);
|
||||
};
|
||||
|
||||
// Click on edgeZone: smoothly toggle sidebar open/closed
|
||||
edgeZone.addEventListener('click', () => {
|
||||
// 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 +6811,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 +7072,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,30 +7567,49 @@ 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)
|
||||
// Update navbar display name only if this update is for the logged-in user
|
||||
if (isSelf) {
|
||||
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 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) {
|
||||
bracket.style.display = display_name ? 'inline' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (userName) {
|
||||
const lowerHref = `/user/${userName.toLowerCase()}`;
|
||||
@@ -7479,6 +7635,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 +7682,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 +7709,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)
|
||||
@@ -8605,6 +8768,21 @@ class NotificationSystem {
|
||||
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
|
||||
const fragment = document.createDocumentFragment();
|
||||
data.favs.forEach(fav => {
|
||||
if (fav.hide_fav_badge) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'ghost-fav';
|
||||
a.setAttribute('tooltip', 'Ghost Fav');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/s/img/ghost_fav.svg';
|
||||
img.style.height = '32px';
|
||||
img.style.width = '32px';
|
||||
|
||||
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');
|
||||
@@ -8622,6 +8800,7 @@ class NotificationSystem {
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
}
|
||||
});
|
||||
|
||||
favsContainer.innerHTML = '';
|
||||
|
||||
@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
|
||||
dmGridArea = document.createElement('div');
|
||||
dmGridArea.className = 'emoji-picker-grid';
|
||||
|
||||
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
||||
let lastDmHoldTime = 0;
|
||||
const addDmTabTouch = (btn, cb, onHold) => {
|
||||
let ts = null;
|
||||
let holdTimer = null;
|
||||
let holdFired = false;
|
||||
btn.addEventListener('touchstart', (e) => {
|
||||
holdFired = false;
|
||||
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
if (onHold) {
|
||||
holdTimer = setTimeout(() => {
|
||||
holdFired = true;
|
||||
lastDmHoldTime = Date.now();
|
||||
try { navigator.vibrate?.(40); } catch(err) {}
|
||||
onHold();
|
||||
}, 500);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, { passive: true });
|
||||
|
||||
btn.addEventListener('touchmove', (e) => {
|
||||
if (!ts) return;
|
||||
const dx = Math.abs(e.touches[0].clientX - ts.x);
|
||||
const dy = Math.abs(e.touches[0].clientY - ts.y);
|
||||
if (dx > 10 || dy > 10) {
|
||||
clearTimeout(holdTimer);
|
||||
ts = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
btn.addEventListener('touchend', (e) => {
|
||||
clearTimeout(holdTimer);
|
||||
if (holdFired) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
holdFired = false;
|
||||
ts = null;
|
||||
return;
|
||||
}
|
||||
if (!ts) return;
|
||||
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
||||
ts = null;
|
||||
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
||||
});
|
||||
|
||||
btn.addEventListener('touchcancel', () => {
|
||||
clearTimeout(holdTimer);
|
||||
holdFired = false;
|
||||
ts = null;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDmFavDefault = (tabEl) => {
|
||||
const KEY = 'f0ck_emoji_default_tab';
|
||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||
if (isDefault) {
|
||||
localStorage.removeItem(KEY);
|
||||
tabEl.classList.remove('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites unset as default tab');
|
||||
} else {
|
||||
localStorage.setItem(KEY, '__favs__');
|
||||
tabEl.classList.add('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites set as default tab');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Favorites tab ──
|
||||
const favsTab = document.createElement('button');
|
||||
favsTab.className = 'ep-tab ep-tab-favs';
|
||||
@@ -1985,22 +2052,13 @@ if (window.__dmLoaded) {
|
||||
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
|
||||
favsTab.addEventListener('mousedown', e => e.preventDefault());
|
||||
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
|
||||
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
|
||||
// Right-click on fav tab → set/unset as default
|
||||
favsTab.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (navigator.maxTouchPoints > 0) return;
|
||||
const KEY = 'f0ck_emoji_default_tab';
|
||||
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||
if (isDefault) {
|
||||
localStorage.removeItem(KEY);
|
||||
favsTab.classList.remove('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites unset as default tab');
|
||||
} else {
|
||||
localStorage.setItem(KEY, '__favs__');
|
||||
favsTab.classList.add('ep-tab-default');
|
||||
window._showEmojiToast?.('Favorites set as default tab');
|
||||
}
|
||||
if (Date.now() - lastDmHoldTime < 1000) return;
|
||||
toggleDmFavDefault(favsTab);
|
||||
});
|
||||
// Mark as default if already set
|
||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
|
||||
}
|
||||
tab.addEventListener('mousedown', e => e.preventDefault());
|
||||
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
|
||||
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
|
||||
dmTabBar.appendChild(tab);
|
||||
});
|
||||
|
||||
|
||||
@@ -1000,9 +1000,10 @@
|
||||
e.stopPropagation();
|
||||
const itemId = slide.dataset.id;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1157,10 +1158,14 @@
|
||||
if (nowFaved) flashFav(slide);
|
||||
}
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/togglefav', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `postid=${id}`
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
// Sync count to server truth (handles race conditions)
|
||||
@@ -1582,9 +1587,10 @@
|
||||
e.stopPropagation();
|
||||
const itemId = slide.dataset.id;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1850,11 +1856,12 @@
|
||||
else if (item.external_board === 'gif') rating = 'nsfw';
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'x-csrf-token': window.scrollerCsrf || ''
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: item.external_media_url || item.dest,
|
||||
@@ -1966,9 +1973,10 @@
|
||||
showShareToast(info.toast);
|
||||
}
|
||||
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||
method: 'PUT',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -2119,7 +2127,12 @@
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
if (raw.includes('>') || raw.includes('<') || raw.includes('&')) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = raw;
|
||||
raw = txt.value;
|
||||
}
|
||||
const lines = raw.split('\n');
|
||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
@@ -2787,11 +2800,16 @@
|
||||
if (!content || !commentsItemId || commentsPosting) return;
|
||||
commentsPosting = true; commentSendBtn.disabled = true;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
||||
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
||||
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
|
||||
const resp = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: postBody
|
||||
});
|
||||
const data = await resp.json();
|
||||
@@ -2899,10 +2917,14 @@
|
||||
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
||||
closeSugg();
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `tagname=${encodeURIComponent(tag)}`
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
@@ -3658,7 +3680,11 @@
|
||||
if (sMarkAll) {
|
||||
sMarkAll.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/notifications/read', { method: 'POST' });
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
await fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
updateScrollerNotifBadge(0);
|
||||
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
||||
updateScrollerTabBadges(sCachedNotifs);
|
||||
@@ -3673,7 +3699,12 @@
|
||||
if (!item) return;
|
||||
const nid = item.dataset.id;
|
||||
if (nid && item.classList.contains('unread')) {
|
||||
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/notifications/${nid}/read`, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
}).catch(() => {});
|
||||
item.classList.remove('unread');
|
||||
// Update cache
|
||||
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
||||
|
||||
@@ -1119,6 +1119,68 @@
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New Dual Column Layout Toggle
|
||||
const layoutToggle = document.getElementById('use_new_layout_toggle');
|
||||
if (layoutToggle) {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const playSidebarEmojiVideos = (container) => {
|
||||
@@ -153,12 +153,6 @@
|
||||
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
||||
if (!content) return '';
|
||||
|
||||
// Truncate extremely long comments before any processing to keep the sidebar
|
||||
// fast and the DOM lean, regardless of markdown / regex complexity.
|
||||
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
|
||||
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
return escapeHtml(content)
|
||||
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||
@@ -268,9 +262,10 @@
|
||||
// Manual greentext handling — apply emoji if the user preference allows it
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||
const rendered = quoteEmojis
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,12 @@ if (!window.UserCommentSystem) {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||
const rendered = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit
|
||||
@@ -450,7 +455,7 @@ if (!window.UserCommentSystem) {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,4 +460,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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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": "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",
|
||||
"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 +539,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:",
|
||||
|
||||
@@ -165,6 +165,10 @@
|
||||
"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",
|
||||
"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 +543,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:",
|
||||
|
||||
@@ -165,6 +165,10 @@
|
||||
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
|
||||
"disable_swiping": "Swipen uitschakelen",
|
||||
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
||||
"favorites_private": "Privé favorieten",
|
||||
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
|
||||
"hide_fav_badge": "Favoriet-badge avatar verbergen",
|
||||
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
|
||||
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
||||
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
||||
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
||||
@@ -535,6 +539,7 @@
|
||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||
"no_uploads": "geen uploads gevonden",
|
||||
"no_favs": "geen favorieten",
|
||||
"private_favorites": "privé favorieten",
|
||||
"back_to_profile": "Terug naar Profiel",
|
||||
"ban_modal_title": "Gebruiker Bannen",
|
||||
"ban_modal_reason": "Reden:",
|
||||
|
||||
@@ -165,6 +165,10 @@
|
||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
||||
"favorites_private": "Private Favoriten",
|
||||
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
|
||||
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
|
||||
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
||||
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
@@ -536,6 +540,7 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||
"no_uploads": "keine Aufladierungen gefunden",
|
||||
"no_favs": "keine Favoriten",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
|
||||
@@ -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 ---
|
||||
@@ -428,7 +480,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 ---
|
||||
@@ -705,7 +768,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
|
||||
@@ -884,7 +947,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;
|
||||
|
||||
|
||||
@@ -1428,8 +1428,8 @@ export default (router, tpl) => {
|
||||
const userId = newUser[0].id;
|
||||
|
||||
await db`
|
||||
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||
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, {
|
||||
|
||||
@@ -218,9 +218,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 +232,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({
|
||||
|
||||
@@ -1066,7 +1066,7 @@ export default router => {
|
||||
}
|
||||
|
||||
const favs = await db`
|
||||
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
|
||||
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
|
||||
|
||||
@@ -315,6 +315,44 @@ 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 Username Color preference
|
||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||
const { color } = req.post;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* f0ckm API v2 — JSON endpoints for the new SvelteKit frontend.
|
||||
*
|
||||
* These routes live alongside the existing flummpress routes and are
|
||||
* purely additive — nothing in the old app is changed or removed.
|
||||
*
|
||||
* Mounted at: /api/v2/
|
||||
*/
|
||||
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
|
||||
const json = (res, body, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
export default (router, _tpl) => {
|
||||
|
||||
// ── GET /api/v2/session ───────────────────────────────────────────────────
|
||||
router.get('/api/v2/session', async (req, res) => {
|
||||
if (!req.session) {
|
||||
return json(res, { loggedIn: false, user: null });
|
||||
}
|
||||
return json(res, {
|
||||
loggedIn: true,
|
||||
user: {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
login: req.session.login,
|
||||
admin: !!req.session.admin,
|
||||
is_moderator: !!req.session.is_moderator,
|
||||
mode: req.session.mode ?? 3,
|
||||
theme: req.session.theme ?? 'f0ck',
|
||||
avatar: req.session.avatar ?? null,
|
||||
avatar_file: req.session.avatar_file ?? null,
|
||||
username_color: req.session.username_color ?? null,
|
||||
display_name: req.session.display_name ?? null,
|
||||
use_new_layout: req.session.use_new_layout ?? true,
|
||||
excluded_tags: req.session.excluded_tags ?? [],
|
||||
min_xd_score: req.session.min_xd_score ?? 0,
|
||||
uploads_remaining: req.session.uploads_remaining,
|
||||
pending_count: req.session.pending_count,
|
||||
language: req.session.language ?? null,
|
||||
disable_autoplay: !!req.session.disable_autoplay,
|
||||
disable_swiping: !!req.session.disable_swiping,
|
||||
csrf_token: req.session.csrf_token ?? ''
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /api/v2/items ─────────────────────────────────────────────────────
|
||||
router.get('/api/v2/items', async (req, res) => {
|
||||
const q = req.url.qs ?? {};
|
||||
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
|
||||
const ratingsArr = ratingsRaw
|
||||
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||
: null;
|
||||
|
||||
const mode = q.mode !== undefined ? +q.mode : req.mode;
|
||||
|
||||
try {
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: q.page ? +q.page : 1,
|
||||
mode,
|
||||
ratings: ratingsArr,
|
||||
mime: q.mime ?? (req.cookies?.mime ?? null),
|
||||
tag: q.tag ?? null,
|
||||
hall: q.hall ?? null,
|
||||
user: q.user ?? null,
|
||||
userHall: q.userHall ?? null,
|
||||
userHallOwner: q.userHallOwner ?? null,
|
||||
fav: q.fav === 'true',
|
||||
random: q.random === '1' || req.cookies?.random_mode === '1',
|
||||
strict: q.strict === '1' || q.strict === 'true' || !!req.session?.strict_mode,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id ?? null,
|
||||
exclude: req.session?.excluded_tags ?? [],
|
||||
minXdScore: req.session?.min_xd_score ?? 0,
|
||||
newer: q.newer ?? null,
|
||||
tagger: q.tagger ?? null
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return json(res, { success: false, items: [], pagination: null, total: 0, link: null });
|
||||
}
|
||||
|
||||
return json(res, {
|
||||
success: true,
|
||||
items: data.items,
|
||||
pagination: data.pagination,
|
||||
total: data.total,
|
||||
link: data.link
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[API v2] /items error:', e);
|
||||
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/v2/items/:id ─────────────────────────────────────────────────
|
||||
router.get(/^\/api\/v2\/items\/(?<itemid>\d+)$/, async (req, res) => {
|
||||
const itemid = req.params.itemid;
|
||||
const q = req.url.qs ?? {};
|
||||
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
|
||||
const ratingsArr = ratingsRaw
|
||||
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||
: null;
|
||||
|
||||
const mode = q.mode !== undefined ? +q.mode : req.mode;
|
||||
|
||||
try {
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid,
|
||||
mode,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id ?? null,
|
||||
user: q.user ?? null,
|
||||
tag: q.tag ?? null,
|
||||
hall: q.hall ?? null,
|
||||
userHall: q.userHall ?? null,
|
||||
userHallOwner: q.userHallOwner ?? null,
|
||||
mime: q.mime ?? (req.cookies?.mime ?? null),
|
||||
fav: q.fav === 'true',
|
||||
random: q.random === '1',
|
||||
strict: q.strict === '1' || !!req.session?.strict_mode,
|
||||
exclude: req.session?.excluded_tags ?? [],
|
||||
lang: req.lang ?? 'en'
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return json(res, { success: false, item: null, message: data.message }, 404);
|
||||
}
|
||||
|
||||
// Precompute hall display data (mirrors existing index route logic)
|
||||
if (data.item?.halls?.length) {
|
||||
const currentHallSlug = data.tmp?.hall
|
||||
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||
: null;
|
||||
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
|
||||
// Subscription status
|
||||
const isSubscribed = req.session
|
||||
? await f0cklib.getSubscriptionStatus(req.session.id, itemid).catch(() => false)
|
||||
: false;
|
||||
|
||||
// xD Score
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
const commentsForScore = await f0cklib.getComments(itemid, 'old', false);
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
} else {
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
|
||||
// Notify read
|
||||
if (req.session?.id) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
|
||||
return json(res, {
|
||||
success: true,
|
||||
item: data.item,
|
||||
pagination: data.pagination,
|
||||
link: data.link,
|
||||
isSubscribed
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[API v2] /items/:id error:', e);
|
||||
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/v2/random ────────────────────────────────────────────────────
|
||||
router.get('/api/v2/random', async (req, res) => {
|
||||
const ratingsRaw = req.cookies?.ratings ?? null;
|
||||
const ratingsArr = ratingsRaw
|
||||
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||
: null;
|
||||
|
||||
try {
|
||||
const data = await f0cklib.getRandom({
|
||||
mode: req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: !!req.session,
|
||||
mime: req.cookies?.mime ?? null
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return json(res, { success: false, message: data.message }, 404);
|
||||
}
|
||||
|
||||
const url = encodeURI(`${data.link.main}${data.link.path ?? ''}${data.itemid}${data.link.suffix ?? ''}`);
|
||||
return json(res, { success: true, id: data.itemid, url });
|
||||
} catch (e) {
|
||||
console.error('[API v2] /random error:', e);
|
||||
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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) {
|
||||
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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -95,7 +95,7 @@ export default (router, tpl) => {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
||||
return res.json({
|
||||
success: true,
|
||||
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||
currentPage: page,
|
||||
hasMore: tags.length === TAGS_PER_PAGE
|
||||
});
|
||||
@@ -128,6 +128,7 @@ export default (router, tpl) => {
|
||||
phrase,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
user: req.session?.user ? { user: req.session.user } : null,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'Tags',
|
||||
|
||||
154
src/index.mjs
154
src/index.mjs
@@ -49,7 +49,7 @@ const gateOptions = {
|
||||
},
|
||||
cloudflare_status: {
|
||||
status: 'ok',
|
||||
location: 'Frankfurt',
|
||||
location: cfg.websrv.private_society_gate_location || 'Frankfurt',
|
||||
name: 'Cloudflare',
|
||||
status_text: 'Working',
|
||||
},
|
||||
@@ -79,11 +79,13 @@ const nginx502Fallback = `<html>
|
||||
</html>`;
|
||||
|
||||
// Login + Register modal injected before </body>
|
||||
// This string is built at startup so it can reference cfg.recaptcha values.
|
||||
// Dynamic function so reCAPTCHA can be bypassed for .onion requests
|
||||
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
||||
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
|
||||
|
||||
const gateLoginInjection = `
|
||||
function getGateLoginInjection(req) {
|
||||
const rcEnabled = _rcEnabled && !lib.isOnionRequest(req);
|
||||
return `
|
||||
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
|
||||
|
||||
<div id="gate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;align-items:center;justify-content:center;">
|
||||
@@ -123,7 +125,7 @@ const gateLoginInjection = `
|
||||
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
|
||||
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
|
||||
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
|
||||
${_rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
|
||||
${rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
|
||||
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
|
||||
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
|
||||
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
|
||||
@@ -264,8 +266,9 @@ const gateLoginInjection = `
|
||||
if (_sb.length > 12) _sb = _sb.slice(-12);
|
||||
});
|
||||
</script>
|
||||
${_rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
|
||||
${rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// Text injected into the "What can I do?" section
|
||||
@@ -282,11 +285,40 @@ try {
|
||||
}
|
||||
|
||||
// Called on every gated request — produces a fresh page with unique Ray ID + timestamp
|
||||
// Accepts an optional request object to inject the visitor's real IP into the footer reveal.
|
||||
// Accepts an optional request object to inject the visitor's real IP into the footer reveal and dynamic host into page text/title.
|
||||
function buildGatePage(req) {
|
||||
if (!_cfRender) return nginx502Fallback;
|
||||
|
||||
let html = _cfRender({ ...gateOptions, what_can_i_do: gateSignInButton });
|
||||
let reqHost = null;
|
||||
if (req && req.headers) {
|
||||
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'];
|
||||
if (rawHost) {
|
||||
const firstHost = rawHost.split(',')[0].trim();
|
||||
let extracted = '';
|
||||
if (firstHost.startsWith('[')) {
|
||||
const closeBracket = firstHost.indexOf(']');
|
||||
extracted = closeBracket !== -1 ? firstHost.substring(1, closeBracket) : firstHost;
|
||||
} else {
|
||||
extracted = firstHost.split(':')[0].trim();
|
||||
}
|
||||
const isLocalHost = ['localhost', '127.0.0.1', '::1', 'f0ckm', '0.0.0.0', 'localhost.localdomain'].includes(extracted.toLowerCase());
|
||||
if (extracted && !isLocalHost) {
|
||||
reqHost = extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reqHost) reqHost = cfg.main.url.domain;
|
||||
|
||||
const currentGateOptions = {
|
||||
...gateOptions,
|
||||
host_status: {
|
||||
...gateOptions.host_status,
|
||||
location: reqHost,
|
||||
},
|
||||
what_can_i_do: gateSignInButton,
|
||||
};
|
||||
|
||||
let html = _cfRender(currentGateOptions);
|
||||
|
||||
// Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
|
||||
if (req) {
|
||||
@@ -305,9 +337,9 @@ function buildGatePage(req) {
|
||||
// Patch the <title> to include the domain — matches real Cloudflare behaviour
|
||||
html = html.replace(
|
||||
'<title>502: Bad Gateway</title>',
|
||||
`<title>${cfg.main.url.domain} | 502: Bad gateway</title>`
|
||||
`<title>${reqHost} | 502: Bad gateway</title>`
|
||||
);
|
||||
html = html.replace('</body>', gateLoginInjection + '\n</body>');
|
||||
html = html.replace('</body>', getGateLoginInjection(req) + '\n</body>');
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -316,7 +348,14 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
|
||||
? null
|
||||
: nginx502Fallback;
|
||||
|
||||
// Custom gate template — resolved once at boot from config
|
||||
// Set private_society_gate: "custom" and private_society_gate_template: "your-template-name" (no .html)
|
||||
const _customGateTemplate = (cfg.websrv.private_society && cfg.websrv.private_society_gate === 'custom' && cfg.websrv.private_society_gate_template)
|
||||
? String(cfg.websrv.private_society_gate_template).replace(/\.html$/i, '')
|
||||
: null;
|
||||
|
||||
if (nginx502 === null) console.log('[BOOT] Private society gate: Cloudflare dynamic mode');
|
||||
if (_customGateTemplate) console.log(`[BOOT] Private society gate: Custom template → views/${_customGateTemplate}.html (flummpress resolves extension)`);
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -456,6 +495,22 @@ process.on('uncaughtException', err => {
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Block incoming requests to .onion if Tor Hidden Service is explicitly disabled in config
|
||||
if (cfg.websrv?.enable_tor_hs === false && lib.isOnionRequest(req)) {
|
||||
res.writeHead(503).end();
|
||||
req.url.pathname = '/tor_hs_disabled_bypass';
|
||||
return;
|
||||
}
|
||||
|
||||
// Tor Onion-Location header: advertises .onion service counterpart to Tor Browser users when enabled
|
||||
if (cfg.websrv?.enable_tor_hs !== false && cfg.main?.onion && !lib.isOnionRequest(req)) {
|
||||
const p = req.url?.pathname || '/';
|
||||
const s = req.url?.search || '';
|
||||
const onionHost = cfg.main.onion.replace(/\/+$/, '');
|
||||
res.setHeader('Onion-Location', `${onionHost}${p}${s}`);
|
||||
}
|
||||
|
||||
if (isSecure) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
@@ -614,20 +669,17 @@ process.on('uncaughtException', err => {
|
||||
let _cachedRow = _scGet(_sessionHash);
|
||||
let user;
|
||||
if (_cachedRow) {
|
||||
// Shallow-clone the cached row so per-request mutations (theme, uploads_remaining,
|
||||
// pending_count, etc.) don't corrupt the shared cache entry.
|
||||
user = [{ ..._cachedRow }];
|
||||
user = [_cachedRow];
|
||||
} else {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${_sessionHash}
|
||||
limit 1
|
||||
`;
|
||||
// Store a plain-object clone — keeps the cache entry free of per-request fields.
|
||||
if (user.length > 0) _scSet(_sessionHash, { ...user[0] });
|
||||
if (user.length > 0) _scSet(_sessionHash, user[0]);
|
||||
}
|
||||
|
||||
if (user.length === 0) {
|
||||
@@ -804,18 +856,21 @@ process.on('uncaughtException', err => {
|
||||
req.url.pathname = '/private_society_bypass';
|
||||
return;
|
||||
}
|
||||
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate)
|
||||
if (req.url.pathname !== '/') {
|
||||
// For page requests, serve the gate for all paths (custom template or fallback)
|
||||
if (_customGateTemplate) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(tpl.render(_customGateTemplate, {}, req));
|
||||
} else if (req.url.pathname !== '/') {
|
||||
// Non-home paths: always show the static/cloudflare gate
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
||||
req.url.pathname = '/private_society_bypass';
|
||||
return;
|
||||
}
|
||||
// Homepage: in cloudflare dynamic mode serve gate directly; otherwise fall through to template
|
||||
if (nginx502 === null) {
|
||||
} else if (nginx502 === null) {
|
||||
// Homepage in cloudflare dynamic mode: serve the CF gate directly
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
|
||||
req.url.pathname = '/private_society_bypass';
|
||||
} else {
|
||||
// Homepage in plain nginx mode: fall through to index.html template
|
||||
return;
|
||||
}
|
||||
req.url.pathname = '/private_society_bypass';
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1295,6 +1350,7 @@ process.on('uncaughtException', err => {
|
||||
show_mime_picker: cfg.websrv.show_mime_picker !== false,
|
||||
private_society: cfg.websrv.private_society || false,
|
||||
private_society_gate: cfg.websrv.private_society_gate || '',
|
||||
private_society_gate_location: cfg.websrv.private_society_gate_location || 'Frankfurt',
|
||||
show_content_warning: cfg.websrv.show_content_warning !== false,
|
||||
web_url_upload: !!cfg.websrv.web_url_upload,
|
||||
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
|
||||
@@ -1406,26 +1462,39 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve per-request recaptcha preference (disabled for .onion requests or when passed false)
|
||||
const effectiveReq = req || (data && data.req);
|
||||
const defaultRecaptcha = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
||||
let perRequestRecaptcha = defaultRecaptcha;
|
||||
|
||||
if (effectiveReq && lib.isOnionRequest(effectiveReq)) {
|
||||
perRequestRecaptcha = false;
|
||||
} else if (data && typeof data.recaptcha_enabled === 'boolean') {
|
||||
perRequestRecaptcha = data.recaptcha_enabled;
|
||||
}
|
||||
|
||||
// Build data: globals first, then caller-supplied data, then per-request i18n last
|
||||
// so t/lang always reflect the user's language, not the site default.
|
||||
// ALSO mutate globals.t and globals.lang: flummpress spreads this.#globals LAST
|
||||
// ALSO mutate globals.t, globals.lang, globals.recaptcha_enabled: flummpress spreads this.#globals LAST
|
||||
// inside render(), so globals must carry the per-request values too.
|
||||
globals.t = perRequestT;
|
||||
globals.lang = perRequestLang;
|
||||
globals.recaptcha_enabled = perRequestRecaptcha;
|
||||
|
||||
// Resolve per-request infobox preference
|
||||
// Guests always get false — the alternative infobox is a logged-in user preference only
|
||||
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
|
||||
? req.session.use_alternative_infobox
|
||||
: (req && !req.session
|
||||
const activeReq = req || effectiveReq;
|
||||
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
|
||||
? activeReq.session.use_alternative_infobox
|
||||
: (activeReq && !activeReq.session
|
||||
? false
|
||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||
? data.user_alternative_infobox
|
||||
: (cfg.websrv.user_alternative_infobox !== false)));
|
||||
|
||||
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean')
|
||||
? req.session.use_alternative_steuerung
|
||||
: (req && !req.session
|
||||
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
|
||||
? activeReq.session.use_alternative_steuerung
|
||||
: (activeReq && !activeReq.session
|
||||
? false
|
||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||
? data.user_alternative_steuerung
|
||||
@@ -1434,11 +1503,12 @@ process.on('uncaughtException', err => {
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
lang: perRequestLang,
|
||||
recaptcha_enabled: perRequestRecaptcha,
|
||||
user_alternative_infobox: useAltInfobox,
|
||||
user_alternative_steuerung: useAltSteuerung,
|
||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
||||
? req.session.comment_display_mode
|
||||
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
|
||||
? activeReq.session.comment_display_mode
|
||||
: (data && typeof data.comment_display_mode === 'number'
|
||||
? data.comment_display_mode
|
||||
: (cfg.websrv.default_comment_display_mode || 0))
|
||||
@@ -1450,17 +1520,19 @@ process.on('uncaughtException', err => {
|
||||
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
|
||||
}
|
||||
|
||||
if (req) {
|
||||
if (req.mode !== undefined) data.mode = req.mode;
|
||||
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
||||
if (!data.url) data.url = req.url;
|
||||
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false;
|
||||
data.user_logged_in_bool = !!req.session;
|
||||
data.csrf_token = req.session?.csrf_token || '';
|
||||
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
if (activeReq) {
|
||||
data.recaptcha_enabled = perRequestRecaptcha;
|
||||
if (activeReq.mode !== undefined) data.mode = activeReq.mode;
|
||||
data.theme = activeReq.theme || activeReq.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
||||
if (!data.url) data.url = activeReq.url;
|
||||
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
|
||||
data.user_logged_in_bool = !!activeReq.session;
|
||||
data.csrf_token = activeReq.session?.csrf_token || '';
|
||||
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
||||
} else {
|
||||
data.recaptcha_enabled = perRequestRecaptcha;
|
||||
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
||||
data.user_strict_bool = false;
|
||||
data.user_logged_in_bool = false;
|
||||
|
||||
35
views/_gate_template.html
Normal file
35
views/_gate_template.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html theme="@if(typeof theme !== 'undefined'){{ theme }}@else{{ default_theme || 'amoled' }}@endif">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ domain }}</title>
|
||||
<link rel="icon" type="image/gif" href="/s/img/favicon.gif" />
|
||||
<link rel="stylesheet" id="injected-gate-styles" href="/s/css/f0ckm.css?v={{ ts }}">
|
||||
</head>
|
||||
<body class="{{ default_layout === 'legacy' ? 'layout-legacy' : 'layout-modern' }}">
|
||||
|
||||
<div class="wrapper" style="text-align: center;">
|
||||
<h2>Example Gate</h2>
|
||||
<button type="button" onclick="openLoginGate();">Login</button>
|
||||
|
||||
<div id="gate-container" style="display:none;">
|
||||
@include(snippets/navbar)
|
||||
@include(snippets/footer)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openLoginGate() {
|
||||
document.getElementById('gate-container').style.display = 'block';
|
||||
const tryOpen = () => {
|
||||
const m = document.getElementById('login-modal');
|
||||
if (m) { m.style.display = 'flex'; }
|
||||
else { setTimeout(tryOpen, 50); }
|
||||
};
|
||||
tryOpen();
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -319,7 +319,7 @@
|
||||
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -339,7 +339,7 @@
|
||||
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
|
||||
var res = await fetch('/api/v2/admin/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -359,7 +359,7 @@
|
||||
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||
var res = await fetch('/api/v2/admin/unban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -379,7 +379,7 @@
|
||||
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -398,7 +398,7 @@
|
||||
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -422,7 +422,7 @@
|
||||
ModAction.confirm('Set Display Name', hint, async (newName) => {
|
||||
var res = await fetch('/api/v2/admin/users/set-display-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, display_name: newName || '' })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -457,7 +457,7 @@
|
||||
ModAction.confirm('Unlock Layout', 'Unlock comment layout for <strong>' + escHTML(userName) + '</strong>? They will be able to change it again.', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, lock: false })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -481,7 +481,7 @@
|
||||
var mode = document.getElementById('force-mode-select').value;
|
||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -616,7 +616,7 @@
|
||||
try {
|
||||
var res = await fetch('/api/v2/admin/users/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ username, email: email || null, password, role })
|
||||
});
|
||||
var data = await res.json();
|
||||
@@ -662,7 +662,7 @@
|
||||
var role = selectEl.value;
|
||||
var res = await fetch('/api/v2/admin/users/set-role', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, role })
|
||||
});
|
||||
var data = await res.json();
|
||||
|
||||
@@ -197,7 +197,11 @@
|
||||
</span>
|
||||
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge)
|
||||
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
|
||||
@else
|
||||
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
|
||||
@endif
|
||||
@endeach
|
||||
</span>
|
||||
@if(enable_xd_score && item.xd_score > 0)
|
||||
@@ -296,7 +300,7 @@
|
||||
@if(item.src.short)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
||||
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
|
||||
@@ -157,7 +157,11 @@
|
||||
</div>
|
||||
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge)
|
||||
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
|
||||
@else
|
||||
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
|
||||
@endif
|
||||
@endeach
|
||||
</span>
|
||||
|
||||
@@ -238,7 +242,7 @@
|
||||
@if(item.src.short)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
||||
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
|
||||
|
||||
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
|
||||
@media (max-width: 950px) {
|
||||
@media (max-width: 1250px) {
|
||||
/* Target all possible shell triggers */
|
||||
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
|
||||
height: auto !important;
|
||||
|
||||
@@ -320,6 +320,20 @@
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
|
||||
<span>{{ t('settings.favorites_private') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
|
||||
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
|
||||
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
|
||||
<span>{{ t('settings.hide_fav_badge') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
|
||||
<script>(function(){var h=document.documentElement;if(localStorage.getItem('hideItemRatings')==='true')h.classList.add('hide-item-ratings-active');if(localStorage.getItem('blurNsfw')==='true')h.classList.add('blur-nsfw-active');if(localStorage.getItem('blurNsfl')==='true')h.classList.add('blur-nsfl-active');if(localStorage.getItem('blurSfw')==='true')h.classList.add('blur-sfw-active');if(localStorage.getItem('blurUntagged')==='true')h.classList.add('blur-untagged-active');if(localStorage.getItem('blurDetail')!=='false')h.classList.add('blur-detail-active');if(localStorage.getItem('imageExpandOnClick')!=='false')h.classList.add('image-expand-active');})();</script>
|
||||
<style>
|
||||
html { background-color: #000; color: #fff; }
|
||||
@if(session && session.font)
|
||||
@@ -22,6 +23,7 @@
|
||||
}
|
||||
@endif
|
||||
:root {
|
||||
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
|
||||
@if(session && session.font)
|
||||
--font: 'CustomUserFont', monospace !important;
|
||||
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="profile_head_username">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
|
||||
<div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container">
|
||||
<span id="nav-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)⚡ @elseif(user.is_moderator)🛡 @endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
|
||||
<span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)⚡ @elseif(user.is_moderator)🛡 @endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
|
||||
@if(session && session.id === user.user_id)
|
||||
<button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
@@ -142,6 +142,14 @@
|
||||
</div>
|
||||
@if(!user.is_ghost)
|
||||
<div class="favs">
|
||||
@if(favs && favs.is_private)
|
||||
<div class="favs-header">
|
||||
{{ t('profile.favs_label') }}
|
||||
</div>
|
||||
<div class="private-favs-msg" style="text-align: center;">
|
||||
{{ t('profile.private_favorites') }}
|
||||
</div>
|
||||
@else
|
||||
<div class="favs-header">
|
||||
{{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a>
|
||||
</div>
|
||||
@@ -164,6 +172,7 @@
|
||||
@else
|
||||
{{ t('profile.no_favs') }}
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -509,18 +518,27 @@
|
||||
nameContainer.style.display = 'flex';
|
||||
nameEditContainer.style.display = 'none';
|
||||
|
||||
const nameSpan = document.getElementById('nav-display-name');
|
||||
if (nameSpan) {
|
||||
nameSpan.style.color = color;
|
||||
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
|
||||
let node = nameSpan.childNodes[i];
|
||||
const updateNameNode = (el, text) => {
|
||||
if (!el) return;
|
||||
for (let i = el.childNodes.length - 1; i >= 0; i--) {
|
||||
let node = el.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] : '') + (displayName ? displayName : '{{ user.user }}');
|
||||
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
||||
node.nodeValue = (match ? match[1] : '') + text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const targetName = displayName ? displayName : '{{ user.user }}';
|
||||
const profileSpan = document.getElementById('profile-display-name');
|
||||
if (profileSpan) {
|
||||
profileSpan.style.color = color;
|
||||
updateNameNode(profileSpan, targetName);
|
||||
}
|
||||
const navSpan = document.getElementById('nav-display-name');
|
||||
if (navSpan) {
|
||||
navSpan.style.color = color;
|
||||
updateNameNode(navSpan, targetName);
|
||||
}
|
||||
const bracket = document.getElementById('username-bracket');
|
||||
if (bracket) {
|
||||
|
||||
Reference in New Issue
Block a user