Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1324512ebf |
@@ -1,9 +1,9 @@
|
|||||||
POSTGRES_USER=f0ckm
|
POSTGRES_USER=f0ckm
|
||||||
POSTGRES_DB=f0ckm
|
POSTGRES_DB=f0ckm
|
||||||
POSTGRES_PASSWORD=f0ckm
|
POSTGRES_PASSWORD=f0ckm
|
||||||
# --- Nginx & Tor Profiles (Optional) ---
|
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
||||||
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
|
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
||||||
# COMPOSE_PROFILES=tor
|
# COMPOSE_PROFILES=f0ckm-nginx
|
||||||
#
|
#
|
||||||
# VIRTUAL_HOST=yourdomain.com
|
# VIRTUAL_HOST=yourdomain.com
|
||||||
# LETSENCRYPT_HOST=yourdomain.com
|
# LETSENCRYPT_HOST=yourdomain.com
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,4 +18,3 @@ public/tag_cache
|
|||||||
config.json
|
config.json
|
||||||
bundle.css
|
bundle.css
|
||||||
public/s/fonts/impact.woff
|
public/s/fonts/impact.woff
|
||||||
f0ck-svelte/*
|
|
||||||
20
README.md
20
README.md
@@ -78,23 +78,3 @@ now visit http://localhost:1337 in your browser, you can develop without needing
|
|||||||
## NGINX
|
## NGINX
|
||||||
|
|
||||||
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
||||||
|
|
||||||
## Tor Hidden Service (.onion)
|
|
||||||
|
|
||||||
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
|
|
||||||
|
|
||||||
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat ./f0ckm-data/tor/f0ckm_hs/hostname
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automatic Onion-Location Header
|
|
||||||
|
|
||||||
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"main": {
|
|
||||||
"onion": "http://yourgeneratedaddress.onion"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
11
build.bash
11
build.bash
@@ -16,7 +16,7 @@ fi
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
TARGET="master"
|
TARGET="master"
|
||||||
CACHE_FLAG=""
|
CACHE_FLAG=""
|
||||||
CUSTOM_BRANCH=""
|
BRANCH_TARGET=""
|
||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case $arg in
|
case $arg in
|
||||||
@@ -27,8 +27,8 @@ for arg in "$@"; do
|
|||||||
CACHE_FLAG="--no-cache"
|
CACHE_FLAG="--no-cache"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
if [ -z "$CUSTOM_BRANCH" ]; then
|
if [ -z "$BRANCH_TARGET" ]; then
|
||||||
CUSTOM_BRANCH=$arg
|
BRANCH_TARGET=$arg
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -43,10 +43,7 @@ fi
|
|||||||
# Set Image name and branch defaults
|
# Set Image name and branch defaults
|
||||||
IMAGE_NAME="f0ckm"
|
IMAGE_NAME="f0ckm"
|
||||||
|
|
||||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
if [ "$TARGET" == "master" ]; then
|
||||||
BRANCH_TARGET="$CUSTOM_BRANCH"
|
|
||||||
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
|
|
||||||
elif [ "$TARGET" == "master" ]; then
|
|
||||||
echo "--- PRODUCTION DEPLOYMENT (master) ---"
|
echo "--- PRODUCTION DEPLOYMENT (master) ---"
|
||||||
BRANCH_TARGET="master"
|
BRANCH_TARGET="master"
|
||||||
elif [ "$TARGET" == "stg" ]; then
|
elif [ "$TARGET" == "stg" ]; then
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Tor configuration for f0ckm
|
|
||||||
|
|
||||||
# Enable SOCKS proxy for internal container & external connections
|
|
||||||
SocksPort 0.0.0.0:9050
|
|
||||||
|
|
||||||
# Tor Hidden Service (v3 Onion Service) configuration
|
|
||||||
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
|
|
||||||
HiddenServiceDir /var/lib/tor/f0ckm_hs/
|
|
||||||
HiddenServicePort 80 f0ckm:1337
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
"domain": "example.com",
|
"domain": "example.com",
|
||||||
"regex": "example\\.com"
|
"regex": "example\\.com"
|
||||||
},
|
},
|
||||||
"onion": "http://your-onion-address.onion",
|
|
||||||
"socks": "socks5://127.0.0.1:9050",
|
"socks": "socks5://127.0.0.1:9050",
|
||||||
"mail": "admin@example.com",
|
"mail": "admin@example.com",
|
||||||
"maxfilesize": 104857600,
|
"maxfilesize": 104857600,
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
"show_koepfe": false,
|
"show_koepfe": false,
|
||||||
"hide_sidebar_default": true,
|
"hide_sidebar_default": true,
|
||||||
"koepfe": [],
|
"koepfe": [],
|
||||||
"enable_tor_hs": true,
|
|
||||||
"enable_global_chat": true,
|
"enable_global_chat": true,
|
||||||
"enable_danmaku": true,
|
"enable_danmaku": true,
|
||||||
"private_messages": true,
|
"private_messages": true,
|
||||||
@@ -134,8 +132,6 @@
|
|||||||
"open_registration_require_mail_andor_token": false,
|
"open_registration_require_mail_andor_token": false,
|
||||||
"private_society": false,
|
"private_society": false,
|
||||||
"private_society_gate": "cloudflare",
|
"private_society_gate": "cloudflare",
|
||||||
"private_society_gate_location": "Frankfurt",
|
|
||||||
"private_society_gate_template": "_gate_template",
|
|
||||||
"public_nsfw": false,
|
"public_nsfw": false,
|
||||||
"paths": {
|
"paths": {
|
||||||
"images": "/b",
|
"images": "/b",
|
||||||
|
|||||||
@@ -49,14 +49,9 @@ services:
|
|||||||
tor:
|
tor:
|
||||||
image: dockurr/tor
|
image: dockurr/tor
|
||||||
container_name: tor
|
container_name: tor
|
||||||
profiles:
|
|
||||||
- tor
|
|
||||||
ports:
|
ports:
|
||||||
- "9050:9050"
|
- "9050:9050"
|
||||||
- "8118:8118"
|
- "8118:8118"
|
||||||
volumes:
|
|
||||||
- ./config/tor:/etc/tor:ro
|
|
||||||
- ./f0ckm-data/tor:/var/lib/tor:Z
|
|
||||||
networks:
|
networks:
|
||||||
- f0ckm-net
|
- f0ckm-net
|
||||||
restart: always
|
restart: always
|
||||||
@@ -104,8 +99,6 @@ services:
|
|||||||
container_name: f0ckm-nginx
|
container_name: f0ckm-nginx
|
||||||
profiles:
|
profiles:
|
||||||
- f0ckm-nginx
|
- f0ckm-nginx
|
||||||
environment:
|
|
||||||
- ENABLE_IPV6=true
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
23
f0ck-svelte/.gitignore
vendored
Normal file
23
f0ck-svelte/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
f0ck-svelte/.npmrc
Normal file
1
f0ck-svelte/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
f0ck-svelte/.vscode/extensions.json
vendored
Normal file
3
f0ck-svelte/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
42
f0ck-svelte/README.md
Normal file
42
f0ck-svelte/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.16.3 create --template minimal --types ts --install npm .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
2486
f0ck-svelte/package-lock.json
generated
Normal file
2486
f0ck-svelte/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
f0ck-svelte/package.json
Normal file
28
f0ck-svelte/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "f0ck-svelte",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
|
"@sveltejs/kit": "^2.63.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"daisyui": "^5.6.18",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
f0ck-svelte/src/app.css
Normal file
77
f0ck-svelte/src/app.css
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: f0ck --default, light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* f0ck dark theme — AMOLED-inspired */
|
||||||
|
--color-base-100: oklch(10% 0.02 265);
|
||||||
|
--color-base-200: oklch(13% 0.02 265);
|
||||||
|
--color-base-300: oklch(16% 0.02 265);
|
||||||
|
--color-base-content: oklch(92% 0.01 265);
|
||||||
|
|
||||||
|
--color-primary: oklch(55% 0.22 265);
|
||||||
|
--color-primary-content: oklch(98% 0.01 265);
|
||||||
|
|
||||||
|
--color-secondary: oklch(60% 0.18 300);
|
||||||
|
--color-secondary-content: oklch(98% 0.01 265);
|
||||||
|
|
||||||
|
--color-accent: oklch(72% 0.15 290);
|
||||||
|
--color-accent-content: oklch(10% 0.02 265);
|
||||||
|
|
||||||
|
--color-neutral: oklch(20% 0.02 265);
|
||||||
|
--color-neutral-content: oklch(85% 0.01 265);
|
||||||
|
|
||||||
|
--color-info: oklch(70% 0.15 220);
|
||||||
|
--color-success: oklch(70% 0.15 160);
|
||||||
|
--color-warning: oklch(78% 0.18 80);
|
||||||
|
--color-error: oklch(63% 0.22 25);
|
||||||
|
|
||||||
|
/* Rating colors */
|
||||||
|
--color-sfw: oklch(70% 0.15 160);
|
||||||
|
--color-nsfw: oklch(65% 0.20 25);
|
||||||
|
--color-nsfl: oklch(60% 0.22 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global resets & base styles ───────────────────────────────────────── */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: oklch(30% 0.02 265); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: oklch(40% 0.02 265); }
|
||||||
|
|
||||||
|
/* ── Rating badge utility classes ─────────────────────────────────────── */
|
||||||
|
.badge-sfw { background: oklch(70% 0.15 160 / 20%); color: oklch(70% 0.15 160); border-color: oklch(70% 0.15 160 / 40%); }
|
||||||
|
.badge-nsfw { background: oklch(65% 0.20 25 / 20%); color: oklch(65% 0.20 25); border-color: oklch(65% 0.20 25 / 40%); }
|
||||||
|
.badge-nsfl { background: oklch(55% 0.22 0 / 20%); color: oklch(55% 0.22 0); border-color: oklch(55% 0.22 0 / 40%); }
|
||||||
|
.badge-unt { background: oklch(55% 0.02 265 / 20%); color: oklch(65% 0.02 265); border-color: oklch(55% 0.02 265 / 40%); }
|
||||||
|
|
||||||
|
/* ── Grid thumb skeleton pulse ─────────────────────────────────────────── */
|
||||||
|
@keyframes thumb-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
.thumb-skeleton { animation: thumb-pulse 1.5s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ── Fade-in for navigations ───────────────────────────────────────────── */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.page-enter { animation: fade-in 0.15s ease-out; }
|
||||||
21
f0ck-svelte/src/app.d.ts
vendored
Normal file
21
f0ck-svelte/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
import type { SessionUser } from '$lib/types/session';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
code?: number;
|
||||||
|
}
|
||||||
|
interface Locals {
|
||||||
|
session: SessionUser | null;
|
||||||
|
}
|
||||||
|
interface PageData {
|
||||||
|
session: SessionUser | null;
|
||||||
|
}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
f0ck-svelte/src/app.html
Normal file
12
f0ck-svelte/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
f0ck-svelte/src/hooks.server.ts
Normal file
41
f0ck-svelte/src/hooks.server.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side hook: read session from the existing f0ckm backend
|
||||||
|
* by forwarding the session cookie on every SSR request.
|
||||||
|
*
|
||||||
|
* This keeps auth as a single source of truth in the backend.
|
||||||
|
*/
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
// Forward cookies to the backend session endpoint
|
||||||
|
const cookieHeader = event.request.headers.get('cookie') ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiBase(event)}/api/v2/session`, {
|
||||||
|
headers: { cookie: cookieHeader }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
event.locals.session = data.loggedIn ? data.user : null;
|
||||||
|
} else {
|
||||||
|
event.locals.session = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend not reachable (dev without backend running)
|
||||||
|
event.locals.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the API base URL for server-side fetches.
|
||||||
|
* In production the SvelteKit server and the API backend run on separate ports.
|
||||||
|
* The VITE_API_URL env var (or API_URL for SSR) sets the backend address.
|
||||||
|
*/
|
||||||
|
function getApiBase(event: Parameters<Handle>[0]['event']): string {
|
||||||
|
// SSR: use internal backend address (avoids going through Nginx proxy)
|
||||||
|
const internal = process.env.API_URL ?? 'http://localhost:1337';
|
||||||
|
return internal;
|
||||||
|
}
|
||||||
132
f0ck-svelte/src/lib/api/actions.ts
Normal file
132
f0ck-svelte/src/lib/api/actions.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Comment } from '$lib/types/comment';
|
||||||
|
import { csrfFetch } from '$lib/utils/csrfFetch';
|
||||||
|
|
||||||
|
// ── Comments ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CommentsResponse {
|
||||||
|
success: boolean;
|
||||||
|
comments: Comment[];
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getComments(
|
||||||
|
itemId: number,
|
||||||
|
fetchFn = fetch
|
||||||
|
): Promise<CommentsResponse> {
|
||||||
|
const res = await fetchFn(`/api/comments/${itemId}`);
|
||||||
|
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postComment(payload: {
|
||||||
|
itemId: number;
|
||||||
|
content: string;
|
||||||
|
replyTo?: number | null;
|
||||||
|
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
item_id: String(payload.itemId),
|
||||||
|
content: payload.content,
|
||||||
|
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
|
||||||
|
});
|
||||||
|
const res = await csrfFetch('/api/comments', { method: 'POST', body });
|
||||||
|
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(
|
||||||
|
commentId: number
|
||||||
|
): Promise<{ success: boolean; msg?: string }> {
|
||||||
|
// Backend uses POST /api/comments/:id/delete (not DELETE method)
|
||||||
|
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FavResponse {
|
||||||
|
success: boolean;
|
||||||
|
itemid: number;
|
||||||
|
favs: Array<{
|
||||||
|
user: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
avatar_file: string | null;
|
||||||
|
username_color: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleFav(itemId: number): Promise<FavResponse> {
|
||||||
|
const body = new URLSearchParams({ postid: String(itemId) });
|
||||||
|
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
|
||||||
|
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tags ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TagsResponse {
|
||||||
|
success: boolean;
|
||||||
|
tags: import('$lib/types/item').Tag[];
|
||||||
|
msg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||||
|
const body = new URLSearchParams({ tagname });
|
||||||
|
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
|
||||||
|
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||||
|
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CycleRatingResponse {
|
||||||
|
success: boolean;
|
||||||
|
rating_tag_id: number;
|
||||||
|
rating_label: 'SFW' | 'NSFW' | 'NSFL';
|
||||||
|
rating_class: 'sfw' | 'nsfw' | 'nsfl';
|
||||||
|
msg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
|
||||||
|
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
|
||||||
|
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
|
||||||
|
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!res.ok) throw new Error('suggestTags failed');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item management (mod/admin) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function deleteItem(
|
||||||
|
itemId: number,
|
||||||
|
reason = ''
|
||||||
|
): Promise<{ success: boolean; msg?: string }> {
|
||||||
|
const body = new URLSearchParams({ postid: String(itemId), reason });
|
||||||
|
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
|
||||||
|
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||||
|
// Backend reads id from query string: req.url.qs.id
|
||||||
|
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||||
|
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
29
f0ck-svelte/src/lib/api/auth.ts
Normal file
29
f0ck-svelte/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { SessionResponse } from '$lib/types/session';
|
||||||
|
|
||||||
|
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
|
||||||
|
const res = await fetchFn('/api/v2/session');
|
||||||
|
if (!res.ok) return { loggedIn: false, user: null };
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
kmsi = false
|
||||||
|
): Promise<{ success: boolean; msg?: string }> {
|
||||||
|
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
|
||||||
|
const res = await fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await fetch('/logout', { method: 'POST', credentials: 'include' });
|
||||||
|
}
|
||||||
96
f0ck-svelte/src/lib/api/items.ts
Normal file
96
f0ck-svelte/src/lib/api/items.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
|
||||||
|
|
||||||
|
export interface GetItemsParams {
|
||||||
|
page?: number;
|
||||||
|
mode?: number;
|
||||||
|
ratings?: string;
|
||||||
|
mime?: string;
|
||||||
|
tag?: string;
|
||||||
|
hall?: string;
|
||||||
|
user?: string;
|
||||||
|
fav?: boolean;
|
||||||
|
random?: boolean;
|
||||||
|
strict?: boolean;
|
||||||
|
min_xd?: number;
|
||||||
|
newer?: number;
|
||||||
|
tagger?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page of grid items.
|
||||||
|
* Called from +page.ts (server-side) and from the client for infinite-scroll.
|
||||||
|
*/
|
||||||
|
export async function getItems(
|
||||||
|
params: GetItemsParams,
|
||||||
|
fetchFn: typeof fetch = fetch
|
||||||
|
): Promise<ItemsResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.page) qs.set('page', String(params.page));
|
||||||
|
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||||
|
if (params.ratings) qs.set('ratings', params.ratings);
|
||||||
|
if (params.mime) qs.set('mime', params.mime);
|
||||||
|
if (params.tag) qs.set('tag', params.tag);
|
||||||
|
if (params.hall) qs.set('hall', params.hall);
|
||||||
|
if (params.user) qs.set('user', params.user);
|
||||||
|
if (params.fav) qs.set('fav', 'true');
|
||||||
|
if (params.random) qs.set('random', '1');
|
||||||
|
if (params.strict) qs.set('strict', '1');
|
||||||
|
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
|
||||||
|
if (params.newer) qs.set('newer', String(params.newer));
|
||||||
|
if (params.tagger) qs.set('tagger', params.tagger);
|
||||||
|
|
||||||
|
const res = await fetchFn(`/api/v2/items?${qs}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetItemParams {
|
||||||
|
mode?: number;
|
||||||
|
ratings?: string;
|
||||||
|
tag?: string;
|
||||||
|
hall?: string;
|
||||||
|
user?: string;
|
||||||
|
fav?: boolean;
|
||||||
|
random?: boolean;
|
||||||
|
strict?: boolean;
|
||||||
|
mime?: string;
|
||||||
|
userHall?: string;
|
||||||
|
userHallOwner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single item with its neighbours and metadata.
|
||||||
|
*/
|
||||||
|
export async function getItem(
|
||||||
|
id: number | string,
|
||||||
|
params: GetItemParams = {},
|
||||||
|
fetchFn: typeof fetch = fetch
|
||||||
|
): Promise<ItemResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||||
|
if (params.ratings) qs.set('ratings', params.ratings);
|
||||||
|
if (params.tag) qs.set('tag', params.tag);
|
||||||
|
if (params.hall) qs.set('hall', params.hall);
|
||||||
|
if (params.user) qs.set('user', params.user);
|
||||||
|
if (params.fav) qs.set('fav', 'true');
|
||||||
|
if (params.random) qs.set('random', '1');
|
||||||
|
if (params.strict) qs.set('strict', '1');
|
||||||
|
if (params.mime) qs.set('mime', params.mime);
|
||||||
|
if (params.userHall) qs.set('userHall', params.userHall);
|
||||||
|
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
|
||||||
|
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : '';
|
||||||
|
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random item ID (optionally within a context).
|
||||||
|
* Returns the JSON response; caller should navigate to response.url.
|
||||||
|
*/
|
||||||
|
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
|
||||||
|
const res = await fetchFn('/api/v2/random');
|
||||||
|
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
1
f0ck-svelte/src/lib/assets/favicon.svg
Normal file
1
f0ck-svelte/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getComments, postComment, deleteComment } from '$lib/api/actions';
|
||||||
|
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||||
|
import { flash } from '$lib/stores/flash';
|
||||||
|
import { timeAgo, formatDate } from '$lib/utils/timeago';
|
||||||
|
import { onSSE } from '$lib/stores/sse';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type { Comment } from '$lib/types/comment';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: number;
|
||||||
|
}
|
||||||
|
let { itemId }: Props = $props();
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
let comments = $state<Comment[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let text = $state('');
|
||||||
|
let replyTo = $state<Comment | null>(null);
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// ── Load ──────────────────────────────────────────────────────────────────
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const data = await getComments(itemId);
|
||||||
|
comments = data.comments ?? [];
|
||||||
|
} catch {
|
||||||
|
error = 'Failed to load comments.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on mount and when itemId changes
|
||||||
|
$effect(() => { load(); });
|
||||||
|
|
||||||
|
// ── Live updates via SSE ──────────────────────────────────────────────────
|
||||||
|
const unsubSSE = onSSE((event) => {
|
||||||
|
if (event.type !== 'comments') return;
|
||||||
|
const d = event.data as { item_id: number; type?: string };
|
||||||
|
if (d.item_id !== itemId) return;
|
||||||
|
// Reload the full comment list on any comment activity for this item
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
onDestroy(unsubSSE);
|
||||||
|
|
||||||
|
// ── Post comment ──────────────────────────────────────────────────────────
|
||||||
|
async function submit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!text.trim()) return;
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const res = await postComment({
|
||||||
|
itemId,
|
||||||
|
content: text.trim(),
|
||||||
|
replyTo: replyTo?.id ?? null
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
text = '';
|
||||||
|
replyTo = null;
|
||||||
|
await load();
|
||||||
|
flash('Comment posted', 'success');
|
||||||
|
} else {
|
||||||
|
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete comment ────────────────────────────────────────────────────────
|
||||||
|
async function handleDelete(comment: Comment) {
|
||||||
|
if (!confirm('Delete this comment?')) return;
|
||||||
|
try {
|
||||||
|
const res = await deleteComment(comment.id);
|
||||||
|
if (res.success) {
|
||||||
|
await load();
|
||||||
|
flash('Deleted', 'success');
|
||||||
|
} else {
|
||||||
|
flash(res.msg ?? 'Failed to delete', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDelete(comment: Comment) {
|
||||||
|
return $session && ($isModOrAdmin || $session.user === comment.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarSrc(comment: Comment): string {
|
||||||
|
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
|
||||||
|
if (comment.avatar) return `/t/${comment.avatar}.webp`;
|
||||||
|
return '/a/default.png';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
|
||||||
|
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
|
||||||
|
</span>
|
||||||
|
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
|
||||||
|
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment form -->
|
||||||
|
{#if $session}
|
||||||
|
<form onsubmit={submit} class="flex flex-col gap-2">
|
||||||
|
{#if replyTo}
|
||||||
|
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
|
||||||
|
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
|
||||||
|
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
|
||||||
|
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100">✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
bind:value={text}
|
||||||
|
placeholder="Write a comment…"
|
||||||
|
rows="2"
|
||||||
|
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
|
||||||
|
disabled={submitting}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-sm self-end"
|
||||||
|
disabled={submitting || !text.trim()}
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs opacity-40">
|
||||||
|
<a href="/login" class="link link-primary">Log in</a> to comment.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Comments list -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-dots loading-sm opacity-40"></span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-xs text-error opacity-70">{error}</p>
|
||||||
|
{:else if comments.length === 0}
|
||||||
|
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{#each comments as comment (comment.id)}
|
||||||
|
<li
|
||||||
|
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
|
||||||
|
id="comment-{comment.id}"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-7 rounded-full border border-base-300">
|
||||||
|
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<a
|
||||||
|
href="/user/{comment.username?.toLowerCase()}"
|
||||||
|
class="font-semibold text-xs hover:text-primary transition-colors"
|
||||||
|
style={comment.username_color ? `color: ${comment.username_color}` : ''}
|
||||||
|
>
|
||||||
|
{comment.display_name || comment.username}
|
||||||
|
</a>
|
||||||
|
<time
|
||||||
|
datetime={comment.stamp_full}
|
||||||
|
title={formatDate(comment.stamp_full)}
|
||||||
|
class="text-xs opacity-40"
|
||||||
|
>
|
||||||
|
{comment.stamp}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if comment.is_deleted}
|
||||||
|
<p class="text-xs italic opacity-50">— deleted —</p>
|
||||||
|
{:else}
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
|
||||||
|
{@html comment.content}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
{#if !comment.is_deleted}
|
||||||
|
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{#if $session}
|
||||||
|
<button
|
||||||
|
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
|
||||||
|
class="btn btn-ghost btn-xs gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canDelete(comment)}
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(comment)}
|
||||||
|
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
83
f0ck-svelte/src/lib/components/item/ItemCard.svelte
Normal file
83
f0ck-svelte/src/lib/components/item/ItemCard.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GridItem } from '$lib/types/item';
|
||||||
|
import type { ItemLink } from '$lib/types/item';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: GridItem;
|
||||||
|
link: ItemLink;
|
||||||
|
}
|
||||||
|
let { item, link }: Props = $props();
|
||||||
|
|
||||||
|
// Build the item URL respecting the current filter context
|
||||||
|
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
|
||||||
|
const thumbUrl = $derived(`/t/${item.id}.webp`);
|
||||||
|
|
||||||
|
// Rating badge
|
||||||
|
const ratingClass = $derived(
|
||||||
|
item.tag === 'NSFL' ? 'badge-nsfl' :
|
||||||
|
item.tag === 'NSFW' ? 'badge-nsfw' :
|
||||||
|
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={itemUrl}
|
||||||
|
data-sveltekit-preload-data="hover"
|
||||||
|
class="
|
||||||
|
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
|
||||||
|
hover:border-primary/50 transition-all duration-200
|
||||||
|
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
|
||||||
|
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<div class="aspect-video w-full overflow-hidden bg-base-300">
|
||||||
|
<img
|
||||||
|
src={thumbUrl}
|
||||||
|
alt="Item {item.id}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay on hover -->
|
||||||
|
<div class="
|
||||||
|
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
|
||||||
|
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||||
|
flex flex-col justify-end p-2 gap-1
|
||||||
|
">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if item.is_oc}
|
||||||
|
<span class="badge badge-xs badge-accent">OC</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.is_pinned}
|
||||||
|
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
|
||||||
|
{/if}
|
||||||
|
{#if item.xd_tier > 0}
|
||||||
|
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if item.display_name || item.username}
|
||||||
|
<span class="text-xs text-base-content/60 truncate">
|
||||||
|
{item.display_name || item.username}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating badge (always visible) -->
|
||||||
|
{#if item.tag}
|
||||||
|
<div class="absolute top-1.5 right-1.5">
|
||||||
|
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Notification dot -->
|
||||||
|
{#if item.has_notification}
|
||||||
|
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
503
f0ck-svelte/src/lib/components/item/ItemView.svelte
Normal file
503
f0ck-svelte/src/lib/components/item/ItemView.svelte
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
|
||||||
|
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||||
|
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
|
||||||
|
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
|
||||||
|
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
|
||||||
|
import TagList from '$lib/components/item/TagList.svelte';
|
||||||
|
import Comments from '$lib/components/item/Comments.svelte';
|
||||||
|
import { mimeCategory } from '$lib/utils/mimeHelpers';
|
||||||
|
import { formatDate } from '$lib/utils/timeago';
|
||||||
|
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||||
|
import { goto, preloadData } from '$app/navigation';
|
||||||
|
import { flash } from '$lib/stores/flash';
|
||||||
|
import { onSSE } from '$lib/stores/sse';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { broadcastPin } from '$lib/stores/pin';
|
||||||
|
import {
|
||||||
|
toggleFav, addTag, deleteTag,
|
||||||
|
deleteItem, pinItem, unpinItem, cycleRating
|
||||||
|
} from '$lib/api/actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: FullItem;
|
||||||
|
pagination: Pagination;
|
||||||
|
link: ItemLink;
|
||||||
|
isSubscribed?: boolean;
|
||||||
|
}
|
||||||
|
let { item, pagination, link, isSubscribed = false }: Props = $props();
|
||||||
|
|
||||||
|
// ── Reactive local state (mutable copies updated after actions) ───────────
|
||||||
|
let favorites = $state(item.favorites);
|
||||||
|
let tags = $state(item.tags);
|
||||||
|
let isPinned = $state(item.is_pinned);
|
||||||
|
let isOc = $state(item.is_oc);
|
||||||
|
let isSfw = $state(item.is_sfw);
|
||||||
|
let isNsfw = $state(item.is_nsfw);
|
||||||
|
let isNsfl = $state(item.is_nsfl);
|
||||||
|
|
||||||
|
// Sync when item prop changes (navigation)
|
||||||
|
$effect(() => {
|
||||||
|
favorites = item.favorites;
|
||||||
|
tags = item.tags;
|
||||||
|
isPinned = item.is_pinned;
|
||||||
|
isOc = item.is_oc;
|
||||||
|
isSfw = item.is_sfw;
|
||||||
|
isNsfw = item.is_nsfw;
|
||||||
|
isNsfl = item.is_nsfl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Live SSE updates ──────────────────────────────────────────────────────
|
||||||
|
const unsubSSE = onSSE((event) => {
|
||||||
|
if (event.type === 'favorites') {
|
||||||
|
const d = event.data as { item_id: number; favs: FavoriteUser[] };
|
||||||
|
if (d.item_id === item.id) favorites = d.favs;
|
||||||
|
} else if (event.type === 'tags') {
|
||||||
|
const d = event.data as { item_id: number; tags: Tag[] };
|
||||||
|
if (d.item_id === item.id) {
|
||||||
|
tags = d.tags;
|
||||||
|
// Re-derive rating state from the fresh tag list
|
||||||
|
const SFW_ID = 1, NSFW_ID = 2;
|
||||||
|
const ids = d.tags.map(t => t.id);
|
||||||
|
isSfw = ids.includes(SFW_ID);
|
||||||
|
isNsfw = ids.includes(NSFW_ID);
|
||||||
|
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
|
||||||
|
}
|
||||||
|
} else if (event.type === 'delete_item') {
|
||||||
|
const d = event.data as { id: number };
|
||||||
|
if (d.id === item.id) goto('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onDestroy(unsubSSE);
|
||||||
|
|
||||||
|
const cat = $derived(mimeCategory(item.mime));
|
||||||
|
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
|
||||||
|
|
||||||
|
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
|
||||||
|
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
|
||||||
|
|
||||||
|
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
|
||||||
|
|
||||||
|
// Rating derived from local mutable state (so cycle updates instantly)
|
||||||
|
const ratingClass = $derived(
|
||||||
|
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
|
||||||
|
);
|
||||||
|
const ratingLabel = $derived(
|
||||||
|
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
|
||||||
|
);
|
||||||
|
|
||||||
|
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
|
||||||
|
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
|
||||||
|
|
||||||
|
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
|
||||||
|
let ratingCycling = $state(false);
|
||||||
|
async function handleCycleRating() {
|
||||||
|
if (!isMod) return;
|
||||||
|
ratingCycling = true;
|
||||||
|
try {
|
||||||
|
const res = await cycleRating(item.id);
|
||||||
|
if (res.success) {
|
||||||
|
isSfw = res.rating_class === 'sfw';
|
||||||
|
isNsfw = res.rating_class === 'nsfw';
|
||||||
|
isNsfl = res.rating_class === 'nsfl';
|
||||||
|
flash(`Rating → ${res.rating_label}`, 'success');
|
||||||
|
} else {
|
||||||
|
flash(res.msg ?? 'Failed to cycle rating', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
} finally {
|
||||||
|
ratingCycling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard navigation ───────────────────────────────────────────────────
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
|
||||||
|
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag input state ───────────────────────────────────────────────────────
|
||||||
|
let tagInput = $state('');
|
||||||
|
let tagAdding = $state(false);
|
||||||
|
let showTagInput = $state(false);
|
||||||
|
let suggestions = $state<string[]>([]);
|
||||||
|
|
||||||
|
async function submitTag(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = tagInput.trim();
|
||||||
|
if (!name) return;
|
||||||
|
tagAdding = true;
|
||||||
|
try {
|
||||||
|
const res = await addTag(item.id, name);
|
||||||
|
if (res.success) {
|
||||||
|
tags = res.tags;
|
||||||
|
tagInput = '';
|
||||||
|
showTagInput = false;
|
||||||
|
flash(`Tag "${name}" added`, 'success');
|
||||||
|
} else {
|
||||||
|
flash(res.msg ?? 'Failed to add tag', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
} finally {
|
||||||
|
tagAdding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveTag(tag: Tag) {
|
||||||
|
try {
|
||||||
|
const res = await deleteTag(item.id, tag.tag);
|
||||||
|
if (res.success) {
|
||||||
|
tags = res.tags;
|
||||||
|
flash(`Tag "${tag.tag}" removed`, 'success');
|
||||||
|
} else {
|
||||||
|
flash(res.msg ?? 'Failed to remove tag', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Favorite ──────────────────────────────────────────────────────────────
|
||||||
|
let favLoading = $state(false);
|
||||||
|
async function handleFav() {
|
||||||
|
if (!$session) { flash('Log in to favorite', 'warning'); return; }
|
||||||
|
favLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await toggleFav(item.id);
|
||||||
|
if (res.success) favorites = res.favs;
|
||||||
|
} catch {
|
||||||
|
flash('Failed to update favorite', 'error');
|
||||||
|
} finally {
|
||||||
|
favLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
async function handleDelete() {
|
||||||
|
const reason = prompt('Reason for deletion (optional):') ?? '';
|
||||||
|
if (reason === null) return; // cancelled
|
||||||
|
try {
|
||||||
|
const res = await deleteItem(item.id, reason);
|
||||||
|
if (res.success) {
|
||||||
|
flash('Item deleted', 'success');
|
||||||
|
goto('/');
|
||||||
|
} else {
|
||||||
|
flash(res.msg ?? 'Delete failed', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pin ───────────────────────────────────────────────────────────────────
|
||||||
|
async function handlePin() {
|
||||||
|
try {
|
||||||
|
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
|
||||||
|
if (res.success) {
|
||||||
|
isPinned = res.pinned;
|
||||||
|
broadcastPin(item.id, res.pinned); // patch grid card live
|
||||||
|
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
|
||||||
|
} else {
|
||||||
|
flash('Pin failed', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
|
||||||
|
|
||||||
|
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
|
||||||
|
|
||||||
|
<!-- Tags card -->
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body p-3 gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
|
||||||
|
{#if $session}
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
onclick={() => showTagInput = !showTagInput}
|
||||||
|
aria-label="Add tag"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag input -->
|
||||||
|
{#if showTagInput}
|
||||||
|
<form onsubmit={submitTag} class="flex gap-1">
|
||||||
|
<input
|
||||||
|
bind:value={tagInput}
|
||||||
|
type="text"
|
||||||
|
placeholder="tag name…"
|
||||||
|
class="input input-xs input-bordered flex-1"
|
||||||
|
disabled={tagAdding}
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-xs btn-primary"
|
||||||
|
disabled={tagAdding || !tagInput.trim()}
|
||||||
|
>
|
||||||
|
{#if tagAdding}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
OK
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs opacity-40">No tags yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- xD Score -->
|
||||||
|
{#if item.xd_tier > 0}
|
||||||
|
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
|
||||||
|
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
|
||||||
|
{item.xd_label}
|
||||||
|
<span class="opacity-70 text-xs">{item.xd_score}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<Comments itemId={item.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
|
||||||
|
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
|
||||||
|
|
||||||
|
{#if item.title}
|
||||||
|
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Media + prev/next arrows -->
|
||||||
|
<div class="relative group">
|
||||||
|
<!-- Prev -->
|
||||||
|
<a
|
||||||
|
href={prevUrl ?? '#'}
|
||||||
|
aria-disabled={!prevUrl}
|
||||||
|
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||||
|
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
|
||||||
|
opacity-0 group-hover:opacity-100 transition-opacity
|
||||||
|
{!prevUrl ? 'pointer-events-none' : ''}"
|
||||||
|
>
|
||||||
|
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||||
|
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="rounded-lg overflow-hidden bg-base-300">
|
||||||
|
{#if cat === 'youtube'}
|
||||||
|
<YouTubeEmbed videoId={ytId} />
|
||||||
|
{:else if cat === 'video'}
|
||||||
|
<VideoPlayer src={item.dest} mime={item.mime} />
|
||||||
|
{:else if cat === 'audio'}
|
||||||
|
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
|
||||||
|
{:else if cat === 'image'}
|
||||||
|
<ImageViewer src={item.dest} alt="Item {item.id}" />
|
||||||
|
{:else if cat === 'pdf'}
|
||||||
|
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center p-12 gap-4">
|
||||||
|
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
|
||||||
|
<a href={item.dest} download class="btn btn-primary gap-2">
|
||||||
|
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
<a
|
||||||
|
href={nextUrl ?? '#'}
|
||||||
|
aria-disabled={!nextUrl}
|
||||||
|
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||||
|
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
|
||||||
|
opacity-0 group-hover:opacity-100 transition-opacity
|
||||||
|
{!nextUrl ? 'pointer-events-none' : ''}"
|
||||||
|
>
|
||||||
|
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||||
|
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata / action bar -->
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body p-3 gap-2">
|
||||||
|
|
||||||
|
<!-- Prev / Random / Next -->
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
|
||||||
|
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||||
|
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||||
|
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
|
||||||
|
</a>
|
||||||
|
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
|
||||||
|
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
|
||||||
|
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||||
|
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||||
|
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<!-- Item metadata row -->
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||||
|
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
|
||||||
|
#{item.id}
|
||||||
|
</a>
|
||||||
|
{#if isMod}
|
||||||
|
<button
|
||||||
|
onclick={handleCycleRating}
|
||||||
|
disabled={ratingCycling}
|
||||||
|
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
|
||||||
|
title="Cycle rating: {ratingLabel} → {isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
|
||||||
|
aria-label="Cycle rating"
|
||||||
|
>
|
||||||
|
{#if ratingCycling}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
|
||||||
|
{/if}
|
||||||
|
{ratingLabel}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
|
||||||
|
{/if}
|
||||||
|
<a href="/user/{item.username.toLowerCase()}"
|
||||||
|
class="font-medium hover:text-primary transition-colors"
|
||||||
|
style={item.author_color ? `color: ${item.author_color}` : ''}>
|
||||||
|
{item.author_display_name || item.username}
|
||||||
|
</a>
|
||||||
|
{#if isOc}
|
||||||
|
<span class="badge badge-sm badge-accent">OC</span>
|
||||||
|
{/if}
|
||||||
|
{#if isPinned}
|
||||||
|
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
|
||||||
|
{/if}
|
||||||
|
{#if item.primaryHall}
|
||||||
|
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
|
||||||
|
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
|
||||||
|
{item.primaryHall.name}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
|
||||||
|
{item.timestamp.timeago}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center flex-wrap gap-1 pt-1">
|
||||||
|
<!-- Favorite -->
|
||||||
|
<button
|
||||||
|
onclick={handleFav}
|
||||||
|
disabled={favLoading || !$session}
|
||||||
|
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
|
||||||
|
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
|
||||||
|
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
|
||||||
|
>
|
||||||
|
{#if favLoading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
|
||||||
|
{/if}
|
||||||
|
{#if favorites.length > 0}
|
||||||
|
<span class="text-xs">{favorites.length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Subscribe (stub) -->
|
||||||
|
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
|
||||||
|
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Link copy -->
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
title="Copy link"
|
||||||
|
aria-label="Copy link"
|
||||||
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(location.href);
|
||||||
|
flash('Link copied!', 'success');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mod / owner actions -->
|
||||||
|
{#if canManage}
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<!-- Pin/Unpin -->
|
||||||
|
<button
|
||||||
|
onclick={handlePin}
|
||||||
|
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
|
||||||
|
title="{isPinned ? 'Unpin' : 'Pin'}"
|
||||||
|
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
onclick={handleDelete}
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete item"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorites avatars -->
|
||||||
|
{#if favorites.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 pt-1">
|
||||||
|
{#each favorites as fav (fav.user)}
|
||||||
|
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
|
||||||
|
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
|
||||||
|
{#if fav.avatar_file}
|
||||||
|
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
|
||||||
|
{:else if fav.avatar}
|
||||||
|
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<img src="/a/default.png" alt={fav.user} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
37
f0ck-svelte/src/lib/components/item/TagList.svelte
Normal file
37
f0ck-svelte/src/lib/components/item/TagList.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Tag } from '$lib/types/item';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tags: Tag[];
|
||||||
|
canRemove?: boolean;
|
||||||
|
onRemove?: (tag: Tag) => void;
|
||||||
|
}
|
||||||
|
let { tags, canRemove = false, onRemove }: Props = $props();
|
||||||
|
|
||||||
|
// Map badge class names from the existing tag badge values
|
||||||
|
const badgeClass = (badge: string) => {
|
||||||
|
if (badge === 'badge-sfw') return 'badge-sfw';
|
||||||
|
if (badge === 'badge-nsfw') return 'badge-nsfw';
|
||||||
|
if (badge === 'badge-nsfl') return 'badge-nsfl';
|
||||||
|
return 'badge-neutral';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each tags as tag (tag.id)}
|
||||||
|
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
|
||||||
|
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
|
||||||
|
{tag.tag}
|
||||||
|
</a>
|
||||||
|
{#if canRemove && onRemove}
|
||||||
|
<button
|
||||||
|
class="hover:text-error ml-0.5 transition-colors"
|
||||||
|
onclick={() => onRemove?.(tag)}
|
||||||
|
aria-label="Remove tag {tag.tag}"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
100
f0ck-svelte/src/lib/components/layout/Navbar.svelte
Normal file
100
f0ck-svelte/src/lib/components/layout/Navbar.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { session, loggedIn } from '$lib/stores/session';
|
||||||
|
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getRandom } from '$lib/api/items';
|
||||||
|
import { flash } from '$lib/stores/flash';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
async function goRandom() {
|
||||||
|
try {
|
||||||
|
const res = await getRandom();
|
||||||
|
if (res.success) goto(res.url);
|
||||||
|
} catch {
|
||||||
|
flash('Could not fetch a random item', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
|
||||||
|
// OR just use them in the template with $ prefix — both work, but $ in templates
|
||||||
|
// is fine for Svelte writable stores even in runes mode.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
|
||||||
|
<!-- Logo / Home -->
|
||||||
|
<div class="navbar-start gap-2">
|
||||||
|
<a href="/" class="flex items-center gap-2 group">
|
||||||
|
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
|
||||||
|
f0ckm
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: actions -->
|
||||||
|
<div class="navbar-center gap-1 hidden sm:flex">
|
||||||
|
<!-- Random button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm gap-1"
|
||||||
|
onclick={goRandom}
|
||||||
|
title="Random item"
|
||||||
|
aria-label="Random item"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
|
||||||
|
<span class="hidden md:inline">Random</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Rating filter badge -->
|
||||||
|
<div class="dropdown dropdown-bottom">
|
||||||
|
<button
|
||||||
|
tabindex="0"
|
||||||
|
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
|
||||||
|
aria-label="Content filter"
|
||||||
|
>
|
||||||
|
{$filterLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: user area -->
|
||||||
|
<div class="navbar-end gap-2">
|
||||||
|
{#if $loggedIn}
|
||||||
|
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
|
||||||
|
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
|
||||||
|
<span class="hidden sm:inline">Upload</span>
|
||||||
|
</a>
|
||||||
|
<!-- Avatar / user menu -->
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
|
||||||
|
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
|
||||||
|
{#if $session?.avatar_file}
|
||||||
|
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
|
||||||
|
{:else if $session?.avatar}
|
||||||
|
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
|
||||||
|
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
|
||||||
|
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
|
||||||
|
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
|
||||||
|
{#if $session?.admin || $session?.is_moderator}
|
||||||
|
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
|
||||||
|
{/if}
|
||||||
|
<li class="divider my-1"></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button type="submit" class="text-error w-full text-left">
|
||||||
|
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
|
||||||
|
<a href="/register" class="btn btn-primary btn-sm">Register</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
24
f0ck-svelte/src/lib/components/media/AudioPlayer.svelte
Normal file
24
f0ck-svelte/src/lib/components/media/AudioPlayer.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
mime: string;
|
||||||
|
coverart?: string;
|
||||||
|
}
|
||||||
|
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
|
||||||
|
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
|
||||||
|
>
|
||||||
|
<!-- Dark overlay over coverart -->
|
||||||
|
<div class="absolute inset-0 bg-black/60"></div>
|
||||||
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
|
<audio
|
||||||
|
{src}
|
||||||
|
loop
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
class="relative z-10 w-full max-w-md"
|
||||||
|
></audio>
|
||||||
|
</div>
|
||||||
58
f0ck-svelte/src/lib/components/media/ImageViewer.svelte
Normal file
58
f0ck-svelte/src/lib/components/media/ImageViewer.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
let { src, alt = 'Image' }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
let imgEl = $state<HTMLImageElement | undefined>();
|
||||||
|
|
||||||
|
function toggleExpand() {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close modal
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && expanded) expanded = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
{src}
|
||||||
|
{alt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onclick={toggleExpand}
|
||||||
|
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
|
||||||
|
onclick={toggleExpand}
|
||||||
|
aria-label="Expand"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-expand"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullscreen modal -->
|
||||||
|
{#if expanded}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
|
||||||
|
onclick={toggleExpand}
|
||||||
|
>
|
||||||
|
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
|
||||||
|
onclick={toggleExpand}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
36
f0ck-svelte/src/lib/components/media/VideoPlayer.svelte
Normal file
36
f0ck-svelte/src/lib/components/media/VideoPlayer.svelte
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
mime: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
}
|
||||||
|
let { src, mime, autoplay = false }: Props = $props();
|
||||||
|
|
||||||
|
let videoEl = $state<HTMLVideoElement | undefined>();
|
||||||
|
|
||||||
|
// Keyboard shortcut: space to toggle play/pause
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.target !== document.body) return;
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoEl?.paused) videoEl.play();
|
||||||
|
else videoEl?.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
|
||||||
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
|
<video
|
||||||
|
bind:this={videoEl}
|
||||||
|
{src}
|
||||||
|
{autoplay}
|
||||||
|
loop
|
||||||
|
playsinline
|
||||||
|
controls
|
||||||
|
class="max-h-[80dvh] w-full object-contain"
|
||||||
|
preload="auto"
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
18
f0ck-svelte/src/lib/components/media/YouTubeEmbed.svelte
Normal file
18
f0ck-svelte/src/lib/components/media/YouTubeEmbed.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
videoId: string; // e.g. "dQw4w9WgXcQ"
|
||||||
|
}
|
||||||
|
let { videoId }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
|
||||||
|
<iframe
|
||||||
|
class="absolute inset-0 w-full h-full"
|
||||||
|
src="https://www.youtube.com/embed/{videoId}"
|
||||||
|
title="YouTube video"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
42
f0ck-svelte/src/lib/components/ui/FlashMessages.svelte
Normal file
42
f0ck-svelte/src/lib/components/ui/FlashMessages.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
|
||||||
|
|
||||||
|
const iconMap: Record<FlashType, string> = {
|
||||||
|
info: 'fa-solid fa-circle-info',
|
||||||
|
success: 'fa-solid fa-circle-check',
|
||||||
|
warning: 'fa-solid fa-triangle-exclamation',
|
||||||
|
error: 'fa-solid fa-circle-xmark'
|
||||||
|
};
|
||||||
|
|
||||||
|
const alertMap: Record<FlashType, string> = {
|
||||||
|
info: 'alert-info',
|
||||||
|
success: 'alert-success',
|
||||||
|
warning: 'alert-warning',
|
||||||
|
error: 'alert-error'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
||||||
|
{#each $flashes as msg (msg.id)}
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
|
||||||
|
animate-[slide-up_0.2s_ease-out]"
|
||||||
|
>
|
||||||
|
<i class="{iconMap[msg.type]}"></i>
|
||||||
|
<span class="flex-1 text-sm">{msg.text}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
onclick={() => dismiss(msg.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
f0ck-svelte/src/lib/components/ui/Pagination.svelte
Normal file
62
f0ck-svelte/src/lib/components/ui/Pagination.svelte
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Pagination, ItemLink } from '$lib/types/item';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pagination: Pagination;
|
||||||
|
link: ItemLink;
|
||||||
|
}
|
||||||
|
let { pagination, link }: Props = $props();
|
||||||
|
|
||||||
|
const base = $derived(link.main + link.path);
|
||||||
|
const suffix = $derived(link.suffix ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pagination.end > 1}
|
||||||
|
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
|
||||||
|
<!-- Prev -->
|
||||||
|
<a
|
||||||
|
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
|
||||||
|
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
|
||||||
|
aria-disabled={!pagination.prev}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- First page -->
|
||||||
|
{#if pagination.cheat[0] > 1}
|
||||||
|
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
|
||||||
|
{#if pagination.cheat[0] > 2}
|
||||||
|
<span class="px-1 opacity-40">…</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Page range -->
|
||||||
|
{#each pagination.cheat as p}
|
||||||
|
<a
|
||||||
|
href="{base}{p}{suffix}"
|
||||||
|
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Last page -->
|
||||||
|
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
|
||||||
|
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
|
||||||
|
<span class="px-1 opacity-40">…</span>
|
||||||
|
{/if}
|
||||||
|
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
<a
|
||||||
|
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
|
||||||
|
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
|
||||||
|
aria-disabled={!pagination.next}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
1
f0ck-svelte/src/lib/index.ts
Normal file
1
f0ck-svelte/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
28
f0ck-svelte/src/lib/stores/flash.ts
Normal file
28
f0ck-svelte/src/lib/stores/flash.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// ── Flash message type ────────────────────────────────────────────────────────
|
||||||
|
export type FlashType = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface FlashMessage {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
type: FlashType;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _nextId = 0;
|
||||||
|
export const flashes = writable<FlashMessage[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a flash message. Auto-dismisses after `duration` ms.
|
||||||
|
* Returns the message ID so callers can dismiss early if needed.
|
||||||
|
*/
|
||||||
|
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
|
||||||
|
const id = ++_nextId;
|
||||||
|
flashes.update((q) => [...q, { id, text, type }]);
|
||||||
|
setTimeout(() => dismiss(id), duration);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: number): void {
|
||||||
|
flashes.update((q) => q.filter((m) => m.id !== id));
|
||||||
|
}
|
||||||
20
f0ck-svelte/src/lib/stores/pin.ts
Normal file
20
f0ck-svelte/src/lib/stores/pin.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight broadcast store for pin/unpin events.
|
||||||
|
* ItemView fires it after a successful pin action;
|
||||||
|
* the main grid subscribes to patch the item card immediately.
|
||||||
|
*/
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface PinEvent {
|
||||||
|
itemId: number;
|
||||||
|
pinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null when idle, set to an event when pin/unpin succeeds
|
||||||
|
export const pinEvent = writable<PinEvent | null>(null);
|
||||||
|
|
||||||
|
export function broadcastPin(itemId: number, pinned: boolean) {
|
||||||
|
pinEvent.set({ itemId, pinned });
|
||||||
|
// Reset so subsequent identical events still trigger subscribers
|
||||||
|
setTimeout(() => pinEvent.set(null), 0);
|
||||||
|
}
|
||||||
50
f0ck-svelte/src/lib/stores/preferences.ts
Normal file
50
f0ck-svelte/src/lib/stores/preferences.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { writable, derived, get } from 'svelte/store';
|
||||||
|
|
||||||
|
// ── Content mode ─────────────────────────────────────────────────────────────
|
||||||
|
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
|
||||||
|
export const activeMode = writable<number>(3);
|
||||||
|
|
||||||
|
// ── Multi-rating filter ───────────────────────────────────────────────────────
|
||||||
|
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
|
||||||
|
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
|
||||||
|
export const activeRatings = writable<Rating[]>([]);
|
||||||
|
|
||||||
|
// ── MIME filter ───────────────────────────────────────────────────────────────
|
||||||
|
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
|
||||||
|
export const activeMimes = writable<MimeFilter[]>([]);
|
||||||
|
|
||||||
|
// ── Derived: ratings query param string for API calls ─────────────────────────
|
||||||
|
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
|
||||||
|
|
||||||
|
// ── Derived: readable label for the filter badge ─────────────────────────────
|
||||||
|
export const filterLabel = derived(
|
||||||
|
[activeMode, activeRatings],
|
||||||
|
([$mode, $ratings]) => {
|
||||||
|
if ($ratings.length > 0) {
|
||||||
|
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
|
||||||
|
return $ratings.map((r) => abbr[r]).join('+');
|
||||||
|
}
|
||||||
|
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
|
||||||
|
return labels[$mode] ?? 'ALL';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Persistence helpers ───────────────────────────────────────────────────────
|
||||||
|
// Call these after receiving the session from the server to hydrate from cookie/session.
|
||||||
|
export function initPreferences(opts: { mode: number; ratings?: string }) {
|
||||||
|
activeMode.set(opts.mode ?? 3);
|
||||||
|
if (opts.ratings) {
|
||||||
|
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
|
||||||
|
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
|
||||||
|
);
|
||||||
|
activeRatings.set(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a mode number → server mode number (for API calls when using multi-rating). */
|
||||||
|
export function getServerMode(): number {
|
||||||
|
const ratings = get(activeRatings);
|
||||||
|
if (ratings.length === 0) return get(activeMode);
|
||||||
|
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
|
||||||
|
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
|
||||||
|
}
|
||||||
11
f0ck-svelte/src/lib/stores/session.ts
Normal file
11
f0ck-svelte/src/lib/stores/session.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { SessionUser } from '$lib/types/session';
|
||||||
|
|
||||||
|
/** The current authenticated user, or null if not logged in. */
|
||||||
|
export const session = writable<SessionUser | null>(null);
|
||||||
|
|
||||||
|
/** Convenience derived — true when logged in. */
|
||||||
|
export const loggedIn = derived(session, ($s) => $s !== null);
|
||||||
|
|
||||||
|
/** True when the user has admin or moderator role. */
|
||||||
|
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));
|
||||||
126
f0ck-svelte/src/lib/stores/sse.ts
Normal file
126
f0ck-svelte/src/lib/stores/sse.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* SSE store — connects to the backend's /api/notifications/stream endpoint.
|
||||||
|
*
|
||||||
|
* Emits typed events that components subscribe to with `onSSE()`.
|
||||||
|
* Auto-reconnects with exponential backoff on disconnect.
|
||||||
|
* Client-only: never runs during SSR.
|
||||||
|
*/
|
||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
// ── Event types broadcast by the backend ─────────────────────────────────────
|
||||||
|
export interface SSEComment {
|
||||||
|
type: 'comment' | 'delete';
|
||||||
|
id?: number;
|
||||||
|
item_id: number;
|
||||||
|
comment_id?: number;
|
||||||
|
body?: string;
|
||||||
|
username?: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
avatar_file?: string | null;
|
||||||
|
username_color?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
parent_id?: number | null;
|
||||||
|
xd_score?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEFavorites {
|
||||||
|
item_id: number;
|
||||||
|
favs: Array<{
|
||||||
|
user: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
avatar_file: string | null;
|
||||||
|
username_color: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSETags {
|
||||||
|
item_id: number;
|
||||||
|
fresh: boolean;
|
||||||
|
tags: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSENewItem {
|
||||||
|
id: number;
|
||||||
|
mime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEDeleteItem {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEEvent {
|
||||||
|
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
|
||||||
|
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection state ──────────────────────────────────────────────────────────
|
||||||
|
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||||
|
export const sseState = writable<SSEState>('disconnected');
|
||||||
|
|
||||||
|
// ── Listener registry ─────────────────────────────────────────────────────────
|
||||||
|
type SSEListener = (event: SSEEvent) => void;
|
||||||
|
const listeners = new Set<SSEListener>();
|
||||||
|
|
||||||
|
/** Subscribe to SSE events. Returns an unsubscribe function. */
|
||||||
|
export function onSSE(fn: SSEListener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal connection state ─────────────────────────────────────────────────
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let retryDelay = 2000;
|
||||||
|
const MAX_RETRY = 30_000;
|
||||||
|
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
|
||||||
|
|
||||||
|
function dispatch(raw: SSEEvent) {
|
||||||
|
for (const fn of listeners) {
|
||||||
|
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry() {
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
retryTimer = setTimeout(connect, retryDelay);
|
||||||
|
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connect() {
|
||||||
|
if (!browser) return;
|
||||||
|
if (es) { es.close(); es = null; }
|
||||||
|
|
||||||
|
sseState.set('connecting');
|
||||||
|
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
sseState.set('connected');
|
||||||
|
retryDelay = 2000; // reset backoff on successful connect
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data) as SSEEvent;
|
||||||
|
dispatch(data);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed frames
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
sseState.set('error');
|
||||||
|
es?.close();
|
||||||
|
es = null;
|
||||||
|
scheduleRetry();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnect() {
|
||||||
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||||
|
es?.close();
|
||||||
|
es = null;
|
||||||
|
sseState.set('disconnected');
|
||||||
|
}
|
||||||
81
f0ck-svelte/src/lib/stores/ws.ts
Normal file
81
f0ck-svelte/src/lib/stores/ws.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// ── WebSocket message types ───────────────────────────────────────────────────
|
||||||
|
export type WSMessage =
|
||||||
|
| { type: 'pong' }
|
||||||
|
| { type: 'notification'; count: number; item?: unknown }
|
||||||
|
| { type: 'comment:new'; itemId: number; comment: unknown }
|
||||||
|
| { type: 'comment:delete'; itemId: number; commentId: number }
|
||||||
|
| { type: 'item:tag_added'; itemId: number; tag: unknown }
|
||||||
|
| { type: 'item:favorited'; itemId: number; user: string }
|
||||||
|
| { type: 'global:activity'; payload: unknown };
|
||||||
|
|
||||||
|
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||||
|
export const wsConnected = writable(false);
|
||||||
|
|
||||||
|
let _socket: WebSocket | null = null;
|
||||||
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let _reconnectDelay = 1000;
|
||||||
|
|
||||||
|
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
|
||||||
|
|
||||||
|
/** Connect to the WebSocket server. Call once from the root layout. */
|
||||||
|
export function wsConnect(): void {
|
||||||
|
if (typeof window === 'undefined') return; // SSR guard
|
||||||
|
if (_socket?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
_socket = new WebSocket(`${proto}://${location.host}/ws`);
|
||||||
|
|
||||||
|
_socket.onopen = () => {
|
||||||
|
wsConnected.set(true);
|
||||||
|
_reconnectDelay = 1000; // reset backoff on successful connect
|
||||||
|
_ping();
|
||||||
|
};
|
||||||
|
|
||||||
|
_socket.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data) as WSMessage;
|
||||||
|
// Dispatch to typed listeners
|
||||||
|
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
|
||||||
|
_listeners.get('*')?.forEach((fn) => fn(msg));
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_socket.onclose = () => {
|
||||||
|
wsConnected.set(false);
|
||||||
|
_socket = null;
|
||||||
|
// Exponential backoff reconnect
|
||||||
|
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||||
|
_reconnectTimer = setTimeout(() => {
|
||||||
|
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
||||||
|
wsConnect();
|
||||||
|
}, _reconnectDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
_socket.onerror = () => {
|
||||||
|
_socket?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a typed message to the server. */
|
||||||
|
export function wsSend(msg: object): void {
|
||||||
|
if (_socket?.readyState === WebSocket.OPEN) {
|
||||||
|
_socket.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a specific message type. Returns an unsubscribe function. */
|
||||||
|
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
|
||||||
|
if (!_listeners.has(type)) _listeners.set(type, new Set());
|
||||||
|
_listeners.get(type)!.add(fn);
|
||||||
|
return () => _listeners.get(type)?.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
function _ping() {
|
||||||
|
if (_pingTimer) clearInterval(_pingTimer);
|
||||||
|
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
|
||||||
|
}
|
||||||
21
f0ck-svelte/src/lib/types/comment.ts
Normal file
21
f0ck-svelte/src/lib/types/comment.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
item_id: number;
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
avatar_file: string | null;
|
||||||
|
username_color: string | null;
|
||||||
|
content: string; // may be HTML (server-rendered)
|
||||||
|
stamp: string; // human-readable relative time
|
||||||
|
stamp_full: string; // ISO timestamp
|
||||||
|
is_deleted: boolean;
|
||||||
|
reply_to: number | null;
|
||||||
|
poll?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentsResponse {
|
||||||
|
success: boolean;
|
||||||
|
comments: Comment[];
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
137
f0ck-svelte/src/lib/types/item.ts
Normal file
137
f0ck-svelte/src/lib/types/item.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
|
||||||
|
export interface GridItem {
|
||||||
|
id: number;
|
||||||
|
mime: string;
|
||||||
|
dest: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
is_pinned: boolean;
|
||||||
|
is_oc: boolean;
|
||||||
|
xd_score: number;
|
||||||
|
xd_tier: number;
|
||||||
|
xd_label: string;
|
||||||
|
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
|
||||||
|
tag_id: number | null;
|
||||||
|
thumb_size: 1 | 2;
|
||||||
|
has_notification: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
|
||||||
|
export interface FullItem {
|
||||||
|
id: number;
|
||||||
|
mime: string;
|
||||||
|
dest: string;
|
||||||
|
username: string;
|
||||||
|
author_id: number | null;
|
||||||
|
author_color: string | null;
|
||||||
|
author_display_name: string | null;
|
||||||
|
author_avatar: string | null;
|
||||||
|
author_avatar_file: string | null;
|
||||||
|
title: string | null;
|
||||||
|
src: { long: string; short: string };
|
||||||
|
thumbnail: string;
|
||||||
|
og_thumbnail: string;
|
||||||
|
og_description: string;
|
||||||
|
coverart: string;
|
||||||
|
size: string;
|
||||||
|
checksum: string | null;
|
||||||
|
timestamp: { timeago: string; timefull: string };
|
||||||
|
favorites: FavoriteUser[];
|
||||||
|
tags: Tag[];
|
||||||
|
halls: Hall[];
|
||||||
|
user_halls: Hall[];
|
||||||
|
is_nsfw: boolean;
|
||||||
|
is_nsfl: boolean;
|
||||||
|
is_sfw: boolean;
|
||||||
|
is_pinned: boolean;
|
||||||
|
is_comments_locked: boolean;
|
||||||
|
is_oc: boolean;
|
||||||
|
is_repost: boolean;
|
||||||
|
reposts: RepostItem[];
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
original_filename: string | null;
|
||||||
|
xd_score: number;
|
||||||
|
xd_tier: number;
|
||||||
|
xd_label: string;
|
||||||
|
primaryHall: Hall | null;
|
||||||
|
otherHalls: Hall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pagination ───────────────────────────────────────────────────────────────
|
||||||
|
export interface Pagination {
|
||||||
|
prev: number | null;
|
||||||
|
next: number | null;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
current: number;
|
||||||
|
page: number;
|
||||||
|
cheat: number[];
|
||||||
|
location?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Link context ─────────────────────────────────────────────────────────────
|
||||||
|
export interface ItemLink {
|
||||||
|
main: string;
|
||||||
|
mainDisplay?: string;
|
||||||
|
path: string;
|
||||||
|
suffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tags ─────────────────────────────────────────────────────────────────────
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
tag: string;
|
||||||
|
normalized: string;
|
||||||
|
badge: string;
|
||||||
|
display_name: string | null;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Halls ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface Hall {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||||
|
export interface FavoriteUser {
|
||||||
|
user: string;
|
||||||
|
avatar: string | null;
|
||||||
|
avatar_file: string | null;
|
||||||
|
username_color: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reposts ──────────────────────────────────────────────────────────────────
|
||||||
|
export interface RepostItem {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
stamp: number;
|
||||||
|
match_type: 'checksum' | 'phash';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API response wrappers ─────────────────────────────────────────────────────
|
||||||
|
export interface ItemsResponse {
|
||||||
|
success: boolean;
|
||||||
|
items: GridItem[];
|
||||||
|
pagination: Pagination;
|
||||||
|
total: number;
|
||||||
|
link: ItemLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemResponse {
|
||||||
|
success: boolean;
|
||||||
|
item: FullItem | null;
|
||||||
|
pagination: Pagination;
|
||||||
|
link: ItemLink;
|
||||||
|
isSubscribed: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RandomResponse {
|
||||||
|
success: boolean;
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
27
f0ck-svelte/src/lib/types/session.ts
Normal file
27
f0ck-svelte/src/lib/types/session.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface SessionUser {
|
||||||
|
id: number;
|
||||||
|
user: string;
|
||||||
|
login: string;
|
||||||
|
admin: boolean;
|
||||||
|
is_moderator: boolean;
|
||||||
|
mode: number;
|
||||||
|
theme: string;
|
||||||
|
avatar: string | null;
|
||||||
|
avatar_file: string | null;
|
||||||
|
username_color: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
use_new_layout: boolean;
|
||||||
|
excluded_tags: number[];
|
||||||
|
min_xd_score: number;
|
||||||
|
uploads_remaining: number | undefined;
|
||||||
|
pending_count?: number;
|
||||||
|
language: string | null;
|
||||||
|
disable_autoplay: boolean;
|
||||||
|
disable_swiping: boolean;
|
||||||
|
csrf_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
loggedIn: boolean;
|
||||||
|
user: SessionUser | null;
|
||||||
|
}
|
||||||
23
f0ck-svelte/src/lib/utils/csrfFetch.ts
Normal file
23
f0ck-svelte/src/lib/utils/csrfFetch.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Wrapper around fetch that automatically injects the CSRF token as a header
|
||||||
|
* on all state-changing requests (POST, PUT, PATCH, DELETE).
|
||||||
|
*
|
||||||
|
* The token is read from the session store at call time so it's always fresh.
|
||||||
|
*/
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { session } from '$lib/stores/session';
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const method = (init.method ?? 'GET').toUpperCase();
|
||||||
|
if (SAFE_METHODS.has(method)) return fetch(input, init);
|
||||||
|
|
||||||
|
const token = get(session)?.csrf_token;
|
||||||
|
if (!token) return fetch(input, init); // not logged in — let backend return 401
|
||||||
|
|
||||||
|
const headers = new Headers(init.headers ?? {});
|
||||||
|
headers.set('X-CSRF-Token', token);
|
||||||
|
|
||||||
|
return fetch(input, { ...init, headers });
|
||||||
|
}
|
||||||
32
f0ck-svelte/src/lib/utils/mimeHelpers.ts
Normal file
32
f0ck-svelte/src/lib/utils/mimeHelpers.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** Map a MIME type to a simple category string. */
|
||||||
|
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
|
||||||
|
if (mime === 'video/youtube') return 'youtube';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime.startsWith('audio/')) return 'audio';
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime === 'application/pdf') return 'pdf';
|
||||||
|
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
|
||||||
|
if (mime.startsWith('application/')) return 'archive';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a Font Awesome icon class for a MIME category. */
|
||||||
|
export function mimeIcon(mime: string): string {
|
||||||
|
const cat = mimeCategory(mime);
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
youtube: 'fa-brands fa-youtube',
|
||||||
|
video: 'fa-solid fa-film',
|
||||||
|
audio: 'fa-solid fa-music',
|
||||||
|
image: 'fa-solid fa-image',
|
||||||
|
flash: 'fa-solid fa-bolt',
|
||||||
|
pdf: 'fa-solid fa-file-pdf',
|
||||||
|
archive: 'fa-solid fa-file-zipper',
|
||||||
|
unknown: 'fa-solid fa-file'
|
||||||
|
};
|
||||||
|
return map[cat] ?? map.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the MIME type represents inline-playable media. */
|
||||||
|
export function isPlayable(mime: string): boolean {
|
||||||
|
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
|
||||||
|
}
|
||||||
45
f0ck-svelte/src/lib/utils/timeago.ts
Normal file
45
f0ck-svelte/src/lib/utils/timeago.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
|
||||||
|
* relative time string, e.g. "3 hours ago".
|
||||||
|
*/
|
||||||
|
export function timeAgo(input: number | string | Date): string {
|
||||||
|
const date =
|
||||||
|
input instanceof Date
|
||||||
|
? input
|
||||||
|
: typeof input === 'number'
|
||||||
|
? new Date(input * 1000)
|
||||||
|
: new Date(input);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
|
||||||
|
const epochs: [string, number][] = [
|
||||||
|
['year', 365 * 24 * 3600 * 1000],
|
||||||
|
['month', 30 * 24 * 3600 * 1000],
|
||||||
|
['week', 7 * 24 * 3600 * 1000],
|
||||||
|
['day', 24 * 3600 * 1000],
|
||||||
|
['hour', 3600 * 1000],
|
||||||
|
['minute', 60 * 1000],
|
||||||
|
['second', 1000]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, ms] of epochs) {
|
||||||
|
const n = Math.floor(diff / ms);
|
||||||
|
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO timestamp to a readable full date string.
|
||||||
|
*/
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
8
f0ck-svelte/src/routes/+layout.server.ts
Normal file
8
f0ck-svelte/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
/** Pass session to all pages via PageData. */
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
};
|
||||||
56
f0ck-svelte/src/routes/+layout.svelte
Normal file
56
f0ck-svelte/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import Navbar from '$lib/components/layout/Navbar.svelte';
|
||||||
|
import FlashMessages from '$lib/components/ui/FlashMessages.svelte';
|
||||||
|
import { session as sessionStore } from '$lib/stores/session';
|
||||||
|
import { initPreferences } from '$lib/stores/preferences';
|
||||||
|
import { connect as sseConnect, disconnect as sseDisconnect } from '$lib/stores/sse';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
session: import('$lib/types/session').SessionUser | null;
|
||||||
|
};
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
// Hydrate session store from server-loaded data
|
||||||
|
$effect(() => {
|
||||||
|
sessionStore.set(data.session);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init user preferences from session
|
||||||
|
$effect(() => {
|
||||||
|
if (data.session) {
|
||||||
|
initPreferences({ mode: data.session.mode ?? 3 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
sseConnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
sseDisconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>f0ckm</title>
|
||||||
|
<meta name="description" content="f0ckm — media sharing" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-dvh flex flex-col" data-theme="f0ck">
|
||||||
|
<Navbar />
|
||||||
|
<div class="flex-1">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global flash messages -->
|
||||||
|
<FlashMessages />
|
||||||
|
</div>
|
||||||
30
f0ck-svelte/src/routes/+page.server.ts
Normal file
30
f0ck-svelte/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getItems } from '$lib/api/items';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||||
|
const page = Number(url.searchParams.get('page') ?? 1);
|
||||||
|
const mode = locals.session?.mode ?? 3;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getItems({ page, mode }, fetch);
|
||||||
|
if (data.success) {
|
||||||
|
return {
|
||||||
|
items: data.items,
|
||||||
|
pagination: data.pagination,
|
||||||
|
total: data.total,
|
||||||
|
link: data.link
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Backend not yet running or API route not registered — return empty state
|
||||||
|
// so the page renders rather than hard-erroring.
|
||||||
|
console.warn('[/] Failed to load items:', (e as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
pagination: null,
|
||||||
|
total: 0,
|
||||||
|
link: { main: '/', path: 'p/', suffix: '' }
|
||||||
|
};
|
||||||
|
};
|
||||||
79
f0ck-svelte/src/routes/+page.svelte
Normal file
79
f0ck-svelte/src/routes/+page.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import type { GridItem } from '$lib/types/item';
|
||||||
|
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
||||||
|
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||||
|
import { onSSE } from '$lib/stores/sse';
|
||||||
|
import { pinEvent } from '$lib/stores/pin';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// Local mutable copy of the items list — SSE patches it live
|
||||||
|
let items = $state<GridItem[]>(data.items);
|
||||||
|
let total = $state(data.total);
|
||||||
|
|
||||||
|
// Re-sync when server data changes (e.g. navigation / invalidation)
|
||||||
|
$effect(() => {
|
||||||
|
items = data.items;
|
||||||
|
total = data.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Live grid updates via SSE ─────────────────────────────────────────────
|
||||||
|
const unsubSSE = onSSE((event) => {
|
||||||
|
if (event.type === 'new_item') {
|
||||||
|
const d = event.data as GridItem & { dest: string; username: string };
|
||||||
|
if (!items.some(i => i.id === d.id)) {
|
||||||
|
items = [d, ...items];
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
} else if (event.type === 'delete_item') {
|
||||||
|
const d = event.data as { id: number };
|
||||||
|
const before = items.length;
|
||||||
|
items = items.filter(i => i.id !== d.id);
|
||||||
|
if (items.length < before) total = Math.max(0, total - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onDestroy(unsubSSE);
|
||||||
|
|
||||||
|
// ── Pin store: patch the grid card is_pinned live ─────────────────────────
|
||||||
|
const unsubPin = pinEvent.subscribe((evt) => {
|
||||||
|
if (!evt) return;
|
||||||
|
items = items.map(i => i.id === evt.itemId ? { ...i, is_pinned: evt.pinned } : i);
|
||||||
|
});
|
||||||
|
onDestroy(unsubPin);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>f0ckm — {total} items</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
||||||
|
<!-- Grid header -->
|
||||||
|
{#if total > 0}
|
||||||
|
<p class="text-xs opacity-40 mb-3 font-mono">{total.toLocaleString()} items</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Thumbnail grid -->
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="
|
||||||
|
grid gap-1.5
|
||||||
|
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
||||||
|
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
||||||
|
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
||||||
|
">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<ItemCard {item} link={data.link} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.pagination}
|
||||||
|
<Pagination pagination={data.pagination} link={data.link} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
||||||
|
<i class="fa-solid fa-box-open text-5xl"></i>
|
||||||
|
<p class="text-sm">No items found.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
28
f0ck-svelte/src/routes/[itemid]/+page.server.ts
Normal file
28
f0ck-svelte/src/routes/[itemid]/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getItem } from '$lib/api/items';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch, locals, url }) => {
|
||||||
|
const id = params.itemid;
|
||||||
|
if (!id || !/^\d+$/.test(id)) throw error(404, 'Not found');
|
||||||
|
|
||||||
|
const mode = locals.session?.mode ?? 3;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getItem(id, { mode }, fetch);
|
||||||
|
|
||||||
|
if (!data.success || !data.item) {
|
||||||
|
throw error(404, 'Item not found or not visible in current mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: data.item,
|
||||||
|
pagination: data.pagination,
|
||||||
|
link: data.link,
|
||||||
|
isSubscribed: data.isSubscribed
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Response) throw e;
|
||||||
|
throw error(500, 'Failed to load item');
|
||||||
|
}
|
||||||
|
};
|
||||||
32
f0ck-svelte/src/routes/[itemid]/+page.svelte
Normal file
32
f0ck-svelte/src/routes/[itemid]/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import ItemView from '$lib/components/item/ItemView.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const ogTitle = $derived(data.item.title ?? `#${data.item.id} — f0ckm`);
|
||||||
|
const ogImage = $derived(data.item.og_thumbnail);
|
||||||
|
const ogDesc = $derived(data.item.og_description ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{ogTitle}</title>
|
||||||
|
<meta name="description" content={ogDesc} />
|
||||||
|
<!-- OG tags for embeds -->
|
||||||
|
<meta property="og:title" content={ogTitle} />
|
||||||
|
<meta property="og:description" content={ogDesc} />
|
||||||
|
<meta property="og:image" content={ogImage} />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<!-- Twitter card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={ogTitle} />
|
||||||
|
<meta name="twitter:description" content={ogDesc} />
|
||||||
|
<meta name="twitter:image" content={ogImage} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<ItemView
|
||||||
|
item={data.item}
|
||||||
|
pagination={data.pagination}
|
||||||
|
link={data.link}
|
||||||
|
isSubscribed={data.isSubscribed}
|
||||||
|
/>
|
||||||
9
f0ck-svelte/src/routes/login/+page.server.ts
Normal file
9
f0ck-svelte/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Stub page for /login — will be built in Sprint 2.
|
||||||
|
// For now redirect to the legacy login page so auth still works.
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
// Temporary: redirect to legacy backend login until we build the Svelte login form
|
||||||
|
throw redirect(307, 'http://localhost:1337/login');
|
||||||
|
};
|
||||||
16
f0ck-svelte/src/routes/random/+page.server.ts
Normal file
16
f0ck-svelte/src/routes/random/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getRandom } from '$lib/api/items';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
|
try {
|
||||||
|
const data = await getRandom(fetch);
|
||||||
|
if (data.success && data.url) {
|
||||||
|
throw redirect(307, data.url);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Response) throw e;
|
||||||
|
}
|
||||||
|
// Fallback: go home if no random item found
|
||||||
|
throw redirect(307, '/');
|
||||||
|
};
|
||||||
6
f0ck-svelte/src/routes/register/+page.server.ts
Normal file
6
f0ck-svelte/src/routes/register/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
throw redirect(307, 'http://localhost:1337/register');
|
||||||
|
};
|
||||||
31
f0ck-svelte/src/routes/tag/[tag]/+page.server.ts
Normal file
31
f0ck-svelte/src/routes/tag/[tag]/+page.server.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getItems } from '$lib/api/items';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url, params, locals }) => {
|
||||||
|
const tag = decodeURIComponent(params.tag);
|
||||||
|
const page = Number(url.searchParams.get('page') ?? 1);
|
||||||
|
const mode = locals.session?.mode ?? 3;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getItems({ page, mode, tag }, fetch);
|
||||||
|
if (data.success) {
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
items: data.items,
|
||||||
|
pagination: data.pagination,
|
||||||
|
total: data.total,
|
||||||
|
link: data.link
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[/tag/${tag}] Failed to load items:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
items: [],
|
||||||
|
pagination: null,
|
||||||
|
total: 0,
|
||||||
|
link: { main: `/tag/${encodeURIComponent(tag)}/`, path: 'p/', suffix: '' }
|
||||||
|
};
|
||||||
|
};
|
||||||
76
f0ck-svelte/src/routes/tag/[tag]/+page.svelte
Normal file
76
f0ck-svelte/src/routes/tag/[tag]/+page.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import type { GridItem } from '$lib/types/item';
|
||||||
|
import ItemCard from '$lib/components/item/ItemCard.svelte';
|
||||||
|
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||||
|
import { onSSE } from '$lib/stores/sse';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let items = $state<GridItem[]>(data.items);
|
||||||
|
let total = $state(data.total);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
items = data.items;
|
||||||
|
total = data.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live: remove items that get deleted
|
||||||
|
// (we don't prepend new_item because it may not match this tag filter)
|
||||||
|
const unsubSSE = onSSE((event) => {
|
||||||
|
if (event.type === 'delete_item') {
|
||||||
|
const d = event.data as { id: number };
|
||||||
|
const before = items.length;
|
||||||
|
items = items.filter(i => i.id !== d.id);
|
||||||
|
if (items.length < before) total = Math.max(0, total - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onDestroy(unsubSSE);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>#{data.tag} — f0ckm</title>
|
||||||
|
<meta name="description" content="Items tagged #{data.tag} on f0ckm" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-[1600px] mx-auto px-3 py-4">
|
||||||
|
|
||||||
|
<!-- Tag header -->
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-tag text-primary opacity-70" aria-hidden="true"></i>
|
||||||
|
<h1 class="text-xl font-bold">{data.tag}</h1>
|
||||||
|
</div>
|
||||||
|
{#if total > 0}
|
||||||
|
<span class="badge badge-neutral font-mono text-xs">{total.toLocaleString()}</span>
|
||||||
|
{/if}
|
||||||
|
<a href="/" class="btn btn-ghost btn-xs ml-auto opacity-60 hover:opacity-100 gap-1">
|
||||||
|
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i> Clear
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="
|
||||||
|
grid gap-1.5
|
||||||
|
grid-cols-[repeat(auto-fill,minmax(140px,1fr))]
|
||||||
|
sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
|
||||||
|
md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]
|
||||||
|
">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<ItemCard {item} link={data.link} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.pagination}
|
||||||
|
<Pagination pagination={data.pagination} link={data.link} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center py-32 gap-4 opacity-40">
|
||||||
|
<i class="fa-solid fa-tag text-5xl" aria-hidden="true"></i>
|
||||||
|
<p class="text-sm">No items found for <strong>#{data.tag}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
27
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.server.ts
Normal file
27
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getItem } from '$lib/api/items';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, params, locals }) => {
|
||||||
|
const tag = decodeURIComponent(params.tag);
|
||||||
|
const itemid = Number(params.itemid);
|
||||||
|
const mode = locals.session?.mode ?? 3;
|
||||||
|
|
||||||
|
if (!itemid) throw error(404, 'Not found');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getItem(itemid, { mode, tag }, fetch);
|
||||||
|
if (!data.success || !data.item) throw error(404, data.message ?? 'Item not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
item: data.item,
|
||||||
|
pagination: data.pagination,
|
||||||
|
link: data.link,
|
||||||
|
isSubscribed: data.isSubscribed ?? false
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if ((e as { status?: number })?.status) throw e;
|
||||||
|
throw error(500, 'Failed to load item');
|
||||||
|
}
|
||||||
|
};
|
||||||
31
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.svelte
Normal file
31
f0ck-svelte/src/routes/tag/[tag]/[itemid]/+page.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import ItemView from '$lib/components/item/ItemView.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>#{data.tag} — item {data.item.id} — f0ckm</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Tag breadcrumb -->
|
||||||
|
<div class="max-w-[1600px] mx-auto px-4 pt-2">
|
||||||
|
<div class="text-xs opacity-50 flex items-center gap-1.5">
|
||||||
|
<a href="/" class="hover:opacity-100 transition-opacity">f0ckm</a>
|
||||||
|
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||||
|
<a href="/tag/{encodeURIComponent(data.tag)}" class="hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||||
|
<i class="fa-solid fa-tag text-[10px]" aria-hidden="true"></i>
|
||||||
|
{data.tag}
|
||||||
|
</a>
|
||||||
|
<i class="fa-solid fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||||
|
<span>#{data.item.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ItemView
|
||||||
|
item={data.item}
|
||||||
|
pagination={data.pagination}
|
||||||
|
link={data.link}
|
||||||
|
isSubscribed={data.isSubscribed}
|
||||||
|
/>
|
||||||
3
f0ck-svelte/static/robots.txt
Normal file
3
f0ck-svelte/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
21
f0ck-svelte/tsconfig.json
Normal file
21
f0ck-svelte/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
47
f0ck-svelte/vite.config.ts
Normal file
47
f0ck-svelte/vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
sveltekit({
|
||||||
|
compilerOptions: {
|
||||||
|
runes: ({ filename }) =>
|
||||||
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
|
},
|
||||||
|
adapter: adapter()
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// Proxy API + WebSocket to the existing f0ckm backend
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:1337',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ajax': {
|
||||||
|
target: 'http://localhost:1337',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:1337',
|
||||||
|
ws: true
|
||||||
|
},
|
||||||
|
// Static media files — use exact prefix matching to avoid
|
||||||
|
// colliding with Vite's own /src/ module paths.
|
||||||
|
// Each rule anchored to exactly /X/ (with trailing slash).
|
||||||
|
'^/b/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||||
|
'^/t/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||||
|
'^/ca/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||||
|
'^/a/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||||
|
// /s/ — NOTE: must NOT match /src/ — regex anchors it
|
||||||
|
'^/s/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||||
|
'^/memes/': { target: 'http://localhost:1337', changeOrigin: true },
|
||||||
|
// mod actions (pin, unpin, delete)
|
||||||
|
'^/mod/': { target: 'http://localhost:1337', changeOrigin: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
git pu/* 98.css framework - Scoped exclusively for f0ck98 theme */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Pixelated MS Sans Serif";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: url("/s/fonts/ms_sans_serif.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Pixelated MS Sans Serif";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: url("/s/fonts/ms_sans_serif_bold.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] {
|
|
||||||
--w98-silver: #c0c0c0;
|
|
||||||
--w98-teal: #008080;
|
|
||||||
--w98-navy: #000080;
|
|
||||||
--w98-navy-gradient: linear-gradient(90deg, navy, #1084d0);
|
|
||||||
--w98-dark-shadow: #0a0a0a;
|
|
||||||
--w98-light-highlight: #ffffff;
|
|
||||||
--w98-mid-gray: grey;
|
|
||||||
--w98-light-gray: #dfdfdf;
|
|
||||||
|
|
||||||
color: #000;
|
|
||||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
background-color: var(--w98-silver) !important;
|
|
||||||
background: var(--w98-silver) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] body {
|
|
||||||
color: #000;
|
|
||||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar,
|
|
||||||
html[theme="f0ck98"] .window,
|
|
||||||
html[theme="f0ck98"] button,
|
|
||||||
html[theme="f0ck98"] input,
|
|
||||||
html[theme="f0ck98"] label,
|
|
||||||
html[theme="f0ck98"] legend,
|
|
||||||
html[theme="f0ck98"] li[role=tab],
|
|
||||||
html[theme="f0ck98"] option,
|
|
||||||
html[theme="f0ck98"] select,
|
|
||||||
html[theme="f0ck98"] table,
|
|
||||||
html[theme="f0ck98"] textarea,
|
|
||||||
html[theme="f0ck98"] ul.tree-view {
|
|
||||||
-webkit-font-smoothing: none;
|
|
||||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] u {
|
|
||||||
border-bottom: 0.5px solid #222;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Windows 98 Buttons */
|
|
||||||
html[theme="f0ck98"] button,
|
|
||||||
html[theme="f0ck98"] input[type=reset],
|
|
||||||
html[theme="f0ck98"] input[type=submit],
|
|
||||||
html[theme="f0ck98"] .btn {
|
|
||||||
background: silver;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
|
||||||
box-sizing: border-box;
|
|
||||||
color: #000;
|
|
||||||
min-height: 23px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] button.default,
|
|
||||||
html[theme="f0ck98"] input[type=reset].default,
|
|
||||||
html[theme="f0ck98"] input[type=submit].default,
|
|
||||||
html[theme="f0ck98"] .btn.default {
|
|
||||||
box-shadow: inset -2px -2px #0a0a0a, inset 1px 1px #0a0a0a, inset 2px 2px #fff, inset -3px -3px grey, inset 3px 3px #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .vertical-bar {
|
|
||||||
background: silver;
|
|
||||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
|
||||||
height: 20px;
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] button:not(:disabled):active,
|
|
||||||
html[theme="f0ck98"] input[type=reset]:not(:disabled):active,
|
|
||||||
html[theme="f0ck98"] input[type=submit]:not(:disabled):active,
|
|
||||||
html[theme="f0ck98"] .btn:not(:disabled):active {
|
|
||||||
box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] button.default:not(:disabled):active,
|
|
||||||
html[theme="f0ck98"] input[type=reset].default:not(:disabled):active,
|
|
||||||
html[theme="f0ck98"] input[type=submit].default:not(:disabled):active {
|
|
||||||
box-shadow: inset 2px 2px #0a0a0a, inset -1px -1px #0a0a0a, inset -2px -2px #fff, inset 3px 3px grey, inset -3px -3px #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] button:focus,
|
|
||||||
html[theme="f0ck98"] input[type=reset]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=submit]:focus,
|
|
||||||
html[theme="f0ck98"] .btn:focus {
|
|
||||||
outline: 1px dotted #000;
|
|
||||||
outline-offset: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] :disabled,
|
|
||||||
html[theme="f0ck98"] :disabled+label,
|
|
||||||
html[theme="f0ck98"] input[readonly],
|
|
||||||
html[theme="f0ck98"] input[readonly]+label {
|
|
||||||
color: grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] :disabled+label,
|
|
||||||
html[theme="f0ck98"] button:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=reset]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=submit]:disabled,
|
|
||||||
html[theme="f0ck98"] .btn:disabled {
|
|
||||||
text-shadow: 1px 1px 0 #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Window Shell & Title Bars */
|
|
||||||
html[theme="f0ck98"] .window {
|
|
||||||
background: silver;
|
|
||||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar {
|
|
||||||
align-items: center;
|
|
||||||
background: linear-gradient(90deg, navy, #1084d0);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 3px 2px 3px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar.inactive {
|
|
||||||
background: linear-gradient(90deg, grey, #b5b5b5);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-text {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0;
|
|
||||||
margin-right: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button {
|
|
||||||
display: block;
|
|
||||||
min-height: 14px;
|
|
||||||
min-width: 16px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button:active {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button[aria-label=Minimize],
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button[aria-label].minimize {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='6' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' d='M0 0h6v2H0z'/%3E%3C/svg%3E");
|
|
||||||
background-position: bottom 3px left 4px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button[aria-label=Maximize],
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button[aria-label].maximize {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='9' height='9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 0H0v9h9V0zM8 2H1v6h7V2z' fill='%23000'/%3E%3C/svg%3E");
|
|
||||||
background-position: top 2px left 3px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button[aria-label=Close],
|
|
||||||
html[theme="f0ck98"] .title-bar-controls button[aria-label].close {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h2v1h1v1h2V1h1V0h2v1H7v1H6v1H5v1h1v1h1v1h1v1H6V6H5V5H3v1H2v1H0V6h1V5h1V4h1V3H2V2H1V1H0V0z' fill='%23000'/%3E%3C/svg%3E");
|
|
||||||
background-position: top 3px left 4px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Bar */
|
|
||||||
html[theme="f0ck98"] .status-bar {
|
|
||||||
gap: 1px;
|
|
||||||
display: flex;
|
|
||||||
margin: 0 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .status-bar-field {
|
|
||||||
box-shadow: inset -1px -1px #dfdfdf, inset 1px 1px grey;
|
|
||||||
flex-grow: 1;
|
|
||||||
margin: 0;
|
|
||||||
padding: 2px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .window-body {
|
|
||||||
margin: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] fieldset {
|
|
||||||
border-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='5' height='5' fill='gray' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h5v5H0V2h2v1h1V2H0' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h4v4H0V1h1v2h2V1H0'/%3E%3C/svg%3E") 2;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px;
|
|
||||||
padding-block-start: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] legend {
|
|
||||||
background: silver;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .field-row {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] [class^=field-row]+[class^=field-row] {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .field-row>*+* {
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] label {
|
|
||||||
align-items: center;
|
|
||||||
display: inline-flex;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Inputs & Textarea */
|
|
||||||
html[theme="f0ck98"] input[type=email],
|
|
||||||
html[theme="f0ck98"] input[type=number],
|
|
||||||
html[theme="f0ck98"] input[type=password],
|
|
||||||
html[theme="f0ck98"] input[type=search],
|
|
||||||
html[theme="f0ck98"] input[type=tel],
|
|
||||||
html[theme="f0ck98"] input[type=text],
|
|
||||||
html[theme="f0ck98"] input[type=url],
|
|
||||||
html[theme="f0ck98"] select,
|
|
||||||
html[theme="f0ck98"] textarea {
|
|
||||||
background-color: #fff;
|
|
||||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 3px 4px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] input[type=email]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=number]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=password]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=search]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=tel]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=text]:disabled,
|
|
||||||
html[theme="f0ck98"] input[type=url]:disabled,
|
|
||||||
html[theme="f0ck98"] textarea:disabled {
|
|
||||||
background-color: silver;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] select {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E");
|
|
||||||
background-position: top 2px right 2px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border-radius: 0;
|
|
||||||
padding-right: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] input[type=email]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=number]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=password]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=search]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=tel]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=text]:focus,
|
|
||||||
html[theme="f0ck98"] input[type=url]:focus,
|
|
||||||
html[theme="f0ck98"] select:focus,
|
|
||||||
html[theme="f0ck98"] textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] a {
|
|
||||||
color: #000080;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] a:focus {
|
|
||||||
outline: 1px dotted #000080;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .sunken-panel {
|
|
||||||
background-color: #fff;
|
|
||||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scoped Scrollbars for f0ck98 */
|
|
||||||
html[theme="f0ck98"] ::-webkit-scrollbar {
|
|
||||||
width: 16px;
|
|
||||||
height: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] ::-webkit-scrollbar-corner {
|
|
||||||
background: #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] ::-webkit-scrollbar-track {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='2' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 0H0v1h1v1h1V1H1V0z' fill='silver'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 0H1v1H0v1h1V1h1V0z' fill='%23fff'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] ::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #dfdfdf;
|
|
||||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] ::-webkit-scrollbar-button:vertical:start {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 6H7v1H6v1H5v1H4v1h7V9h-1V8H9V7H8V6z' fill='%23000'/%3E%3C/svg%3E");
|
|
||||||
height: 17px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] ::-webkit-scrollbar-button:vertical:end {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E");
|
|
||||||
height: 17px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .sidebar-activity .comment-content.has-overflow:not(.expanded) .comment-content-inner::after {
|
|
||||||
background: linear-gradient(to bottom, transparent, rgb(192, 192, 192));
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .item-sidebar-right, .index-sidebar-right, .global-sidebar-right {
|
|
||||||
box-shadow: none!important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .sidebar-activity .read-more-btn, .sidebar-activity .see-less-btn {
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .modal-content p {
|
|
||||||
color: var(--black);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[theme="f0ck98"] .drop-zone {
|
|
||||||
border: 2px dashed rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,8 @@
|
|||||||
/* Meme Creator Styles */
|
/* Meme Creator Styles */
|
||||||
|
|
||||||
.meme-creator-container {
|
.meme-creator-container {
|
||||||
box-sizing: border-box !important;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 1100px;
|
max-width: 1200px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -112,7 +111,6 @@
|
|||||||
|
|
||||||
.canvas-wrapper {
|
.canvas-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 650px;
|
|
||||||
min-width: 0; /* Allow shrinking */
|
min-width: 0; /* Allow shrinking */
|
||||||
background: #000;
|
background: #000;
|
||||||
border: 2px solid var(--nav-bg, #2b2b2b);
|
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||||
@@ -240,49 +238,22 @@ canvas#memeCanvas {
|
|||||||
color: var(--white, #fff);
|
color: var(--white, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Stacking - Clean Flexbox Column Layout */
|
/* Mobile Stacking - Simple 2-Row Layout */
|
||||||
@media (max-width: 950px) {
|
@media (max-width: 950px) {
|
||||||
.meme-editor-layout {
|
.meme-editor-layout {
|
||||||
display: flex !important;
|
display: grid !important;
|
||||||
flex-direction: column !important;
|
grid-template-columns: 1fr !important;
|
||||||
align-items: center !important;
|
grid-template-rows: 0.6fr auto !important;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.meme-controls {
|
.meme-controls {
|
||||||
box-sizing: border-box !important;
|
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 500px !important;
|
grid-row: 2;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
.canvas-wrapper {
|
.canvas-wrapper {
|
||||||
box-sizing: border-box !important;
|
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 650px !important;
|
grid-row: 1;
|
||||||
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;
|
margin-bottom: 10px;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
@@ -323,19 +294,20 @@ canvas#memeCanvas {
|
|||||||
|
|
||||||
/* Sidebar space reservation for meme pages */
|
/* Sidebar space reservation for meme pages */
|
||||||
.meme-layout-wrapper {
|
.meme-layout-wrapper {
|
||||||
box-sizing: border-box !important;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
transition: padding-right 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1000px) {
|
@media (min-width: 1200px) {
|
||||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||||
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
|
.meme-layout-wrapper {
|
||||||
padding-right: 300px;
|
padding-right: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapse reserved space when sidebar is hidden */
|
||||||
|
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapse reserved space when sidebar is hidden */
|
|
||||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
|
||||||
padding-right: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -54,7 +54,7 @@ class CommentSystem {
|
|||||||
document.body.classList.remove('sidebar-left-hidden');
|
document.body.classList.remove('sidebar-left-hidden');
|
||||||
const layout = this.container.closest('.item-layout-container');
|
const layout = this.container.closest('.item-layout-container');
|
||||||
if (layout) layout.classList.remove('sidebar-hidden');
|
if (layout) layout.classList.remove('sidebar-hidden');
|
||||||
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
|
} else if (isHidden) {
|
||||||
this.container.classList.add('faded-out');
|
this.container.classList.add('faded-out');
|
||||||
this.container.style.display = 'none';
|
this.container.style.display = 'none';
|
||||||
document.body.classList.add('sidebar-left-hidden');
|
document.body.classList.add('sidebar-left-hidden');
|
||||||
@@ -596,7 +596,7 @@ class CommentSystem {
|
|||||||
if (cached) cached.content = fullContent;
|
if (cached) cached.content = fullContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
contentEl.dataset.raw = fullContent;
|
contentEl.dataset.raw = this.escapeHtml(fullContent);
|
||||||
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
||||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||||
CommentSystem.playEmojiVideos(contentEl);
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
@@ -622,7 +622,6 @@ class CommentSystem {
|
|||||||
|
|
||||||
const contentEl = el.querySelector('.comment-content');
|
const contentEl = el.querySelector('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
contentEl.dataset.raw = data.content;
|
|
||||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
|
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
|
||||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||||
CommentSystem.playEmojiVideos(contentEl);
|
CommentSystem.playEmojiVideos(contentEl);
|
||||||
@@ -858,14 +857,26 @@ class CommentSystem {
|
|||||||
* border-right) is fully restored after the effect completes.
|
* border-right) is fully restored after the effect completes.
|
||||||
*
|
*
|
||||||
* @param {Element} el - The .comment element to animate.
|
* @param {Element} el - The .comment element to animate.
|
||||||
* @param {boolean} entering - Unused (kept for compatibility).
|
* @param {boolean} entering - True to also add 'comment-entering' (own post);
|
||||||
|
* false for 'new-item-fade' only (live others).
|
||||||
*/
|
*/
|
||||||
static _animateNewComment(el, entering = false) {
|
static _animateNewComment(el, entering = false) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.classList.add('new-item-fade');
|
|
||||||
setTimeout(() => {
|
const classes = entering
|
||||||
el.classList.remove('new-item-fade');
|
? ['comment-entering', 'new-item-fade']
|
||||||
}, 2500);
|
: ['new-item-fade'];
|
||||||
|
|
||||||
|
// Reset in case the element already has a stale animation
|
||||||
|
classes.forEach(c => el.classList.remove(c));
|
||||||
|
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
|
||||||
|
|
||||||
|
classes.forEach(c => el.classList.add(c));
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
classes.forEach(c => el.classList.remove(c));
|
||||||
|
};
|
||||||
|
el.addEventListener('animationend', cleanup, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -881,6 +892,7 @@ class CommentSystem {
|
|||||||
if (el) {
|
if (el) {
|
||||||
// Remove highlight from any previously highlighted or newly-posted comment
|
// Remove highlight from any previously highlighted or newly-posted comment
|
||||||
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
|
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'));
|
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
|
||||||
|
|
||||||
// Always smooth-scroll so there's no jarring jump
|
// Always smooth-scroll so there's no jarring jump
|
||||||
@@ -1026,7 +1038,7 @@ class CommentSystem {
|
|||||||
preview.dataset.id = targetId;
|
preview.dataset.id = targetId;
|
||||||
|
|
||||||
// Remove temporary animation/highlight classes
|
// Remove temporary animation/highlight classes
|
||||||
preview.classList.remove('new-item-fade', 'comment-highlighted');
|
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
|
||||||
|
|
||||||
// Remove input forms or action buttons
|
// Remove input forms or action buttons
|
||||||
const actions = preview.querySelector('.comment-actions');
|
const actions = preview.querySelector('.comment-actions');
|
||||||
@@ -1160,13 +1172,12 @@ class CommentSystem {
|
|||||||
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
||||||
const contentEl = body.querySelector('.comment-content');
|
const contentEl = body.querySelector('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
let rawText = (contentEl.dataset.raw || '').trim();
|
const LINE_MAX = 200;
|
||||||
if (rawText.includes('>') || rawText.includes('<') || rawText.includes('&')) {
|
const rawText = (contentEl.dataset.raw || '').trim();
|
||||||
const txt = document.createElement('textarea');
|
// Preserve all lines but cap any single line exceeding LINE_MAX chars
|
||||||
txt.innerHTML = rawText;
|
const lines = rawText.split('\n').map(line =>
|
||||||
rawText = txt.value;
|
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
|
||||||
}
|
);
|
||||||
const lines = rawText.split('\n');
|
|
||||||
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -1621,25 +1632,17 @@ class CommentSystem {
|
|||||||
syncLevel(roots, list, false);
|
syncLevel(roots, list, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum characters & lines to fully render in the item view per comment
|
// Maximum characters to fully render in the item view per comment
|
||||||
static get ITEM_VIEW_MAX_CHARS() { return 500; }
|
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
|
||||||
static get ITEM_VIEW_MAX_LINES() { return 6; }
|
|
||||||
|
|
||||||
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
renderCommentContent(content, commentId = null, bypassTruncation = false) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
// Truncate long comments before any processing
|
// Truncate extremely long comments before any processing
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
if (!bypassTruncation) {
|
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||||
const lines = content.split('\n');
|
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
|
||||||
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
|
truncated = true;
|
||||||
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') {
|
if (typeof marked === 'undefined') {
|
||||||
@@ -1788,10 +1791,9 @@ class CommentSystem {
|
|||||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
|
||||||
const renderedContent = quoteEmojis
|
const renderedContent = quoteEmojis
|
||||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||||
: escapedQuote;
|
: quoteContent;
|
||||||
return `<span class="greentext">>${renderedContent}</span>`;
|
return `<span class="greentext">>${renderedContent}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2197,7 +2199,7 @@ class CommentSystem {
|
|||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = unsafe;
|
div.textContent = unsafe;
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCommentAttachments(files, content = '') {
|
renderCommentAttachments(files, content = '') {
|
||||||
@@ -3334,15 +3336,9 @@ class CommentSystem {
|
|||||||
params.append('has_poll', '1');
|
params.append('has_poll', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
|
||||||
if (csrfToken) params.append('csrf_token', csrfToken);
|
|
||||||
|
|
||||||
const res = await fetch('/api/comments', {
|
const res = await fetch('/api/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
|
||||||
},
|
|
||||||
body: params
|
body: params
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3608,9 +3604,9 @@ class CommentSystem {
|
|||||||
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
|
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
|
||||||
// optimistic DOM insert to guarantee the button regardless of rendering path.
|
// optimistic DOM insert to guarantee the button regardless of rendering path.
|
||||||
_ensureTruncationButton(commentEl, rawContent) {
|
_ensureTruncationButton(commentEl, rawContent) {
|
||||||
if (!rawContent) return;
|
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
|
||||||
const lineCount = rawContent.split('\n').length;
|
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
|
||||||
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
|
console.log('[ensureBtn] below threshold, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contentEl = commentEl.querySelector('.comment-content');
|
const contentEl = commentEl.querySelector('.comment-content');
|
||||||
@@ -3812,12 +3808,9 @@ class CommentSystem {
|
|||||||
toggleComments() {
|
toggleComments() {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
if (document.body.classList.contains('layout-modern')) {
|
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
|
||||||
if (window.innerWidth < 1000) return;
|
window.toggleSidebarLeft();
|
||||||
if (typeof window.toggleSidebarLeft === 'function') {
|
return;
|
||||||
window.toggleSidebarLeft();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = this.container.closest('.item-layout-container');
|
const layout = this.container.closest('.item-layout-container');
|
||||||
@@ -4464,71 +4457,20 @@ class CommentSystem {
|
|||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
|
// Helper: reliable touch handling for tab buttons on mobile
|
||||||
let lastHoldTime = 0;
|
const addTabTouch = (btn, cb) => {
|
||||||
const addTabTouch = (btn, cb, onHold) => {
|
|
||||||
let ts = null;
|
let ts = null;
|
||||||
let holdTimer = null;
|
|
||||||
let holdFired = false;
|
|
||||||
btn.addEventListener('touchstart', (e) => {
|
btn.addEventListener('touchstart', (e) => {
|
||||||
holdFired = false;
|
|
||||||
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
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();
|
e.stopPropagation();
|
||||||
}, { passive: true });
|
}, { 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) => {
|
btn.addEventListener('touchend', (e) => {
|
||||||
clearTimeout(holdTimer);
|
|
||||||
if (holdFired) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
holdFired = false;
|
|
||||||
ts = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!ts) return;
|
if (!ts) return;
|
||||||
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
|
||||||
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
|
||||||
ts = null;
|
ts = null;
|
||||||
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
|
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
|
// Build tab buttons — Favorites first
|
||||||
@@ -4540,13 +4482,23 @@ class CommentSystem {
|
|||||||
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
|
favTab.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('mousedown', e => e.preventDefault());
|
||||||
favTab.addEventListener('click', () => showTab('__favs__'));
|
favTab.addEventListener('click', () => showTab('__favs__'));
|
||||||
addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
|
addTabTouch(favTab, () => showTab('__favs__'));
|
||||||
// Right-click on fav tab → set/unset as default
|
// Right-click on fav tab → set/unset as default
|
||||||
favTab.addEventListener('contextmenu', (e) => {
|
favTab.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (Date.now() - lastHoldTime < 1000) return;
|
if (navigator.maxTouchPoints > 0) return;
|
||||||
toggleFavDefault(favTab);
|
const KEY = 'f0ck_emoji_default_tab';
|
||||||
|
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||||
|
if (isDefault) {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
favTab.classList.remove('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites unset as default tab');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(KEY, '__favs__');
|
||||||
|
favTab.classList.add('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites set as default tab');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Mark as default if already set
|
// Mark as default if already set
|
||||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||||
|
|||||||
@@ -2307,49 +2307,9 @@ window.cancelAnimFrame = (function () {
|
|||||||
hidden = hiddenValue === 'true';
|
hidden = hiddenValue === 'true';
|
||||||
}
|
}
|
||||||
document.body.classList.toggle('sidebar-right-hidden', hidden);
|
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();
|
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 = {}) => {
|
const loadPageAjax = async (url, replace = true, options = {}) => {
|
||||||
if (isNavigating) return;
|
if (isNavigating) return;
|
||||||
|
|
||||||
@@ -4467,10 +4427,6 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (window.renderTags) {
|
if (window.renderTags) {
|
||||||
window.renderTags(res.tags);
|
window.renderTags(res.tags);
|
||||||
}
|
}
|
||||||
// Evict the cached item HTML so navigating back fetches fresh content
|
|
||||||
if (window.invalidateItemCache) {
|
|
||||||
window.invalidateItemCache(postid);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
revert();
|
revert();
|
||||||
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
|
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
|
||||||
@@ -6321,10 +6277,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|
||||||
// Freeze sidebar AND wrapper transitions so the class toggle
|
// Freeze all transitions (override CSS !important) so the class toggle
|
||||||
// doesn't trigger a second padding-right animation.
|
// doesn't trigger an additional right-property animation.
|
||||||
const wrappers = getSidebarWrappers();
|
|
||||||
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
|
|
||||||
sidebar.style.setProperty('transition', 'none', 'important');
|
sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
|
|
||||||
if (stateChanging) {
|
if (stateChanging) {
|
||||||
@@ -6342,11 +6296,6 @@ window.cancelAnimFrame = (function () {
|
|||||||
// Restore CSS-controlled transitions on the next frame
|
// Restore CSS-controlled transitions on the next frame
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
sidebar.style.removeProperty('transition');
|
sidebar.style.removeProperty('transition');
|
||||||
wrappers.forEach(w => {
|
|
||||||
w.style.removeProperty('transition');
|
|
||||||
w.style.removeProperty('padding-right');
|
|
||||||
w.style.removeProperty('right');
|
|
||||||
});
|
|
||||||
swipeRT.isDraggingSidebar = false;
|
swipeRT.isDraggingSidebar = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -6584,10 +6533,6 @@ window.cancelAnimFrame = (function () {
|
|||||||
const settle = () => {
|
const settle = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
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');
|
sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
|
|
||||||
if (stateChanging) {
|
if (stateChanging) {
|
||||||
@@ -6600,11 +6545,6 @@ window.cancelAnimFrame = (function () {
|
|||||||
sidebar.style.transform = '';
|
sidebar.style.transform = '';
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
sidebar.style.removeProperty('transition');
|
sidebar.style.removeProperty('transition');
|
||||||
wrappers.forEach(w => {
|
|
||||||
w.style.removeProperty('transition');
|
|
||||||
w.style.removeProperty('padding-right');
|
|
||||||
w.style.removeProperty('right');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6672,55 +6612,15 @@ window.cancelAnimFrame = (function () {
|
|||||||
});
|
});
|
||||||
window.addEventListener('resize', syncEdgeZone, { passive: true });
|
window.addEventListener('resize', syncEdgeZone, { passive: true });
|
||||||
|
|
||||||
// Returns true if (clientX, clientY) is within the pill's hit area.
|
// Touchend on edgeZone: instant tap-to-toggle (no 300ms synthetic click delay)
|
||||||
// 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 => {
|
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) {
|
if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
|
||||||
e.preventDefault(); // always prevent 300ms synthetic click
|
e.preventDefault();
|
||||||
const touch = e.changedTouches[0];
|
edgeZone.dispatchEvent(new Event('click'));
|
||||||
if (touch && isWithinPillArea(touch.clientX, touch.clientY)) {
|
|
||||||
window.toggleSidebarRight();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { 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
|
// Smoothly toggle sidebar open/closed
|
||||||
window.toggleSidebarRight = () => {
|
window.toggleSidebarRight = () => {
|
||||||
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
||||||
@@ -6730,29 +6630,6 @@ window.cancelAnimFrame = (function () {
|
|||||||
const sidebarWidth = sidebar.offsetWidth || 300;
|
const sidebarWidth = sidebar.offsetWidth || 300;
|
||||||
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
|
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.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
|
||||||
sidebar.style.transform = `translateX(${targetTranslate}px)`;
|
sidebar.style.transform = `translateX(${targetTranslate}px)`;
|
||||||
|
|
||||||
@@ -6760,22 +6637,13 @@ window.cancelAnimFrame = (function () {
|
|||||||
const settle = () => {
|
const settle = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
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');
|
sidebar.style.setProperty('transition', 'none', 'important');
|
||||||
const toHidden = !isHidden;
|
const toHidden = !isHidden;
|
||||||
document.body.classList.toggle('sidebar-right-hidden', toHidden);
|
document.body.classList.toggle('sidebar-right-hidden', toHidden);
|
||||||
localStorage.setItem('sidebarRightHidden', toHidden);
|
localStorage.setItem('sidebarRightHidden', toHidden);
|
||||||
void sidebar.offsetWidth;
|
void sidebar.offsetWidth;
|
||||||
sidebar.style.transform = '';
|
sidebar.style.transform = '';
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => sidebar.style.removeProperty('transition'));
|
||||||
sidebar.style.removeProperty('transition');
|
|
||||||
wrappers.forEach(w => {
|
|
||||||
w.style.removeProperty('transition');
|
|
||||||
w.style.removeProperty('padding-right');
|
|
||||||
w.style.removeProperty('right');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnd = ev => {
|
const onEnd = ev => {
|
||||||
@@ -6787,11 +6655,9 @@ window.cancelAnimFrame = (function () {
|
|||||||
setTimeout(settle, 350);
|
setTimeout(settle, 350);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click on edgeZone: only toggle when the click lands on the pill
|
// Click on edgeZone: smoothly toggle sidebar open/closed
|
||||||
edgeZone.addEventListener('click', (e) => {
|
edgeZone.addEventListener('click', () => {
|
||||||
if (isWithinPillArea(e.clientX, e.clientY)) {
|
window.toggleSidebarRight();
|
||||||
window.toggleSidebarRight();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// </mouse-sidebar-drag>
|
// </mouse-sidebar-drag>
|
||||||
|
|
||||||
@@ -6811,9 +6677,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isLSActive = () =>
|
const isLSActive = () =>
|
||||||
document.body.classList.contains('layout-modern') &&
|
document.body.classList.contains('layout-modern') && !!document.querySelector('.item-sidebar-left');
|
||||||
!!document.querySelector('.item-sidebar-left') &&
|
|
||||||
window.innerWidth >= 1000;
|
|
||||||
|
|
||||||
// Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX.
|
// 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.
|
// OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
|
||||||
@@ -7072,7 +6936,6 @@ window.cancelAnimFrame = (function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.toggleSidebarLeft = () => {
|
window.toggleSidebarLeft = () => {
|
||||||
if (window.innerWidth < 1000) return;
|
|
||||||
const { sb, layout } = getLS();
|
const { sb, layout } = getLS();
|
||||||
if (!sb || !layout) return;
|
if (!sb || !layout) return;
|
||||||
const isHidden = layout.classList.contains('sidebar-hidden');
|
const isHidden = layout.classList.contains('sidebar-hidden');
|
||||||
@@ -7567,48 +7430,29 @@ class NotificationSystem {
|
|||||||
|
|
||||||
// Helper: apply a display name change to all relevant DOM nodes
|
// Helper: apply a display name change to all relevant DOM nodes
|
||||||
this.applyDisplayNameUpdate = (display_name, user) => {
|
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
|
// Capture old value before overwriting — needed for thumb matching below
|
||||||
const oldDisplayName = isSelf ? (window.f0ckSession?.display_name || null) : null;
|
const oldDisplayName = window.f0ckSession?.display_name || null;
|
||||||
if (isSelf && window.f0ckSession) {
|
if (window.f0ckSession) window.f0ckSession.display_name = display_name || null;
|
||||||
window.f0ckSession.display_name = display_name || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userName = user || window.f0ckSession?.user;
|
const userName = user || window.f0ckSession?.user;
|
||||||
|
|
||||||
// Update navbar display name only if this update is for the logged-in user
|
// Update ALL nav-display-name instances (handles the profile page having a duplicate ID)
|
||||||
if (isSelf) {
|
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
|
||||||
document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
|
// preserve the badges (Admin, Mod)
|
||||||
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
|
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
|
||||||
let node = nameSpan.childNodes[i];
|
let node = nameSpan.childNodes[i];
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
||||||
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
|
||||||
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
|
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
||||||
break;
|
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
|
||||||
}
|
break;
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update profile header display name if currently viewing this user's profile
|
|
||||||
const profileBox = document.getElementById('live-profile-preview-box');
|
|
||||||
if (profileBox && profileBox.dataset.bannerUser && userName && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
|
|
||||||
const profileNameSpan = document.getElementById('profile-display-name');
|
|
||||||
if (profileNameSpan) {
|
|
||||||
for (let i = profileNameSpan.childNodes.length - 1; i >= 0; i--) {
|
|
||||||
let node = profileNameSpan.childNodes[i];
|
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
|
||||||
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
|
||||||
node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bracket = document.getElementById('username-bracket');
|
});
|
||||||
if (bracket) {
|
|
||||||
|
const bracket = document.getElementById('username-bracket');
|
||||||
|
if (bracket) {
|
||||||
bracket.style.display = display_name ? 'inline' : 'none';
|
bracket.style.display = display_name ? 'inline' : 'none';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userName) {
|
if (userName) {
|
||||||
@@ -7635,7 +7479,6 @@ class NotificationSystem {
|
|||||||
|
|
||||||
// Helper: apply a username color change to relevant DOM nodes
|
// Helper: apply a username color change to relevant DOM nodes
|
||||||
this.applyUsernameColorUpdate = (username_color, user) => {
|
this.applyUsernameColorUpdate = (username_color, user) => {
|
||||||
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
|
|
||||||
const userName = user || window.f0ckSession?.user;
|
const userName = user || window.f0ckSession?.user;
|
||||||
if (!userName) return;
|
if (!userName) return;
|
||||||
|
|
||||||
@@ -7682,21 +7525,22 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Profile header display name color (if currently viewing this user's profile)
|
// Special case for settings page preview block
|
||||||
const profileBox = document.getElementById('live-profile-preview-box');
|
if (window.f0ckSession && userName === window.f0ckSession.user) {
|
||||||
if (profileBox && profileBox.dataset.bannerUser && profileBox.dataset.bannerUser.toLowerCase() === userName.toLowerCase()) {
|
const previewBlock = document.getElementById('live-profile-preview-box');
|
||||||
const profileNameSpan = document.getElementById('profile-display-name');
|
if (previewBlock) {
|
||||||
if (profileNameSpan) {
|
|
||||||
if (username_color) {
|
if (username_color) {
|
||||||
profileNameSpan.style.color = username_color;
|
previewBlock.style.setProperty('--author-accent', username_color);
|
||||||
|
previewBlock.style.setProperty('--author-border', username_color);
|
||||||
} else {
|
} else {
|
||||||
profileNameSpan.style.removeProperty('color');
|
previewBlock.style.setProperty('--author-accent', 'var(--accent)');
|
||||||
|
previewBlock.style.setProperty('--author-border', 'var(--accent)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navbar display name color (only for current user)
|
// Handle the nav-display-name span elements (profile header and top navbar)
|
||||||
if (isSelf) {
|
if (window.f0ckSession && userName === window.f0ckSession.user) {
|
||||||
document.querySelectorAll('#nav-display-name').forEach(el => {
|
document.querySelectorAll('#nav-display-name').forEach(el => {
|
||||||
if (username_color) {
|
if (username_color) {
|
||||||
el.style.color = username_color;
|
el.style.color = username_color;
|
||||||
@@ -7709,21 +7553,14 @@ class NotificationSystem {
|
|||||||
|
|
||||||
// Helper: apply an avatar update to relevant DOM nodes
|
// Helper: apply an avatar update to relevant DOM nodes
|
||||||
this.applyAvatarUpdate = (avatar_file, user) => {
|
this.applyAvatarUpdate = (avatar_file, user) => {
|
||||||
const isSelf = !user || (window.f0ckSession?.user && user.toLowerCase() === window.f0ckSession.user.toLowerCase());
|
|
||||||
const userName = user || window.f0ckSession?.user;
|
const userName = user || window.f0ckSession?.user;
|
||||||
if (!userName) return;
|
if (!userName) return;
|
||||||
|
|
||||||
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
|
const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
|
||||||
|
|
||||||
// Update Nav Avatar only for current user
|
// Update Nav Avatar & profile header Avatar (only for current user)
|
||||||
if (isSelf) {
|
if (window.f0ckSession && userName === window.f0ckSession.user) {
|
||||||
document.querySelectorAll('.nav-avatar-img').forEach(img => img.src = newSrc);
|
document.querySelectorAll('.nav-avatar-img, .profile_head_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)
|
// Update globally (comments, sidebar, polls, rank, infoboxes, favs)
|
||||||
|
|||||||
@@ -1976,73 +1976,6 @@ if (window.__dmLoaded) {
|
|||||||
dmGridArea = document.createElement('div');
|
dmGridArea = document.createElement('div');
|
||||||
dmGridArea.className = 'emoji-picker-grid';
|
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 ──
|
// ── Favorites tab ──
|
||||||
const favsTab = document.createElement('button');
|
const favsTab = document.createElement('button');
|
||||||
favsTab.className = 'ep-tab ep-tab-favs';
|
favsTab.className = 'ep-tab ep-tab-favs';
|
||||||
@@ -2052,13 +1985,22 @@ if (window.__dmLoaded) {
|
|||||||
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
|
favsTab.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('mousedown', e => e.preventDefault());
|
||||||
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
|
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
|
||||||
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
|
|
||||||
// Right-click on fav tab → set/unset as default
|
// Right-click on fav tab → set/unset as default
|
||||||
favsTab.addEventListener('contextmenu', (e) => {
|
favsTab.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (Date.now() - lastDmHoldTime < 1000) return;
|
if (navigator.maxTouchPoints > 0) return;
|
||||||
toggleDmFavDefault(favsTab);
|
const KEY = 'f0ck_emoji_default_tab';
|
||||||
|
const isDefault = localStorage.getItem(KEY) === '__favs__';
|
||||||
|
if (isDefault) {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
favsTab.classList.remove('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites unset as default tab');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(KEY, '__favs__');
|
||||||
|
favsTab.classList.add('ep-tab-default');
|
||||||
|
window._showEmojiToast?.('Favorites set as default tab');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Mark as default if already set
|
// Mark as default if already set
|
||||||
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
|
||||||
@@ -2086,7 +2028,6 @@ if (window.__dmLoaded) {
|
|||||||
}
|
}
|
||||||
tab.addEventListener('mousedown', e => e.preventDefault());
|
tab.addEventListener('mousedown', e => e.preventDefault());
|
||||||
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
|
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
|
||||||
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
|
|
||||||
dmTabBar.appendChild(tab);
|
dmTabBar.appendChild(tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1000,10 +1000,9 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const itemId = slide.dataset.id;
|
const itemId = slide.dataset.id;
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) pill.remove();
|
if (data.success) pill.remove();
|
||||||
@@ -1158,14 +1157,10 @@
|
|||||||
if (nowFaved) flashFav(slide);
|
if (nowFaved) flashFav(slide);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
const resp = await fetch('/api/v2/togglefav', {
|
const resp = await fetch('/api/v2/togglefav', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
body: `postid=${id}`
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
|
||||||
},
|
|
||||||
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
// Sync count to server truth (handles race conditions)
|
// Sync count to server truth (handles race conditions)
|
||||||
@@ -1587,10 +1582,9 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const itemId = slide.dataset.id;
|
const itemId = slide.dataset.id;
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) pill.remove();
|
if (data.success) pill.remove();
|
||||||
@@ -1856,12 +1850,11 @@
|
|||||||
else if (item.external_board === 'gif') rating = 'nsfw';
|
else if (item.external_board === 'gif') rating = 'nsfw';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
'x-csrf-token': window.scrollerCsrf || ''
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
url: item.external_media_url || item.dest,
|
url: item.external_media_url || item.dest,
|
||||||
@@ -1973,10 +1966,9 @@
|
|||||||
showShareToast(info.toast);
|
showShareToast(info.toast);
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -2127,12 +2119,7 @@
|
|||||||
const contentEl = commentEl.querySelector('.comment-content');
|
const contentEl = commentEl.querySelector('.comment-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
|
||||||
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
const 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 lines = raw.split('\n');
|
||||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||||
|
|
||||||
@@ -2800,16 +2787,11 @@
|
|||||||
if (!content || !commentsItemId || commentsPosting) return;
|
if (!content || !commentsItemId || commentsPosting) return;
|
||||||
commentsPosting = true; commentSendBtn.disabled = true;
|
commentsPosting = true; commentSendBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
||||||
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
||||||
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
|
|
||||||
const resp = await fetch('/api/comments', {
|
const resp = await fetch('/api/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
|
||||||
},
|
|
||||||
body: postBody
|
body: postBody
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -2917,14 +2899,10 @@
|
|||||||
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
||||||
closeSugg();
|
closeSugg();
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
|
||||||
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
body: `tagname=${encodeURIComponent(tag)}`
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
|
||||||
},
|
|
||||||
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -3680,11 +3658,7 @@
|
|||||||
if (sMarkAll) {
|
if (sMarkAll) {
|
||||||
sMarkAll.addEventListener('click', async () => {
|
sMarkAll.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
await fetch('/api/notifications/read', { method: 'POST' });
|
||||||
await fetch('/api/notifications/read', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
|
||||||
});
|
|
||||||
updateScrollerNotifBadge(0);
|
updateScrollerNotifBadge(0);
|
||||||
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
||||||
updateScrollerTabBadges(sCachedNotifs);
|
updateScrollerTabBadges(sCachedNotifs);
|
||||||
@@ -3699,12 +3673,7 @@
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
const nid = item.dataset.id;
|
const nid = item.dataset.id;
|
||||||
if (nid && item.classList.contains('unread')) {
|
if (nid && item.classList.contains('unread')) {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
|
||||||
fetch(`/api/notifications/${nid}/read`, {
|
|
||||||
method: 'POST',
|
|
||||||
keepalive: true,
|
|
||||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
|
||||||
}).catch(() => {});
|
|
||||||
item.classList.remove('unread');
|
item.classList.remove('unread');
|
||||||
// Update cache
|
// Update cache
|
||||||
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = unsafe;
|
div.textContent = unsafe;
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSidebarEmojiVideos = (container) => {
|
const playSidebarEmojiVideos = (container) => {
|
||||||
@@ -153,6 +153,12 @@
|
|||||||
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
||||||
if (!content) return '';
|
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') {
|
if (typeof marked === 'undefined') {
|
||||||
return escapeHtml(content)
|
return escapeHtml(content)
|
||||||
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||||
@@ -262,10 +268,9 @@
|
|||||||
// Manual greentext handling — apply emoji if the user preference allows it
|
// Manual greentext handling — apply emoji if the user preference allows it
|
||||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
|
||||||
const rendered = quoteEmojis
|
const rendered = quoteEmojis
|
||||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||||
: escapedQuote;
|
: quoteContent;
|
||||||
return `<span class="greentext">>${rendered}</span>`;
|
return `<span class="greentext">>${rendered}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'f0ck98', 'iced', 'orange', 'p1nk', '4d'];
|
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||||
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
||||||
|
|
||||||
const setTheme = (theme) => {
|
const setTheme = (theme) => {
|
||||||
|
|||||||
@@ -306,12 +306,7 @@ if (!window.UserCommentSystem) {
|
|||||||
const trimmed = line.trimStart();
|
const trimmed = line.trimStart();
|
||||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
return `<span class="greentext">>${quoteContent}</span>`;
|
||||||
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
|
// Per-line limit
|
||||||
@@ -455,7 +450,7 @@ if (!window.UserCommentSystem) {
|
|||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = unsafe;
|
div.textContent = unsafe;
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const cssDir = path.join(process.cwd(), 'public/s/css');
|
const cssDir = path.join(process.cwd(), 'public/s/css');
|
||||||
const files = ['f0ckm.css', 'v0ck.css', '98.css'];
|
const files = ['f0ckm.css', 'v0ck.css'];
|
||||||
const outputFile = path.join(cssDir, 'bundle.css');
|
const outputFile = path.join(cssDir, 'bundle.css');
|
||||||
|
|
||||||
let combined = '';
|
let combined = '';
|
||||||
|
|||||||
@@ -460,15 +460,4 @@ export default new class {
|
|||||||
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
||||||
return `Internal Error. Reference: ${errId}`;
|
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');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1428,8 +1428,8 @@ export default (router, tpl) => {
|
|||||||
const userId = newUser[0].id;
|
const userId = newUser[0].id;
|
||||||
|
|
||||||
await db`
|
await db`
|
||||||
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
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}, ${cfg.websrv.user_alternative_infobox !== false})
|
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
|
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
|
||||||
|
|||||||
212
src/inc/routes/apiv2/svelte.mjs
Normal file
212
src/inc/routes/apiv2/svelte.mjs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* f0ckm API v2 — JSON endpoints for the new SvelteKit frontend.
|
||||||
|
*
|
||||||
|
* These routes live alongside the existing flummpress routes and are
|
||||||
|
* purely additive — nothing in the old app is changed or removed.
|
||||||
|
*
|
||||||
|
* Mounted at: /api/v2/
|
||||||
|
*/
|
||||||
|
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||||
|
import cfg from '../../config.mjs';
|
||||||
|
|
||||||
|
const json = (res, body, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (router, _tpl) => {
|
||||||
|
|
||||||
|
// ── GET /api/v2/session ───────────────────────────────────────────────────
|
||||||
|
router.get('/api/v2/session', async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return json(res, { loggedIn: false, user: null });
|
||||||
|
}
|
||||||
|
return json(res, {
|
||||||
|
loggedIn: true,
|
||||||
|
user: {
|
||||||
|
id: req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
login: req.session.login,
|
||||||
|
admin: !!req.session.admin,
|
||||||
|
is_moderator: !!req.session.is_moderator,
|
||||||
|
mode: req.session.mode ?? 3,
|
||||||
|
theme: req.session.theme ?? 'f0ck',
|
||||||
|
avatar: req.session.avatar ?? null,
|
||||||
|
avatar_file: req.session.avatar_file ?? null,
|
||||||
|
username_color: req.session.username_color ?? null,
|
||||||
|
display_name: req.session.display_name ?? null,
|
||||||
|
use_new_layout: req.session.use_new_layout ?? true,
|
||||||
|
excluded_tags: req.session.excluded_tags ?? [],
|
||||||
|
min_xd_score: req.session.min_xd_score ?? 0,
|
||||||
|
uploads_remaining: req.session.uploads_remaining,
|
||||||
|
pending_count: req.session.pending_count,
|
||||||
|
language: req.session.language ?? null,
|
||||||
|
disable_autoplay: !!req.session.disable_autoplay,
|
||||||
|
disable_swiping: !!req.session.disable_swiping,
|
||||||
|
csrf_token: req.session.csrf_token ?? ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v2/items ─────────────────────────────────────────────────────
|
||||||
|
router.get('/api/v2/items', async (req, res) => {
|
||||||
|
const q = req.url.qs ?? {};
|
||||||
|
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
|
||||||
|
const ratingsArr = ratingsRaw
|
||||||
|
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mode = q.mode !== undefined ? +q.mode : req.mode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await f0cklib.getf0cks({
|
||||||
|
page: q.page ? +q.page : 1,
|
||||||
|
mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
|
mime: q.mime ?? (req.cookies?.mime ?? null),
|
||||||
|
tag: q.tag ?? null,
|
||||||
|
hall: q.hall ?? null,
|
||||||
|
user: q.user ?? null,
|
||||||
|
userHall: q.userHall ?? null,
|
||||||
|
userHallOwner: q.userHallOwner ?? null,
|
||||||
|
fav: q.fav === 'true',
|
||||||
|
random: q.random === '1' || req.cookies?.random_mode === '1',
|
||||||
|
strict: q.strict === '1' || q.strict === 'true' || !!req.session?.strict_mode,
|
||||||
|
session: !!req.session,
|
||||||
|
user_id: req.session?.id ?? null,
|
||||||
|
exclude: req.session?.excluded_tags ?? [],
|
||||||
|
minXdScore: req.session?.min_xd_score ?? 0,
|
||||||
|
newer: q.newer ?? null,
|
||||||
|
tagger: q.tagger ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return json(res, { success: false, items: [], pagination: null, total: 0, link: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
items: data.items,
|
||||||
|
pagination: data.pagination,
|
||||||
|
total: data.total,
|
||||||
|
link: data.link
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API v2] /items error:', e);
|
||||||
|
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v2/items/:id ─────────────────────────────────────────────────
|
||||||
|
router.get(/^\/api\/v2\/items\/(?<itemid>\d+)$/, async (req, res) => {
|
||||||
|
const itemid = req.params.itemid;
|
||||||
|
const q = req.url.qs ?? {};
|
||||||
|
const ratingsRaw = req.cookies?.ratings ?? q.ratings ?? null;
|
||||||
|
const ratingsArr = ratingsRaw
|
||||||
|
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mode = q.mode !== undefined ? +q.mode : req.mode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await f0cklib.getf0ck({
|
||||||
|
itemid,
|
||||||
|
mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
|
session: !!req.session,
|
||||||
|
user_id: req.session?.id ?? null,
|
||||||
|
user: q.user ?? null,
|
||||||
|
tag: q.tag ?? null,
|
||||||
|
hall: q.hall ?? null,
|
||||||
|
userHall: q.userHall ?? null,
|
||||||
|
userHallOwner: q.userHallOwner ?? null,
|
||||||
|
mime: q.mime ?? (req.cookies?.mime ?? null),
|
||||||
|
fav: q.fav === 'true',
|
||||||
|
random: q.random === '1',
|
||||||
|
strict: q.strict === '1' || !!req.session?.strict_mode,
|
||||||
|
exclude: req.session?.excluded_tags ?? [],
|
||||||
|
lang: req.lang ?? 'en'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return json(res, { success: false, item: null, message: data.message }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute hall display data (mirrors existing index route logic)
|
||||||
|
if (data.item?.halls?.length) {
|
||||||
|
const currentHallSlug = data.tmp?.hall
|
||||||
|
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||||
|
: null;
|
||||||
|
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||||
|
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||||
|
} else if (data.item) {
|
||||||
|
data.item.primaryHall = null;
|
||||||
|
data.item.otherHalls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription status
|
||||||
|
const isSubscribed = req.session
|
||||||
|
? await f0cklib.getSubscriptionStatus(req.session.id, itemid).catch(() => false)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// xD Score
|
||||||
|
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||||
|
const commentsForScore = await f0cklib.getComments(itemid, 'old', false);
|
||||||
|
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||||
|
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||||
|
data.item.xd_score = xdScore;
|
||||||
|
data.item.xd_tier = xdMeta.tier;
|
||||||
|
data.item.xd_label = xdMeta.label;
|
||||||
|
} else {
|
||||||
|
data.item.xd_score = 0;
|
||||||
|
data.item.xd_tier = 0;
|
||||||
|
data.item.xd_label = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify read
|
||||||
|
if (req.session?.id) {
|
||||||
|
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
item: data.item,
|
||||||
|
pagination: data.pagination,
|
||||||
|
link: data.link,
|
||||||
|
isSubscribed
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API v2] /items/:id error:', e);
|
||||||
|
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v2/random ────────────────────────────────────────────────────
|
||||||
|
router.get('/api/v2/random', async (req, res) => {
|
||||||
|
const ratingsRaw = req.cookies?.ratings ?? null;
|
||||||
|
const ratingsArr = ratingsRaw
|
||||||
|
? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await f0cklib.getRandom({
|
||||||
|
mode: req.mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
|
session: !!req.session,
|
||||||
|
mime: req.cookies?.mime ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return json(res, { success: false, message: data.message }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = encodeURI(`${data.link.main}${data.link.path ?? ''}${data.itemid}${data.link.suffix ?? ''}`);
|
||||||
|
return json(res, { success: true, id: data.itemid, url });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API v2] /random error:', e);
|
||||||
|
return json(res, { success: false, error: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -25,8 +25,7 @@ export default router => {
|
|||||||
|
|
||||||
group.post(/$/, lib.loggedin, async (req, res) => {
|
group.post(/$/, lib.loggedin, async (req, res) => {
|
||||||
// assign and/or create tag
|
// assign and/or create tag
|
||||||
const rawTagname = req.post?.tagname || req.body?.tagname;
|
if (!req.params.postid || !req.post.tagname) {
|
||||||
if (!req.params.postid || !rawTagname) {
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
msg: 'missing postid or tag'
|
msg: 'missing postid or tag'
|
||||||
@@ -34,7 +33,7 @@ export default router => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postid = +req.params.postid;
|
const postid = +req.params.postid;
|
||||||
const tagname = rawTagname.trim();
|
const tagname = req.post.tagname?.trim();
|
||||||
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
||||||
|
|
||||||
if (protectedTags.includes(tagname.toLowerCase())) {
|
if (protectedTags.includes(tagname.toLowerCase())) {
|
||||||
|
|||||||
@@ -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.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
|
||||||
}
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() }, req)
|
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export default (router, tpl) => {
|
|||||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||||
}
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() }, req)
|
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export default (router, tpl) => {
|
|||||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
||||||
}
|
}
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() }, req)
|
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,9 +88,8 @@ export default (router, tpl) => {
|
|||||||
return renderError("Passwords do not match.");
|
return renderError("Passwords do not match.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
|
// reCAPTCHA verification
|
||||||
const isOnion = lib.isOnionRequest(req);
|
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
||||||
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
|
||||||
const rcToken = req.post['g-recaptcha-response'];
|
const rcToken = req.post['g-recaptcha-response'];
|
||||||
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
|
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
|
||||||
try {
|
try {
|
||||||
@@ -168,8 +167,8 @@ export default (router, tpl) => {
|
|||||||
const avatarFile = 'default.png';
|
const avatarFile = 'default.png';
|
||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
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}, ${cfg.websrv.user_alternative_infobox !== false})
|
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[REGISTER] DB Error during user creation:`, 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) {
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
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),
|
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
hasMore: tags.length === TAGS_PER_PAGE
|
hasMore: tags.length === TAGS_PER_PAGE
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,6 @@ export default (router, tpl) => {
|
|||||||
phrase,
|
phrase,
|
||||||
tmp: null,
|
tmp: null,
|
||||||
hidePagination: true,
|
hidePagination: true,
|
||||||
user: req.session?.user ? { user: req.session.user } : null,
|
|
||||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||||
page_meta: {
|
page_meta: {
|
||||||
title: 'Tags',
|
title: 'Tags',
|
||||||
|
|||||||
156
src/index.mjs
156
src/index.mjs
@@ -49,7 +49,7 @@ const gateOptions = {
|
|||||||
},
|
},
|
||||||
cloudflare_status: {
|
cloudflare_status: {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
location: cfg.websrv.private_society_gate_location || 'Frankfurt',
|
location: 'Frankfurt',
|
||||||
name: 'Cloudflare',
|
name: 'Cloudflare',
|
||||||
status_text: 'Working',
|
status_text: 'Working',
|
||||||
},
|
},
|
||||||
@@ -79,13 +79,11 @@ const nginx502Fallback = `<html>
|
|||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
// Login + Register modal injected before </body>
|
// Login + Register modal injected before </body>
|
||||||
// Dynamic function so reCAPTCHA can be bypassed for .onion requests
|
// This string is built at startup so it can reference cfg.recaptcha values.
|
||||||
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
||||||
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
|
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
|
||||||
|
|
||||||
function getGateLoginInjection(req) {
|
const gateLoginInjection = `
|
||||||
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="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;">
|
<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;">
|
||||||
@@ -125,7 +123,7 @@ function getGateLoginInjection(req) {
|
|||||||
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
|
<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;" />
|
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" />
|
<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;"
|
<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>
|
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;">
|
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
|
||||||
@@ -266,9 +264,8 @@ function getGateLoginInjection(req) {
|
|||||||
if (_sb.length > 12) _sb = _sb.slice(-12);
|
if (_sb.length > 12) _sb = _sb.slice(-12);
|
||||||
});
|
});
|
||||||
</script>
|
</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
|
// Text injected into the "What can I do?" section
|
||||||
@@ -285,40 +282,11 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Called on every gated request — produces a fresh page with unique Ray ID + timestamp
|
// 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 and dynamic host into page text/title.
|
// Accepts an optional request object to inject the visitor's real IP into the footer reveal.
|
||||||
function buildGatePage(req) {
|
function buildGatePage(req) {
|
||||||
if (!_cfRender) return nginx502Fallback;
|
if (!_cfRender) return nginx502Fallback;
|
||||||
|
|
||||||
let reqHost = null;
|
let html = _cfRender({ ...gateOptions, what_can_i_do: gateSignInButton });
|
||||||
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
|
// Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
|
||||||
if (req) {
|
if (req) {
|
||||||
@@ -337,9 +305,9 @@ function buildGatePage(req) {
|
|||||||
// Patch the <title> to include the domain — matches real Cloudflare behaviour
|
// Patch the <title> to include the domain — matches real Cloudflare behaviour
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
'<title>502: Bad Gateway</title>',
|
'<title>502: Bad Gateway</title>',
|
||||||
`<title>${reqHost} | 502: Bad gateway</title>`
|
`<title>${cfg.main.url.domain} | 502: Bad gateway</title>`
|
||||||
);
|
);
|
||||||
html = html.replace('</body>', getGateLoginInjection(req) + '\n</body>');
|
html = html.replace('</body>', gateLoginInjection + '\n</body>');
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,14 +316,7 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
|
|||||||
? null
|
? null
|
||||||
: nginx502Fallback;
|
: 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 (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)`);
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -495,22 +456,6 @@ process.on('uncaughtException', err => {
|
|||||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
|
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
|
||||||
res.setHeader('Connection', 'keep-alive');
|
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) {
|
if (isSecure) {
|
||||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
}
|
}
|
||||||
@@ -659,7 +604,7 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'f0ck98', 'iced', 'orange', 'p1nk', '4d'];
|
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||||
const defaultTheme = cfg.websrv.theme || 'amoled';
|
const defaultTheme = cfg.websrv.theme || 'amoled';
|
||||||
req.theme = (req.cookies?.theme && availableThemes.includes(req.cookies.theme)) ? req.cookies.theme : defaultTheme;
|
req.theme = (req.cookies?.theme && availableThemes.includes(req.cookies.theme)) ? req.cookies.theme : defaultTheme;
|
||||||
req.fullscreen = req.cookies.fullscreen || 0;
|
req.fullscreen = req.cookies.fullscreen || 0;
|
||||||
@@ -669,7 +614,9 @@ process.on('uncaughtException', err => {
|
|||||||
let _cachedRow = _scGet(_sessionHash);
|
let _cachedRow = _scGet(_sessionHash);
|
||||||
let user;
|
let user;
|
||||||
if (_cachedRow) {
|
if (_cachedRow) {
|
||||||
user = [_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 }];
|
||||||
} else {
|
} else {
|
||||||
user = await db`
|
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".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
|
||||||
@@ -679,7 +626,8 @@ process.on('uncaughtException', err => {
|
|||||||
where "user_sessions".session = ${_sessionHash}
|
where "user_sessions".session = ${_sessionHash}
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
if (user.length > 0) _scSet(_sessionHash, user[0]);
|
// 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) {
|
if (user.length === 0) {
|
||||||
@@ -856,21 +804,18 @@ process.on('uncaughtException', err => {
|
|||||||
req.url.pathname = '/private_society_bypass';
|
req.url.pathname = '/private_society_bypass';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// For page requests, serve the gate for all paths (custom template or fallback)
|
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate)
|
||||||
if (_customGateTemplate) {
|
if (req.url.pathname !== '/') {
|
||||||
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));
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
||||||
} else if (nginx502 === null) {
|
req.url.pathname = '/private_society_bypass';
|
||||||
// Homepage in cloudflare dynamic mode: serve the CF gate directly
|
return;
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
|
}
|
||||||
} else {
|
// Homepage: in cloudflare dynamic mode serve gate directly; otherwise fall through to template
|
||||||
// Homepage in plain nginx mode: fall through to index.html template
|
if (nginx502 === null) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
|
||||||
|
req.url.pathname = '/private_society_bypass';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req.url.pathname = '/private_society_bypass';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1350,7 +1295,6 @@ process.on('uncaughtException', err => {
|
|||||||
show_mime_picker: cfg.websrv.show_mime_picker !== false,
|
show_mime_picker: cfg.websrv.show_mime_picker !== false,
|
||||||
private_society: cfg.websrv.private_society || false,
|
private_society: cfg.websrv.private_society || false,
|
||||||
private_society_gate: cfg.websrv.private_society_gate || '',
|
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,
|
show_content_warning: cfg.websrv.show_content_warning !== false,
|
||||||
web_url_upload: !!cfg.websrv.web_url_upload,
|
web_url_upload: !!cfg.websrv.web_url_upload,
|
||||||
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
|
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
|
||||||
@@ -1462,39 +1406,26 @@ 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
|
// 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.
|
// so t/lang always reflect the user's language, not the site default.
|
||||||
// ALSO mutate globals.t, globals.lang, globals.recaptcha_enabled: flummpress spreads this.#globals LAST
|
// ALSO mutate globals.t and globals.lang: flummpress spreads this.#globals LAST
|
||||||
// inside render(), so globals must carry the per-request values too.
|
// inside render(), so globals must carry the per-request values too.
|
||||||
globals.t = perRequestT;
|
globals.t = perRequestT;
|
||||||
globals.lang = perRequestLang;
|
globals.lang = perRequestLang;
|
||||||
globals.recaptcha_enabled = perRequestRecaptcha;
|
|
||||||
|
|
||||||
// Resolve per-request infobox preference
|
// Resolve per-request infobox preference
|
||||||
// Guests always get false — the alternative infobox is a logged-in user preference only
|
// Guests always get false — the alternative infobox is a logged-in user preference only
|
||||||
const activeReq = req || effectiveReq;
|
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
|
||||||
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
|
? req.session.use_alternative_infobox
|
||||||
? activeReq.session.use_alternative_infobox
|
: (req && !req.session
|
||||||
: (activeReq && !activeReq.session
|
|
||||||
? false
|
? false
|
||||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||||
? data.user_alternative_infobox
|
? data.user_alternative_infobox
|
||||||
: (cfg.websrv.user_alternative_infobox !== false)));
|
: (cfg.websrv.user_alternative_infobox !== false)));
|
||||||
|
|
||||||
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
|
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean')
|
||||||
? activeReq.session.use_alternative_steuerung
|
? req.session.use_alternative_steuerung
|
||||||
: (activeReq && !activeReq.session
|
: (req && !req.session
|
||||||
? false
|
? false
|
||||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||||
? data.user_alternative_steuerung
|
? data.user_alternative_steuerung
|
||||||
@@ -1503,12 +1434,11 @@ process.on('uncaughtException', err => {
|
|||||||
data = Object.assign({}, globals, data || {}, {
|
data = Object.assign({}, globals, data || {}, {
|
||||||
t: perRequestT,
|
t: perRequestT,
|
||||||
lang: perRequestLang,
|
lang: perRequestLang,
|
||||||
recaptcha_enabled: perRequestRecaptcha,
|
|
||||||
user_alternative_infobox: useAltInfobox,
|
user_alternative_infobox: useAltInfobox,
|
||||||
user_alternative_steuerung: useAltSteuerung,
|
user_alternative_steuerung: useAltSteuerung,
|
||||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
|
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
||||||
? activeReq.session.comment_display_mode
|
? req.session.comment_display_mode
|
||||||
: (data && typeof data.comment_display_mode === 'number'
|
: (data && typeof data.comment_display_mode === 'number'
|
||||||
? data.comment_display_mode
|
? data.comment_display_mode
|
||||||
: (cfg.websrv.default_comment_display_mode || 0))
|
: (cfg.websrv.default_comment_display_mode || 0))
|
||||||
@@ -1520,19 +1450,17 @@ process.on('uncaughtException', err => {
|
|||||||
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
|
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeReq) {
|
if (req) {
|
||||||
data.recaptcha_enabled = perRequestRecaptcha;
|
if (req.mode !== undefined) data.mode = req.mode;
|
||||||
if (activeReq.mode !== undefined) data.mode = activeReq.mode;
|
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
||||||
data.theme = activeReq.theme || activeReq.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
if (!data.url) data.url = req.url;
|
||||||
if (!data.url) data.url = activeReq.url;
|
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false;
|
||||||
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
|
data.user_logged_in_bool = !!req.session;
|
||||||
data.user_logged_in_bool = !!activeReq.session;
|
data.csrf_token = req.session?.csrf_token || '';
|
||||||
data.csrf_token = activeReq.session?.csrf_token || '';
|
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||||
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 * (req.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;
|
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
||||||
} else {
|
} else {
|
||||||
data.recaptcha_enabled = perRequestRecaptcha;
|
|
||||||
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
||||||
data.user_strict_bool = false;
|
data.user_strict_bool = false;
|
||||||
data.user_logged_in_bool = false;
|
data.user_logged_in_bool = false;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<!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 () => {
|
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||||
var res = await fetch('/api/v2/admin/users/activate', {
|
var res = await fetch('/api/v2/admin/users/activate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id })
|
body: JSON.stringify({ user_id: id })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
|
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
|
||||||
var res = await fetch('/api/v2/admin/ban', {
|
var res = await fetch('/api/v2/admin/ban', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
|
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||||
var res = await fetch('/api/v2/admin/unban', {
|
var res = await fetch('/api/v2/admin/unban', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id })
|
body: JSON.stringify({ user_id: id })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
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 () => {
|
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', {
|
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
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 () => {
|
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', {
|
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
ModAction.confirm('Set Display Name', hint, async (newName) => {
|
ModAction.confirm('Set Display Name', hint, async (newName) => {
|
||||||
var res = await fetch('/api/v2/admin/users/set-display-name', {
|
var res = await fetch('/api/v2/admin/users/set-display-name', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, display_name: newName || '' })
|
body: JSON.stringify({ user_id: id, display_name: newName || '' })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
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 () => {
|
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', {
|
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, lock: false })
|
body: JSON.stringify({ user_id: id, lock: false })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
var mode = document.getElementById('force-mode-select').value;
|
var mode = document.getElementById('force-mode-select').value;
|
||||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
|
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
try {
|
try {
|
||||||
var res = await fetch('/api/v2/admin/users/create', {
|
var res = await fetch('/api/v2/admin/users/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, email: email || null, password, role })
|
body: JSON.stringify({ username, email: email || null, password, role })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
@@ -662,7 +662,7 @@
|
|||||||
var role = selectEl.value;
|
var role = selectEl.value;
|
||||||
var res = await fetch('/api/v2/admin/users/set-role', {
|
var res = await fetch('/api/v2/admin/users/set-role', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: id, role })
|
body: JSON.stringify({ user_id: id, role })
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
|
|||||||
@@ -296,7 +296,7 @@
|
|||||||
@if(item.src.short)
|
@if(item.src.short)
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||||
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -238,7 +238,7 @@
|
|||||||
@if(item.src.short)
|
@if(item.src.short)
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||||
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
|
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
|
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
|
||||||
|
|
||||||
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
|
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
|
||||||
@media (max-width: 1250px) {
|
@media (max-width: 950px) {
|
||||||
/* Target all possible shell triggers */
|
/* Target all possible shell triggers */
|
||||||
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
|
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes">
|
<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-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
|
<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>
|
<style>
|
||||||
html { background-color: #000; color: #fff; }
|
html { background-color: #000; color: #fff; }
|
||||||
@if(session && session.font)
|
@if(session && session.font)
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
}
|
}
|
||||||
@endif
|
@endif
|
||||||
:root {
|
:root {
|
||||||
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
|
|
||||||
@if(session && session.font)
|
@if(session && session.font)
|
||||||
--font: 'CustomUserFont', monospace !important;
|
--font: 'CustomUserFont', monospace !important;
|
||||||
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
|
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
|
||||||
@@ -56,7 +54,6 @@
|
|||||||
@if(!private_society || session)
|
@if(!private_society || session)
|
||||||
@if(development || (private_society && !session))
|
@if(development || (private_society && !session))
|
||||||
<link rel="stylesheet" href="/s/css/f0ckm.css?v={{ ts }}">
|
<link rel="stylesheet" href="/s/css/f0ckm.css?v={{ ts }}">
|
||||||
<link rel="stylesheet" href="/s/css/98.css?v={{ ts }}">
|
|
||||||
@endif
|
@endif
|
||||||
@if(development)
|
@if(development)
|
||||||
<link rel="stylesheet" href="/s/css/v0ck.css?v={{ ts }}">
|
<link rel="stylesheet" href="/s/css/v0ck.css?v={{ ts }}">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<div class="profile_head_username">
|
<div class="profile_head_username">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
|
<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">
|
<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="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
|
<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
|
||||||
@if(session && session.id === user.user_id)
|
@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">
|
<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>
|
<i class="fa-solid fa-pen"></i>
|
||||||
@@ -509,27 +509,18 @@
|
|||||||
nameContainer.style.display = 'flex';
|
nameContainer.style.display = 'flex';
|
||||||
nameEditContainer.style.display = 'none';
|
nameEditContainer.style.display = 'none';
|
||||||
|
|
||||||
const updateNameNode = (el, text) => {
|
const nameSpan = document.getElementById('nav-display-name');
|
||||||
if (!el) return;
|
if (nameSpan) {
|
||||||
for (let i = el.childNodes.length - 1; i >= 0; i--) {
|
nameSpan.style.color = color;
|
||||||
let node = el.childNodes[i];
|
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) {
|
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
|
||||||
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
|
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
|
||||||
node.nodeValue = (match ? match[1] : '') + text;
|
const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)'));
|
||||||
|
node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}');
|
||||||
break;
|
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');
|
const bracket = document.getElementById('username-bracket');
|
||||||
if (bracket) {
|
if (bracket) {
|
||||||
|
|||||||
Reference in New Issue
Block a user