37 Commits

Author SHA1 Message Date
1324512ebf svelte lol 2026-07-19 19:40:25 +02:00
3344064030 tags 2026-07-19 07:37:13 +02:00
e998ba8fc7 chickendöner 2026-07-19 03:30:52 +02:00
6b087efab3 xd 2026-07-19 02:12:58 +02:00
6c9f019646 350 2026-07-19 02:11:05 +02:00
d35437373f possible fix for 502 2026-07-19 00:15:36 +02:00
6efb67f2f5 fuck this auto tagging 2026-07-18 23:44:03 +02:00
fecb0f0b0c tag depths 2026-07-18 23:33:25 +02:00
d89c610dcb gfd 2026-07-18 23:09:28 +02:00
93c042251a set default font via config 2026-07-18 22:16:56 +02:00
990de4b710 vertalign 2026-07-18 22:03:33 +02:00
2f9ad4d349 gfd 2026-07-18 17:21:04 +02:00
701bee7a9a fdsa 2026-07-18 17:15:35 +02:00
4b7fba11ad smaller font = better 2026-07-18 17:11:40 +02:00
6f8eb6f83f g 2026-07-18 17:07:55 +02:00
d78b31633a bbb 2026-07-18 16:52:01 +02:00
294cf2455a hide sidebar yes no 2026-07-18 16:48:55 +02:00
663ba13d18 starto 2026-07-18 16:30:22 +02:00
84c3f1ccda gfsd 2026-07-18 16:18:22 +02:00
4dc7f10ae2 kibort 2026-07-18 16:15:47 +02:00
3ff1edf8bf I do love my keyboard 2026-07-18 16:11:06 +02:00
9ad6eaefec gfsd 2026-07-18 16:01:44 +02:00
d4eb0e52d8 confusion 2026-07-18 15:58:59 +02:00
ce54ed460d fugg :D 2026-07-18 15:40:12 +02:00
cddc763c44 consistency refe 2026-07-18 15:36:49 +02:00
61036558a0 Revert "awesome!!"
This reverts commit 8f0e0d9b3a.
2026-07-18 15:03:02 +02:00
3354baf09f Revert "ff"
This reverts commit c3c485af79.
2026-07-18 15:01:01 +02:00
c3c485af79 ff 2026-07-18 14:59:46 +02:00
8f0e0d9b3a awesome!! 2026-07-18 14:45:46 +02:00
fefd3b6ca6 gdf 2026-07-18 14:37:40 +02:00
5818a0ff39 dragging!! 2026-07-18 14:34:18 +02:00
036a9d372d im pathetic at this point 2026-07-18 14:27:04 +02:00
bef96160db the most pathetic fix to exist yet 2026-07-18 14:24:21 +02:00
a99c17c16b actually useful fix 2026-07-18 14:08:50 +02:00
469f699dd7 button fligger 2026-07-18 13:59:43 +02:00
d3f8c87317 stripping source identifier for IG 2026-07-18 13:54:04 +02:00
568555eab8 62% 2026-07-18 11:18:10 +02:00
79 changed files with 5965 additions and 134 deletions

View File

@@ -53,11 +53,13 @@
"amoled" "amoled"
], ],
"theme": "amoled", "theme": "amoled",
"default_font": "",
"default_layout": "legacy", "default_layout": "legacy",
"custom_favicon": "/s/img/favicon.gif", "custom_favicon": "/s/img/favicon.gif",
"custom_brand_image": [], "custom_brand_image": [],
"custom_navbar_brand_text": "", "custom_navbar_brand_text": "",
"show_koepfe": false, "show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [], "koepfe": [],
"enable_global_chat": true, "enable_global_chat": true,
"enable_danmaku": true, "enable_danmaku": true,

23
f0ck-svelte/.gitignore vendored Normal file
View 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
View File

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

3
f0ck-svelte/.vscode/extensions.json vendored Normal file
View File

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

42
f0ck-svelte/README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

28
f0ck-svelte/package.json Normal file
View 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
View 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
View 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
View 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>

View 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;
}

View 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();
}

View 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' });
}

View 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();
}

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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}

View 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>

View 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>

View 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>

View 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}

View File

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

View 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));
}

View 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);
}

View 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;
}

View 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));

View 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');
}

View 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);
}

View 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;
}

View 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;
}

View 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;
}

View 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 });
}

View 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));
}

View 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'
});
}

View 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
};
};

View 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>

View 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: '' }
};
};

View 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>

View 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');
}
};

View 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}
/>

View 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');
};

View 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, '/');
};

View 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');
};

View 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: '' }
};
};

View 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>

View 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');
}
};

View 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}
/>

View File

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

21
f0ck-svelte/tsconfig.json Normal file
View 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
}

View 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 }
}
}
});

View File

@@ -1593,7 +1593,7 @@ html[theme='95'] .item-main-content .metadata {
max-width: 85%; max-width: 85%;
height: auto !important; height: auto !important;
max-height: none !important; max-height: none !important;
background: var(--bg); background: var(--nav-bg);
border-left: 1px solid var(--nav-border-color); border-left: 1px solid var(--nav-border-color);
z-index: 900 !important; z-index: 900 !important;
/* below navbar dropdowns (1000+) but above page content */ /* below navbar dropdowns (1000+) but above page content */
@@ -1609,22 +1609,22 @@ body.sidebar-right-hidden .global-sidebar-right {
} }
body.layout-modern.sidebar-right-hidden .global-sidebar-right { body.layout-modern.sidebar-right-hidden .global-sidebar-right {
right: -400px !important; right: -300px !important;
} }
/* Modern layout: wider 400px sidebar */ /* Modern layout: wider 400px sidebar */
/* no more lol xd */
body.layout-modern .item-sidebar-right, body.layout-modern .item-sidebar-right,
body.layout-modern .index-sidebar-right, body.layout-modern .index-sidebar-right,
body.layout-modern .global-sidebar-right { body.layout-modern .global-sidebar-right {
width: 400px; width: 300px;
} }
/* Prevent native image drag from hijacking the sidebar mouse-drag gesture */ /* Prevent native image drag from hijacking the sidebar mouse-drag gesture */
.global-sidebar-right img, .global-sidebar-right img,
.item-sidebar-right img, .item-sidebar-right img,
.index-sidebar-right img { .index-sidebar-right img {
-webkit-user-drag: none;
user-select: none;
} }
/* Edge zone drag handle — simple centered vertical pill */ /* Edge zone drag handle — simple centered vertical pill */
@@ -1727,7 +1727,7 @@ body.layout-modern .global-sidebar-right {
---------------------------------------------------------- */ ---------------------------------------------------------- */
body.layout-modern .item-layout-container { body.layout-modern .item-layout-container {
display: grid; display: grid;
grid-template-columns: min(400px, 30vw) minmax(0, 1fr) 400px; grid-template-columns: min(350px, 30vw) minmax(0, 1fr) 300px;
grid-template-rows: auto; grid-template-rows: auto;
flex: 1; flex: 1;
height: calc(100vh - var(--navbar-h, 50px)); height: calc(100vh - var(--navbar-h, 50px));
@@ -1738,7 +1738,7 @@ body.layout-modern .global-sidebar-right {
/* Collapsed right sidebar — grid track shrinks to 0 */ /* Collapsed right sidebar — grid track shrinks to 0 */
body.layout-modern.sidebar-right-hidden .item-layout-container { body.layout-modern.sidebar-right-hidden .item-layout-container {
grid-template-columns: min(400px, 30vw) minmax(0, 1fr) 0px; grid-template-columns: min(300px, 30vw) minmax(0, 1fr) 0px;
} }
body.layout-modern .item-sidebar-left { body.layout-modern .item-sidebar-left {
@@ -1782,7 +1782,7 @@ body.layout-modern .global-sidebar-right {
body.layout-modern .item-layout-container .item-main-content > * { body.layout-modern .item-layout-container .item-main-content > * {
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
max-width: 1100px; max-width: 800px;
} }
body.layout-modern .item-layout-container .item-main-content .metadata { body.layout-modern .item-layout-container .item-main-content .metadata {
@@ -1803,11 +1803,11 @@ body.layout-modern .global-sidebar-right {
/* Sidebar toggle: hide left sidebar (e.g. keybind toggle) */ /* Sidebar toggle: hide left sidebar (e.g. keybind toggle) */
body.layout-modern .item-layout-container.sidebar-hidden { body.layout-modern .item-layout-container.sidebar-hidden {
grid-template-columns: minmax(0, 1fr) 400px; grid-template-columns: 0px minmax(0, 1fr) 400px;
} }
body.layout-modern.sidebar-right-hidden .item-layout-container.sidebar-hidden { body.layout-modern.sidebar-right-hidden .item-layout-container.sidebar-hidden {
grid-template-columns: 1fr; grid-template-columns: 0px minmax(0, 1fr) 0px;
} }
/* By the time sidebar-hidden is added (post-slide), content is already off-screen */ /* By the time sidebar-hidden is added (post-slide), content is already off-screen */
@@ -1816,18 +1816,18 @@ body.layout-modern .global-sidebar-right {
} }
body.layout-modern .item-layout-container.sidebar-hidden .item-main-content { body.layout-modern .item-layout-container.sidebar-hidden .item-main-content {
grid-column: 1; grid-column: 2;
} }
/* Body-level left-sidebar hide — set by blocking inline script before first paint /* Body-level left-sidebar hide — set by blocking inline script before first paint
to prevent the sidebar from flashing visible. Mirrors the .sidebar-hidden rules above to prevent the sidebar from flashing visible. Mirrors the .sidebar-hidden rules above
but activates from the body class so it works before the container element exists. */ but activates from the body class so it works before the container element exists. */
body.layout-modern.sidebar-left-hidden .item-layout-container { body.layout-modern.sidebar-left-hidden .item-layout-container {
grid-template-columns: minmax(0, 1fr) 400px; grid-template-columns: 0px minmax(0, 1fr) 400px;
} }
body.layout-modern.sidebar-left-hidden.sidebar-right-hidden .item-layout-container { body.layout-modern.sidebar-left-hidden.sidebar-right-hidden .item-layout-container {
grid-template-columns: 1fr; grid-template-columns: 0px minmax(0, 1fr) 0px;
} }
body.layout-modern.sidebar-left-hidden .item-layout-container .item-sidebar-left { body.layout-modern.sidebar-left-hidden .item-layout-container .item-sidebar-left {
@@ -1835,7 +1835,7 @@ body.layout-modern .global-sidebar-right {
} }
body.layout-modern.sidebar-left-hidden .item-layout-container .item-main-content { body.layout-modern.sidebar-left-hidden .item-layout-container .item-main-content {
grid-column: 1; grid-column: 2;
} }
body.layout-modern .item-sidebar-right, body.layout-modern .item-sidebar-right,
@@ -3988,7 +3988,7 @@ body {
font-family: var(--font); font-family: var(--font);
line-height: 2; line-height: 2;
overflow-x: hidden; overflow-x: hidden;
font-size: 1em; font-size: medium;
} }
html { html {
@@ -4072,6 +4072,7 @@ div.posts>a:not(.notif-item)::before {
} }
div.posts>a:not(.notif-item):hover, div.posts>a:not(.notif-item):hover,
div.posts>a:not(.notif-item):focus-visible,
div.posts>a:not(.notif-item).touch-active { div.posts>a:not(.notif-item).touch-active {
box-shadow: 0 0 0 1px var(--white); box-shadow: 0 0 0 1px var(--white);
} }
@@ -4094,6 +4095,7 @@ div.posts>a:not(.notif-item)::after {
} }
div.posts>a:not(.notif-item):hover::after, div.posts>a:not(.notif-item):hover::after,
div.posts>a:not(.notif-item):focus-visible::after,
div.posts>a:not(.notif-item).touch-active::after { div.posts>a:not(.notif-item).touch-active::after {
visibility: visible; visibility: visible;
} }
@@ -4726,7 +4728,7 @@ body.layout-modern .pagination-container-fluid {
/* Modern layout: wider sidebar */ /* Modern layout: wider sidebar */
body.layout-modern .index-layout-wrapper { body.layout-modern .index-layout-wrapper {
padding-right: 400px; padding-right: 300px;
} }
/* Collapse reserved space when sidebar is hidden */ /* Collapse reserved space when sidebar is hidden */
@@ -4838,13 +4840,13 @@ body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
(-webkit-min-device-pixel-ratio: 1.2), (-webkit-min-device-pixel-ratio: 1.2),
(min-resolution: 1.2dppx) and (max-resolution: 1.25dppx) { (min-resolution: 1.2dppx) and (max-resolution: 1.25dppx) {
body.layout-modern .embed-responsive-16by9::before { body.layout-modern .embed-responsive-16by9::before {
padding-top: 41.25%; padding-top: 52.25%;
} }
body.layout-legacy .embed-responsive-16by9::before { body.layout-legacy .embed-responsive-16by9::before {
padding-top: 41.25%; padding-top: 52.25%;
} }
body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 41.25%; padding-top: 52.25%;
} }
} }
@@ -4910,7 +4912,7 @@ body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
/* ~105115% zoom */ /* ~105115% zoom */
html[data-dpr="dpr-110"] body.layout-modern .embed-responsive-16by9::before { html[data-dpr="dpr-110"] body.layout-modern .embed-responsive-16by9::before {
padding-top: 46.25%; padding-top: 52.25%;
} }
html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 52.25%;
@@ -4921,7 +4923,7 @@ html[data-dpr="dpr-110"] body.layout-legacy.sidebar-right-hidden .embed-responsi
/* ~120125% zoom */ /* ~120125% zoom */
html[data-dpr="dpr-120"] body.layout-modern .embed-responsive-16by9::before { html[data-dpr="dpr-120"] body.layout-modern .embed-responsive-16by9::before {
padding-top: 41.25%; padding-top: 52.25%;
} }
html[data-dpr="dpr-120"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-120"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 52.25%;
@@ -4935,10 +4937,10 @@ html[data-dpr="dpr-130"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 52.25%;
} }
html[data-dpr="dpr-130"] body.layout-modern .embed-responsive-16by9::before { html[data-dpr="dpr-130"] body.layout-modern .embed-responsive-16by9::before {
padding-top: 41.25%; padding-top: 52.25%;
} }
html[data-dpr="dpr-130"] body.layout-modern.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-130"] body.layout-modern.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 41.25%; padding-top: 52.25%;
} }
html[data-dpr="dpr-130"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-130"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 52.25%;
@@ -4967,20 +4969,20 @@ html[data-dpr="dpr-high"] body.layout-legacy.sidebar-right-hidden .embed-respons
/* ── 1440p-class displays (viewport ≥ 2000px) ── */ /* ── 1440p-class displays (viewport ≥ 2000px) ── */
@media (min-width: 2000px) { @media (min-width: 2000px) {
body.layout-legacy .embed-responsive-16by9::before { body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
/* 1440p at 110% zoom */ /* 1440p at 110% zoom */
@media (min-width: 2000px) and (max-width: 2499px) and (min-resolution: 1.05dppx) { @media (min-width: 2000px) and (max-width: 2499px) and (min-resolution: 1.05dppx) {
body.layout-legacy .embed-responsive-16by9::before { body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before {
@@ -4990,73 +4992,73 @@ html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before {
/* ── QHD ultrawide range (viewport ≥ 2500px) ── */ /* ── QHD ultrawide range (viewport ≥ 2500px) ── */
@media (min-width: 2500px) { @media (min-width: 2500px) {
body.layout-legacy .embed-responsive-16by9::before { body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
/* ── Full ultrawide at 100% zoom (viewport ≥ 3000px) ── */ /* ── Full ultrawide at 100% zoom (viewport ≥ 3000px) ── */
@media (min-width: 3000px) { @media (min-width: 3000px) {
body.layout-legacy .embed-responsive-16by9::before { body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
/* Ultrawide + zoomed in (still wide enough to need reduction) */ /* Ultrawide + zoomed in (still wide enough to need reduction) */
@media (min-width: 2000px) and (min-resolution: 1.39dppx) { @media (min-width: 2000px) and (min-resolution: 1.39dppx) {
body.layout-legacy .embed-responsive-16by9::before { body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
/* Firefox DPR fallbacks for large screens — combine data-dpr + min-width */ /* Firefox DPR fallbacks for large screens — combine data-dpr + min-width */
@media (min-width: 2000px) { @media (min-width: 2000px) {
html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-110"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-110"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-110"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-120"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-120"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-120"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-120"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-130"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-130"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-130"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-130"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-150"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-150"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-150"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-150"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-high"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-high"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-high"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-high"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
@media (min-width: 3000px) { @media (min-width: 3000px) {
html[data-dpr="dpr-1x"] body.layout-legacy .embed-responsive-16by9::before { html[data-dpr="dpr-1x"] body.layout-legacy .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
html[data-dpr="dpr-1x"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before { html[data-dpr="dpr-1x"] body.layout-legacy.sidebar-right-hidden .embed-responsive-16by9::before {
padding-top: 52.25%; padding-top: 62.25%;
} }
} }
@@ -5474,6 +5476,10 @@ span#tags {
padding-bottom: 2.5px; padding-bottom: 2.5px;
} }
body.layout-modern span#tags {
border-bottom: none;
}
span#tags:empty { span#tags:empty {
display: none; display: none;
} }
@@ -5537,6 +5543,15 @@ span#tags:not(.tags-expanded) .tags-inner>span:nth-child(n+11) {
padding-bottom: 1.5px; padding-bottom: 1.5px;
} }
a.removetag {
vertical-align: middle;
display: inline-flex;
}
a.removetag i {
vertical-align: middle;
}
.badge-greentext { .badge-greentext {
color: #789922; color: #789922;
text-shadow: inherit !important; text-shadow: inherit !important;
@@ -9423,6 +9438,7 @@ span.badge.badge-current {
width: auto; width: auto;
max-width: 120px !important; max-width: 120px !important;
object-fit: contain; object-fit: contain;
vertical-align: top;
} }
video.sidebar-comment-img.emoji { video.sidebar-comment-img.emoji {
@@ -10961,7 +10977,8 @@ div.posts>a.thumb.has-notif {
border: 1px solid var(--accent); border: 1px solid var(--accent);
} }
div.posts>a.thumb.has-notif:hover { div.posts>a.thumb.has-notif:hover,
div.posts>a.thumb.has-notif:focus-visible {
box-shadow: inset 0 0 0 1000px rgba(255, 0, 0, 0.4), 0 0 0 2px var(--accent) !important; box-shadow: inset 0 0 0 1000px rgba(255, 0, 0, 0.4), 0 0 0 2px var(--accent) !important;
} }
@@ -10975,7 +10992,8 @@ div.posts>a.thumb.has-notif::after {
z-index: 10 !important; z-index: 10 !important;
} }
div.posts>a.thumb.has-notif:hover::after { div.posts>a.thumb.has-notif:hover::after,
div.posts>a.thumb.has-notif:focus-visible::after {
visibility: visible !important; visibility: visible !important;
} }
@@ -14036,7 +14054,7 @@ textarea#profile_description {
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-height: 120px; min-height: 90px;
box-sizing: border-box; box-sizing: border-box;
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -14141,6 +14159,7 @@ textarea#profile_description {
color: #ccc; color: #ccc;
text-align: left; text-align: left;
text-shadow: 0px 0px 5px black; text-shadow: 0px 0px 5px black;
word-break: break-word;
} }
.user-infobox-body { .user-infobox-body {

View File

@@ -155,7 +155,13 @@
postid, postid,
existingTags: tags, existingTags: tags,
anchorEl: anchor, anchorEl: anchor,
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }), onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags renderTags
}); });
}; };

View File

@@ -126,14 +126,43 @@ class CommentSystem {
const container = document.getElementById('comments-container'); const container = document.getElementById('comments-container');
if (!container) return; if (!container) return;
const rect = container.getBoundingClientRect(); const currentlyAtBottom = btn.classList.contains('is-at-bottom');
// Detect if we are at the bottom of the comments section let isAtBottom = currentlyAtBottom;
const isAtBottom = rect.bottom < window.innerHeight + 100;
if (isAtBottom) { // Base the bottom state primarily on whether the input box / end-of-thread marker is visible
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
if (bottomElement) {
const bottomRect = bottomElement.getBoundingClientRect();
if (currentlyAtBottom) {
// We are pointing UP. Switch to DOWN if we scroll significantly UP away from bottomElement
if (bottomRect.top > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
// We are pointing DOWN. Switch to UP if bottomElement enters viewport or we scroll past it
if (bottomRect.top <= window.innerHeight + 50) {
isAtBottom = true;
}
}
} else {
// Fallback to container rect if no bottom element
const rect = container.getBoundingClientRect();
if (currentlyAtBottom) {
if (rect.bottom > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
if (rect.bottom <= window.innerHeight + 50) {
isAtBottom = true;
}
}
}
if (isAtBottom && !currentlyAtBottom) {
btn.classList.add('is-at-bottom'); btn.classList.add('is-at-bottom');
btn.setAttribute('title', 'Scroll to top'); btn.setAttribute('title', 'Scroll to top');
} else { } else if (!isAtBottom && currentlyAtBottom) {
btn.classList.remove('is-at-bottom'); btn.classList.remove('is-at-bottom');
btn.setAttribute('title', 'Scroll to bottom'); btn.setAttribute('title', 'Scroll to bottom');
} }
@@ -3779,6 +3808,11 @@ class CommentSystem {
toggleComments() { toggleComments() {
if (!this.container) return; if (!this.container) return;
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
}
const layout = this.container.closest('.item-layout-container'); const layout = this.container.closest('.item-layout-container');
const sidebar = this.container.closest('.item-sidebar-left'); const sidebar = this.container.closest('.item-sidebar-left');
const siblings = sidebar ? [ const siblings = sidebar ? [

View File

@@ -46,11 +46,19 @@
return; return;
} }
// Mark internal drags to prevent them from triggering the dropzone
window.addEventListener('dragstart', (e) => {
if (e.dataTransfer) {
e.dataTransfer.setData('application/x-f0ck-internal', 'true');
}
});
// Global Drag Events // Global Drag Events
window.addEventListener('dragenter', (e) => { window.addEventListener('dragenter', (e) => {
e.preventDefault(); e.preventDefault();
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
dragCounter++; dragCounter++;
dropOverlay.classList.add('active'); dropOverlay.classList.add('active');
} }
@@ -59,10 +67,17 @@
window.addEventListener('dragover', (e) => { window.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) {
// Change dropEffect to none to indicate this dropzone doesn't accept internal elements
e.dataTransfer.dropEffect = 'none';
}
}); });
window.addEventListener('dragleave', (e) => { window.addEventListener('dragleave', (e) => {
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
// dragleave doesn't always have reliable dataTransfer.types, but if it does, check it
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
dragCounter--; dragCounter--;
if (dragCounter <= 0) { if (dragCounter <= 0) {
dragCounter = 0; dragCounter = 0;
@@ -72,6 +87,8 @@
window.addEventListener('drop', (e) => { window.addEventListener('drop', (e) => {
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
dropOverlay.classList.remove('active'); dropOverlay.classList.remove('active');

View File

@@ -2301,7 +2301,7 @@ window.cancelAnimFrame = (function () {
let hiddenValue = localStorage.getItem('sidebarRightHidden'); let hiddenValue = localStorage.getItem('sidebarRightHidden');
let hidden; let hidden;
if (hiddenValue === null) { if (hiddenValue === null) {
hidden = false; // Default to visible on all devices hidden = (window.f0ckSession && window.f0ckSession.hide_sidebar_default) ? true : false; // Default based on config
localStorage.setItem('sidebarRightHidden', hidden); localStorage.setItem('sidebarRightHidden', hidden);
} else { } else {
hidden = hiddenValue === 'true'; hidden = hiddenValue === 'true';
@@ -2603,6 +2603,10 @@ window.cancelAnimFrame = (function () {
if (isFav) ajaxUrl += `&fav=true`; if (isFav) ajaxUrl += `&fav=true`;
if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`; if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`;
// Preserve tagger filter from URL query string
const taggerMatch = urlObj.searchParams.get('tagger');
if (taggerMatch) ajaxUrl += `&tagger=${encodeURIComponent(taggerMatch)}`;
const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1'); const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1');
if (isRandom) ajaxUrl += `&random=1`; if (isRandom) ajaxUrl += `&random=1`;
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
@@ -3144,6 +3148,9 @@ window.cancelAnimFrame = (function () {
window.location.href = url; // Fallback window.location.href = url; // Fallback
} finally { } finally {
isNavigating = false; isNavigating = false;
if (replace && document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
if (navbar) navbar.classList.remove("pbwork"); if (navbar) navbar.classList.remove("pbwork");
if (window.updateStrictLinks && window.f0ckSession) window.updateStrictLinks(window.f0ckSession.strict_mode); if (window.updateStrictLinks && window.f0ckSession) window.updateStrictLinks(window.f0ckSession.strict_mode);
} }
@@ -3324,10 +3331,15 @@ window.cancelAnimFrame = (function () {
const itemid = numericSegments.pop(); const itemid = numericSegments.pop();
// Extract context from Target URL first // Extract context from Target URL first
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null; let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
const tagMatch = url.match(/\/tag\/([^/?]+)/); const tagMatch = url.match(/\/tag\/([^/?]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]); if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
// Tagger filter from query string
const taggerUrlObj = new URL(url, window.location.origin);
const taggerParam = taggerUrlObj.searchParams.get('tagger');
if (taggerParam) tagger = decodeURIComponent(taggerParam);
// User hall: /user/:owner/hall/:slug/:id // User hall: /user/:owner/hall/:slug/:id
const userHallMatch = url.match(/\/user\/([^/]+)\/hall\/([^/]+)/); const userHallMatch = url.match(/\/user\/([^/]+)\/hall\/([^/]+)/);
if (userHallMatch) { if (userHallMatch) {
@@ -3365,6 +3377,10 @@ window.cancelAnimFrame = (function () {
const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/); const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/);
if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]); if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]);
} }
if (!tagger) {
const wTaggerParam = new URL(window.location.href).searchParams.get('tagger');
if (wTaggerParam) tagger = decodeURIComponent(wTaggerParam);
}
if (!user && !userHall) { if (!user && !userHall) {
const wUserMatch = window.location.href.match(/\/user\/([^/?]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/?]+)/);
if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) { if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) {
@@ -3400,6 +3416,7 @@ window.cancelAnimFrame = (function () {
} else if (user) { } else if (user) {
params.append('user', user); params.append('user', user);
} }
if (tagger) params.append('tagger', tagger);
if (isFavs) params.append('fav', 'true'); if (isFavs) params.append('fav', 'true');
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
@@ -3499,6 +3516,7 @@ window.cancelAnimFrame = (function () {
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; } else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`; else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`; else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`); if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
if (_hash) _pushUrl += _hash; if (_hash) _pushUrl += _hash;
@@ -3701,6 +3719,8 @@ window.cancelAnimFrame = (function () {
} }
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`; else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`; else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
// Append tagger filter so item nav stays in tagger context
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) { if (mime) {
// If it already has itemid at the end, insert mime before it // If it already has itemid at the end, insert mime before it
@@ -6402,22 +6422,13 @@ window.cancelAnimFrame = (function () {
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');
if (!sidebar) return; if (!sidebar) return;
mouseRT.active = true;
mouseRT.isDragging = false;
mouseRT.isCandidate = false;
mouseRT.xDown = e.clientX;
mouseRT.yDown = e.clientY;
mouseRT.xDiff = 0;
mouseRT.yDiff = 0;
mouseRT.timeDown = Date.now();
mouseRT.sidebarEl = sidebar;
mouseRT.sidebarWidth = sidebar.offsetWidth;
const isHidden = document.body.classList.contains('sidebar-right-hidden'); const isHidden = document.body.classList.contains('sidebar-right-hidden');
let candidate = false;
if (isHidden) { if (isHidden) {
// Open zone: 80px from right edge // Open zone: 80px from right edge
if (e.clientX > window.innerWidth - 30) { if (e.clientX > window.innerWidth - 30) {
mouseRT.isCandidate = true; candidate = true;
} }
} else { } else {
// Close: only from the left-edge grab strip (first 16px of sidebar) OR the edgeZone overhang // Close: only from the left-edge grab strip (first 16px of sidebar) OR the edgeZone overhang
@@ -6425,9 +6436,22 @@ window.cancelAnimFrame = (function () {
const sidebarLeft = sidebar.getBoundingClientRect().left; const sidebarLeft = sidebar.getBoundingClientRect().left;
const inGrabStrip = e.clientX >= sidebarLeft && e.clientX <= sidebarLeft + 16; const inGrabStrip = e.clientX >= sidebarLeft && e.clientX <= sidebarLeft + 16;
if (inGrabStrip || e.target === dragZone) { if (inGrabStrip || e.target === dragZone) {
candidate = true;
}
}
if (!candidate) return;
mouseRT.active = true;
mouseRT.isDragging = false;
mouseRT.isCandidate = true; mouseRT.isCandidate = true;
} mouseRT.xDown = e.clientX;
} mouseRT.yDown = e.clientY;
mouseRT.xDiff = 0;
mouseRT.yDiff = 0;
mouseRT.timeDown = Date.now();
mouseRT.sidebarEl = sidebar;
mouseRT.sidebarWidth = sidebar.offsetWidth;
}); });
document.addEventListener('mousemove', e => { document.addEventListener('mousemove', e => {
@@ -6447,6 +6471,8 @@ window.cancelAnimFrame = (function () {
window.getSelection()?.removeAllRanges(); // clear any selection that formed before threshold window.getSelection()?.removeAllRanges(); // clear any selection that formed before threshold
} else if (absY > absX) { } else if (absY > absX) {
mouseRT.isCandidate = false; // Scrolling — bail mouseRT.isCandidate = false; // Scrolling — bail
mouseRT.active = false;
return;
} }
} }
@@ -6887,7 +6913,10 @@ window.cancelAnimFrame = (function () {
// ── Edge zone & sync ──────────────────────────────────────────────────── // ── Edge zone & sync ────────────────────────────────────────────────────
const syncLeftEdgeZone = () => { const syncLeftEdgeZone = () => {
const { sb, layout } = getLS(); const { sb, layout } = getLS();
if (!sb || !layout || !document.body.classList.contains('layout-modern')) { // The left drag zone only applies to the 3-column desktop grid (≥1000px).
// On mobile the sidebar becomes in-flow, so hide the zone to avoid it
// landing next to the right-sidebar drag zone on narrow screens.
if (!sb || !layout || !document.body.classList.contains('layout-modern') || window.innerWidth < 1000) {
leftEdgeZone.style.display = 'none'; leftEdgeZone.style.display = 'none';
leftEdgeZone.style.pointerEvents = 'none'; leftEdgeZone.style.pointerEvents = 'none';
return; return;
@@ -6944,6 +6973,19 @@ window.cancelAnimFrame = (function () {
// Export loaders for external systems (like NotificationSystem) // Export loaders for external systems (like NotificationSystem)
window.loadPageAjax = loadPageAjax; window.loadPageAjax = loadPageAjax;
window.loadItemAjax = loadItemAjax; window.loadItemAjax = loadItemAjax;
// Invalidate all itemCacheMap entries that contain a given item ID.
// Call this after mutating an item (e.g. adding a tag) so the next
// AJAX navigation fetches fresh HTML instead of serving stale cache.
window.invalidateItemCache = (itemId) => {
if (!itemId) return;
const needle = `/${itemId}`;
for (const key of itemCacheMap.keys()) {
if (key.includes(needle)) {
itemCacheMap.delete(key);
}
}
};
// Continued... // Continued...
const sbtForm = document.getElementById('sbtForm'); const sbtForm = document.getElementById('sbtForm');
@@ -9382,22 +9424,24 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
window.startHoverPreview = startPreview; window.startHoverPreview = startPreview;
document.addEventListener('mouseover', (e) => { const handleEnter = (e) => {
const thumb = e.target.closest('.thumb'); const thumb = e.target.closest('.thumb');
// If we are hovering a thumb, and it's NOT the active one
if (thumb && thumb !== activeThumb) { if (thumb && thumb !== activeThumb) {
startPreview(thumb, 150); startPreview(thumb, 150);
} }
}); };
document.addEventListener('mouseover', handleEnter);
document.addEventListener('focusin', handleEnter);
document.addEventListener('mouseout', (e) => { const handleLeave = (e) => {
const thumb = e.target.closest('.thumb'); const thumb = e.target.closest('.thumb');
if (thumb) { if (thumb) {
if (e.relatedTarget && thumb.contains(e.relatedTarget)) return; if (e.type === 'mouseout' && e.relatedTarget && thumb.contains(e.relatedTarget)) return;
clearPreview(); clearPreview();
} }
}); };
document.addEventListener('mouseout', handleLeave);
document.addEventListener('focusout', handleLeave);
// Touch handling to support "tap to activate" behavior // Touch handling to support "tap to activate" behavior
// 1. First tap: Activate (visuals + preview) AND prevent navigation // 1. First tap: Activate (visuals + preview) AND prevent navigation
@@ -10188,14 +10232,21 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Only expand if the image is actually being clipped (not fully visible). // Only expand if the image is actually being clipped (not fully visible).
// Compare the image's rendered height to its natural height —
// if rendered >= natural, the image already fits fully in the box.
const img = wrapper.querySelector('img#f0ck-image'); const img = wrapper.querySelector('img#f0ck-image');
if (img && img.complete && img.naturalHeight > 0) { if (img && img.complete && img.naturalHeight > 0 && img.naturalWidth > 0) {
const rendered = img.getBoundingClientRect(); const rendered = img.getBoundingClientRect();
// Allow a 2px tolerance for subpixel rounding
// Calculate the max height the image can take given the container width
const expandedHeight = rendered.width * (img.naturalHeight / img.naturalWidth);
if (rendered.height + 2 >= img.naturalHeight) { if (rendered.height + 2 >= img.naturalHeight) {
// Fully visible — do nothing (no expand needed, no modal) // Image is unscaled or upscaled. No detail to gain by expanding.
return;
}
if (rendered.height + 2 >= expandedHeight) {
// Expanding inline will not increase display size because it is width-constrained.
openImageModal(elfe.href);
return; return;
} }
} }
@@ -11268,3 +11319,90 @@ document.addEventListener('click', (e) => {
} }
}, true); }, true);
})(); })();
// --- Grid Keyboard Accessibility ---
document.addEventListener('keydown', (e) => {
const isTab = e.key === 'Tab';
const isArrow = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key);
if (!isTab && !isArrow) return;
const thumbs = Array.from(document.querySelectorAll('.posts .thumb'));
if (thumbs.length === 0) return;
if (isTab && document.activeElement === document.body) {
e.preventDefault();
thumbs[0].focus();
return;
}
const active = document.activeElement;
if (isArrow && active && active.classList.contains('thumb')) {
const activeRect = active.getBoundingClientRect();
const activeCenter = {
x: activeRect.left + activeRect.width / 2,
y: activeRect.top + activeRect.height / 2
};
let bestMatch = null;
let minDistance = Infinity;
thumbs.forEach(thumb => {
if (thumb === active) return;
const rect = thumb.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const center = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
const dxCenter = center.x - activeCenter.x;
const dyCenter = center.y - activeCenter.y;
const absDx = Math.abs(dxCenter);
const absDy = Math.abs(dyCenter);
let isCandidate = false;
let primaryDist = 0;
let secondaryGap = 0;
if (e.key === 'ArrowRight') {
// Must be to the right, and within a 60 degree cone
isCandidate = dxCenter > 0 && absDx >= absDy * 0.5;
primaryDist = absDx;
// Gap on the Y axis. If they overlap, gap is 0
secondaryGap = Math.max(0, Math.max(activeRect.top, rect.top) - Math.min(activeRect.bottom, rect.bottom));
} else if (e.key === 'ArrowLeft') {
isCandidate = dxCenter < 0 && absDx >= absDy * 0.5;
primaryDist = absDx;
secondaryGap = Math.max(0, Math.max(activeRect.top, rect.top) - Math.min(activeRect.bottom, rect.bottom));
} else if (e.key === 'ArrowDown') {
isCandidate = dyCenter > 0 && absDy >= absDx * 0.5;
primaryDist = absDy;
secondaryGap = Math.max(0, Math.max(activeRect.left, rect.left) - Math.min(activeRect.right, rect.right));
} else if (e.key === 'ArrowUp') {
isCandidate = dyCenter < 0 && absDy >= absDx * 0.5;
primaryDist = absDy;
secondaryGap = Math.max(0, Math.max(activeRect.left, rect.left) - Math.min(activeRect.right, rect.right));
}
if (isCandidate) {
// Massive penalty for gap to force it to pick overlapping items (same row/col)
// Tiny penalty for center misalignment so ties are broken cleanly
const centerMisalignment = (e.key === 'ArrowLeft' || e.key === 'ArrowRight') ? absDy : absDx;
const distance = primaryDist + (secondaryGap * 15) + (centerMisalignment * 0.1);
if (distance < minDistance) {
minDistance = distance;
bestMatch = thumb;
}
}
});
if (bestMatch) {
e.preventDefault();
bestMatch.focus();
bestMatch.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
});

View File

@@ -1688,7 +1688,7 @@
avatar: '/a/default.png', avatar: '/a/default.png',
stamp: p.time, stamp: p.time,
timeago: timeAgo(p.time * 1000), timeago: timeAgo(p.time * 1000),
tags: `4chan, /${data.board}/`, tags: ``,
is_video: isVideo, is_video: isVideo,
is_image: isImage, is_image: isImage,
is_audio: false, is_audio: false,
@@ -1859,7 +1859,7 @@
body: new URLSearchParams({ body: new URLSearchParams({
url: item.external_media_url || item.dest, url: item.external_media_url || item.dest,
rating: rating, rating: rating,
tags: '4chan', tags: '',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`, comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {}) ...(item.original_filename ? { original_filename: item.original_filename } : {})
}) })

View File

@@ -496,8 +496,8 @@
|| rawContent.split('\n').length > 2 || rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0); || (c.files && c.files.length > 0);
} }
// Always force isLong if there are inline media tags that expand vertically // Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && (/\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) { if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
isLong = true; isLong = true;
} }
@@ -587,7 +587,7 @@
const isExpanded = container.classList.contains('expanded'); const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight; const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight; const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img:not(.emoji), video')).some( const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
media => { media => {
if (media.tagName === 'IMG') { if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true'; return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
@@ -639,8 +639,8 @@
const attachMediaLoadListeners = (element) => { const attachMediaLoadListeners = (element) => {
// Only target images and videos inside comment-content-inner. // Only target images and videos inside comment-content-inner.
// Avatars, emojis, and item preview thumbnails have fixed sizes and do not affect text overflow. // Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img:not(.emoji), .comment-content-inner video').forEach(media => { element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
if (media.dataset.loadListenerBound) return; if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true'; media.dataset.loadListenerBound = 'true';

View File

@@ -120,7 +120,13 @@
postid, postid,
existingTags: tags, existingTags: tags,
anchorEl: anchor, anchorEl: anchor,
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }), onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags renderTags
}); });
}; };

View File

@@ -5,33 +5,33 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<div class="v0ck_progress_filled"></div> <div class="v0ck_progress_filled"></div>
<div class="v0ck_seek_marker"></div> <div class="v0ck_seek_marker"></div>
</div> </div>
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play"> <button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play" tabindex="-1">
<svg style="width: 20px; height: 20px;"> <svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_play" href="${svg}#play"></use> <use id="v0ck_svg_play" href="${svg}#play"></use>
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use> <use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
</svg> </svg>
</button> </button>
<div class="v0ck_volume_group"> <div class="v0ck_volume_group">
<button class="v0ck_player_button v0ck_volume"> <button class="v0ck_player_button v0ck_volume" tabindex="-1">
<svg style="width: 20px; height: 20px;"> <svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use> <use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use> <use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use> <use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
</svg> </svg>
</button> </button>
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" /> <input type="range" name="volume" min="0" max="1" step="0.01" value="1" tabindex="-1" />
</div> </div>
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button> <button class="v0ck_player_button v0ck_playtime" tabindex="-1">00:00 / 00:00</button>
<span style="flex: 30"></span> <span style="flex: 30"></span>
<div class="v0ck_settings_container"> <div class="v0ck_settings_container">
<button class="v0ck_player_button v0ck_settings_btn" title="Settings"> <button class="v0ck_player_button v0ck_settings_btn" title="Settings" tabindex="-1">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;"> <svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/> <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg> </svg>
</button> </button>
<div class="v0ck_settings_menu v0ck_hidden"> <div class="v0ck_settings_menu v0ck_hidden">
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button> <button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
<div class="v0ck_menu_item v0ck_bg_row"> <div class="v0ck_menu_item v0ck_bg_row">
<span class="v0ck_switch_label">Background</span> <span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div> <div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
@@ -44,11 +44,11 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<span class="v0ck_switch_label">Danmaku</span> <span class="v0ck_switch_label">Danmaku</span>
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div> <div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
</div> </div>
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button> <button id="v0ck_download" class="v0ck_menu_item" title="Download File" tabindex="-1">Download${size ? ` (${size})` : ''}</button>
</div> </div>
</div> </div>
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen"> <button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen" tabindex="-1">
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg> <svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
</button> </button>
</div> </div>

View File

@@ -30,7 +30,7 @@ const resolvePath = (defaultRel) => {
const absStorage = path.resolve(storage); const absStorage = path.resolve(storage);
if (defaultRel.startsWith('public/')) { if (defaultRel.startsWith('public/')) {
const sub = defaultRel.replace('public/', ''); const sub = defaultRel.replace('public/', '');
if (sub === 's/emojis' || sub === 's/koepfe') { if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
const storagePath = path.join(absStorage, sub.split('/').pop()); const storagePath = path.join(absStorage, sub.split('/').pop());
if (fs.existsSync(storagePath)) return path.resolve(storagePath); if (fs.existsSync(storagePath)) return path.resolve(storagePath);
return local; return local;
@@ -51,6 +51,7 @@ config.paths = {
s: path.join(base, 'public/s'), s: path.join(base, 'public/s'),
emojis: resolvePath('public/s/emojis'), emojis: resolvePath('public/s/emojis'),
koepfe: resolvePath('public/s/koepfe'), koepfe: resolvePath('public/s/koepfe'),
fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'), memes: resolvePath('public/memes'),
e: resolvePath('e'), e: resolvePath('e'),
pending: resolvePath('pending'), pending: resolvePath('pending'),

View File

@@ -132,6 +132,7 @@ export default new class {
// Build suffix with query params // Build suffix with query params
let suffix = env.strict ? '?strict=1' : ''; let suffix = env.strict ? '?strict=1' : '';
if (env.tagger) suffix += (suffix ? '&' : '?') + `tagger=${encodeURIComponent(env.tagger)}`;
// mainDisplay: decoded for human-readable display (e.g. div.location) // mainDisplay: decoded for human-readable display (e.g. div.location)
// main: keeps percent-encoding for use in href attributes // main: keeps percent-encoding for use in href attributes

View File

@@ -18,7 +18,7 @@ const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === '
const COUNT_CACHE_TTL_MS = 90_000; const COUNT_CACHE_TTL_MS = 90_000;
const countCache = new Map(); // key → { total, expiresAt } const countCache = new Map(); // key → { total, expiresAt }
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }) { function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
return JSON.stringify([ return JSON.stringify([
modequery, modequery,
tag ?? '', tag ?? '',
@@ -30,7 +30,8 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
excludedTags.slice().sort().join(','), excludedTags.slice().sort().join(','),
newerThan ?? '', newerThan ?? '',
minXd, minXd,
userHallObj?.id ?? '' userHallObj?.id ?? '',
tagger ?? ''
]); ]);
} }
@@ -136,7 +137,7 @@ const xdScoreMeta = (score) => {
}; };
export default { export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => { getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table --- // --- title: prefix — search items.title instead of the tags table ---
@@ -189,7 +190,8 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0; const isStrict = strictParams.length > 0;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner }; const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment // Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null; const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0); const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
@@ -200,6 +202,21 @@ export default {
if (isTitleSearch && titleQuery) { if (isTitleSearch && titleQuery) {
// Title search: match items.title ILIKE '%query%' // Title search: match items.title ILIKE '%query%'
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`; titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
} else if (tagger && tag) {
// Tagger+tag filter: items where the specific user applied this specific tag
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
const conditions = terms.map(term => {
return db`and items.id in (
select ta.item_id from tags_assign ta
join tags t on t.id = ta.tag_id
join "user" u on u.id = ta.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
)`;
});
tagFilter = db`${conditions}`;
}
} else if (tag) { } else if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean); const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) { if (terms.length > 0) {
@@ -233,7 +250,7 @@ export default {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
} }
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }); const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = getCachedCount(cacheKey); let total = getCachedCount(cacheKey);
if (total === null) { if (total === null) {
@@ -372,7 +389,7 @@ export default {
for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++) for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++)
cheat.push(i); cheat.push(i);
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict }); const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict, tagger });
// Override link for title searches — pagination must use the /tag/title:... prefix // Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) { if (isTitleSearch && titleQuery) {

View File

@@ -226,7 +226,8 @@ export default (router, tpl) => {
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode, strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true', explicitStrict: query.strict === '1' || query.strict === 'true',
newer: query.newer || null, newer: query.newer || null,
minXdScore: req.session?.min_xd_score || 0 minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
}); });
if (!data.success) { if (!data.success) {

View File

@@ -486,7 +486,7 @@ export default router => {
// F-023 Security: Validate font against actual files on disk // F-023 Security: Validate font against actual files on disk
// The font value is rendered into CSS url() in header.html, so it must be a real filename // The font value is rendered into CSS url() in header.html, so it must be a real filename
if (font) { if (font) {
const fontsDir = path.join(path.resolve(), 'public/s/fonts'); const fontsDir = cfg.paths.fonts;
try { try {
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)); const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
if (!available.includes(font)) { if (!available.includes(font)) {

View 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;
};

View File

@@ -336,7 +336,16 @@ export default router => {
} }
} }
const url = inputUrl.trim(); let url = inputUrl.trim();
try {
const parsed = new URL(url);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
url = parsed.toString();
}
} catch (e) {
// Ignore malformed URL, keep original string
}
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i; const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = url.match(ytRegex); const ytMatch = url.match(ytRegex);

View File

@@ -244,7 +244,8 @@ export default (router, tpl) => {
explicitStrict: !!(req.query?.strict || req.url.qs?.strict), explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
random: req.cookies.random_mode === '1', random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)), minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang lang: req.lang,
tagger: req.url.qs?.tagger || null
}); });
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`); console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);

View File

@@ -23,6 +23,11 @@ export default (router, tpl) => {
route: /^\/s\/koepfe\// route: /^\/s\/koepfe\//
}); });
router.static({
dir: cfg.paths.fonts,
route: /^\/s\/fonts\//
});
router.static({ router.static({
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'), dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
route: /^\/s\/ruffle\// route: /^\/s\/ruffle\//

View File

@@ -120,7 +120,7 @@ export default (router, tpl) => {
return res.json({ return res.json({
success: true, success: true,
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req), html: tpl.render('tag-cards', { toptags: tags, user: { user: userName }, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page, currentPage: page,
pagination: paginationHtml, pagination: paginationHtml,
hasMore: tags.length === TAGS_PER_PAGE hasMore: tags.length === TAGS_PER_PAGE

View File

@@ -505,6 +505,45 @@ process.on('uncaughtException', err => {
} }
}); });
// ─── Session cache ────────────────────────────────────────────────────────
// The session middleware runs in parallel with all other app.use() handlers
// (flummpress uses Promise.all). With many concurrent requests each doing a
// DB SELECT, the pool (max:50) drains and nginx sees 502s. Caching the
// session row for 5s and the upload-count for 30s eliminates the hot path.
const SESSION_CACHE_TTL = 5_000; // ms — short enough to pick up bans quickly
const UPLOAD_COUNT_CACHE_TTL = 30_000; // ms — upload window is 12h, stale for 30s is fine
const _sessionCache = new Map(); // sha256(cookie) -> { data: row, exp: number }
const _uploadCountCache = new Map(); // username -> { count: number, exp: number }
const _scGet = (hash) => {
const e = _sessionCache.get(hash);
if (e && e.exp > Date.now()) return e.data;
_sessionCache.delete(hash);
return null;
};
const _scSet = (hash, data) => {
_sessionCache.set(hash, { data, exp: Date.now() + SESSION_CACHE_TTL });
if (_sessionCache.size > 2000) {
const now = Date.now();
for (const [k, v] of _sessionCache) if (v.exp <= now) _sessionCache.delete(k);
}
};
// Exposed so logout/ban routes can force-evict immediately
global._invalidateSessionCache = (hash) => _sessionCache.delete(hash);
const _ucGet = (username) => {
const e = _uploadCountCache.get(username);
if (e && e.exp > Date.now()) return e.count;
_uploadCountCache.delete(username);
return null;
};
const _ucSet = (username, count) => {
_uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL });
};
// Allow upload handler to bust the cache after a successful upload
global._invalidateUploadCountCache = (username) => _uploadCountCache.delete(username);
// ──────────────────────────────────────────────────────────────────────────
app.use(async (req, res) => { app.use(async (req, res) => {
// This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass // This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass
// if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`); // if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`);
@@ -571,14 +610,25 @@ process.on('uncaughtException', err => {
req.fullscreen = req.cookies.fullscreen || 0; req.fullscreen = req.cookies.fullscreen || 0;
if (req.cookies.session) { if (req.cookies.session) {
const user = await db` const _sessionHash = lib.sha256(req.cookies.session);
let _cachedRow = _scGet(_sessionHash);
let user;
if (_cachedRow) {
// Shallow-clone the cached row so per-request mutations (theme, uploads_remaining,
// pending_count, etc.) don't corrupt the shared cache entry.
user = [{ ..._cachedRow }];
} else {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
from "user_sessions" from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)} where "user_sessions".session = ${_sessionHash}
limit 1 limit 1
`; `;
// Store a plain-object clone — keeps the cache entry free of per-request fields.
if (user.length > 0) _scSet(_sessionHash, { ...user[0] });
}
if (user.length === 0) { if (user.length === 0) {
res.writeHead(307, { // delete session res.writeHead(307, { // delete session
@@ -651,8 +701,10 @@ process.on('uncaughtException', err => {
req.session.pending_count = pending[0].c; req.session.pending_count = pending[0].c;
} }
// Calculate uploads remaining globally for the modal // Calculate uploads remaining globally for the modal (cached 30s per user)
if (!req.session.admin && !req.session.is_moderator) { if (!req.session.admin && !req.session.is_moderator) {
let cachedUploadCount = _ucGet(req.session.user);
if (cachedUploadCount === null) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600); const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db` const uploadCount = await db`
SELECT count(*) as count SELECT count(*) as count
@@ -661,7 +713,10 @@ process.on('uncaughtException', err => {
AND stamp > ${twelveHoursAgo} AND stamp > ${twelveHoursAgo}
AND is_deleted = false AND is_deleted = false
`; `;
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count)); cachedUploadCount = parseInt(uploadCount[0].count);
_ucSet(req.session.user, cachedUploadCount);
}
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - cachedUploadCount);
} else { } else {
req.session.uploads_remaining = undefined; // Unlimited for admins/mods req.session.uploads_remaining = undefined; // Unlimited for admins/mods
} }
@@ -1248,6 +1303,7 @@ process.on('uncaughtException', err => {
custom_favicon: cfg.websrv.custom_favicon || "", custom_favicon: cfg.websrv.custom_favicon || "",
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""), custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "", custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "",
default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster", site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl, enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: cfg.nsfl_tag_id || 3, nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1267,6 +1323,7 @@ process.on('uncaughtException', err => {
ts: Date.now(), ts: Date.now(),
get default_layout() { return getDefaultLayout(); }, get default_layout() { return getDefaultLayout(); },
show_koepfe: !!cfg.websrv.show_koepfe, show_koepfe: !!cfg.websrv.show_koepfe,
hide_sidebar_default: !!cfg.websrv.hide_sidebar_default,
allow_language_change: cfg.websrv.allow_language_change !== false, allow_language_change: cfg.websrv.allow_language_change !== false,
enable_xd_score: !!cfg.websrv.enable_xd_score, enable_xd_score: !!cfg.websrv.enable_xd_score,
enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs, enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs,
@@ -1302,7 +1359,7 @@ process.on('uncaughtException', err => {
get fonts() { get fonts() {
try { try {
const fontsDir = path.join(path.resolve(), 'public/s/fonts'); const fontsDir = cfg.paths.fonts;
if (!fs.existsSync(fontsDir)) return []; if (!fs.existsSync(fontsDir)) return [];
return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({ return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({
name: f.split('.').shift(), name: f.split('.').shift(),

View File

@@ -39,7 +39,6 @@
@endif @endif
<div class="sidebar-tags-container"> <div class="sidebar-tags-container">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--white); font-size: 0.9em; text-transform: uppercase;"></div>
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;"> <span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)

View File

@@ -127,7 +127,7 @@
@endif @endif
@if(private_society && !session) @if(private_society && !session)
<script> <script>
window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif }; window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
(() => { (() => {
const loginModal = document.getElementById('login-modal'); const loginModal = document.getElementById('login-modal');
@@ -406,6 +406,7 @@
dm_unencrypted: @if(dm_unencrypted) true @else false @endif, dm_unencrypted: @if(dm_unencrypted) true @else false @endif,
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
enable_comment_polls: @if(enable_comment_polls) true @else false @endif, enable_comment_polls: @if(enable_comment_polls) true @else false @endif,
hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif,
mode: {{ mode !== undefined ? mode : 0 }} mode: {{ mode !== undefined ? mode : 0 }}
}; };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};

View File

@@ -15,10 +15,17 @@
font-family: 'CustomUserFont'; font-family: 'CustomUserFont';
src: url('/s/fonts/{{ session.font }}'); src: url('/s/fonts/{{ session.font }}');
} }
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
{{ "@" }}font-face {
font-family: 'SiteDefaultFont';
src: url('/s/fonts/{{ default_font }}');
}
@endif @endif
:root { :root {
@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)
--font: 'SiteDefaultFont', monospace !important;
@endif @endif
} }
@if(session && session.font) @if(session && session.font)
@@ -30,6 +37,15 @@
.fas::before, .far::before, .fab::before, .fa::before { .fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important; font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
} }
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
*:not(canvas):not(.meme-title):not(.force-impact):not(.fa-solid):not(.fa-regular):not(.fa-brands):not(.fa-light):not(.fa-thin):not(.fa-duotone):not([class*=" fa-"]):not([class^="fa-"]) {
font-family: 'SiteDefaultFont', monospace !important;
}
/* Explicitly protect FA pseudo-elements */
.fa-solid::before, .fa-regular::before, .fa-brands::before,
.fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
}
@endif @endif
</style> </style>
<link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin> <link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin>
@@ -78,7 +94,7 @@
</head> </head>
<body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif"> <body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif">
<script>if(localStorage.getItem('sidebarRightHidden')==='true')document.body.classList.add('sidebar-right-hidden');if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script> <script>var sRH = localStorage.getItem('sidebarRightHidden'); if(sRH==='true' || (sRH===null && @if(hide_sidebar_default) true @else false @endif)) document.body.classList.add('sidebar-right-hidden'); if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script>
@if(!private_society || session) @if(!private_society || session)
<canvas class="hidden-xs" id="bg"></canvas> <canvas class="hidden-xs" id="bg"></canvas>
@endif @endif

View File

@@ -1,5 +1,5 @@
@each(toptags as toptag) @each(toptags as toptag)
<a href="/tag/{{ toptag.safe_tag }}" class="tag-card"> <a href="/tag/{{ toptag.safe_tag }}@if(user && user.user)?tagger={!! encodeURIComponent(user.user) !!}@endif" class="tag-card">
<div class="tag-card-image"> <div class="tag-card-image">
<img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}"> <img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}">
</div> </div>