svelte lol
This commit is contained in:
132
f0ck-svelte/src/lib/api/actions.ts
Normal file
132
f0ck-svelte/src/lib/api/actions.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
import { csrfFetch } from '$lib/utils/csrfFetch';
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CommentsResponse {
|
||||
success: boolean;
|
||||
comments: Comment[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
itemId: number,
|
||||
fetchFn = fetch
|
||||
): Promise<CommentsResponse> {
|
||||
const res = await fetchFn(`/api/comments/${itemId}`);
|
||||
if (!res.ok) throw new Error(`getComments failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function postComment(payload: {
|
||||
itemId: number;
|
||||
content: string;
|
||||
replyTo?: number | null;
|
||||
}): Promise<{ success: boolean; comment?: Comment; message?: string; msg?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
item_id: String(payload.itemId),
|
||||
content: payload.content,
|
||||
...(payload.replyTo ? { parent_id: String(payload.replyTo) } : {})
|
||||
});
|
||||
const res = await csrfFetch('/api/comments', { method: 'POST', body });
|
||||
if (!res.ok && res.status !== 400 && res.status !== 429) throw new Error(`postComment failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
commentId: number
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
// Backend uses POST /api/comments/:id/delete (not DELETE method)
|
||||
const res = await csrfFetch(`/api/comments/${commentId}/delete`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`deleteComment failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FavResponse {
|
||||
success: boolean;
|
||||
itemid: number;
|
||||
favs: Array<{
|
||||
user: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function toggleFav(itemId: number): Promise<FavResponse> {
|
||||
const body = new URLSearchParams({ postid: String(itemId) });
|
||||
const res = await csrfFetch('/api/v2/togglefav', { method: 'POST', body });
|
||||
if (!res.ok) throw new Error(`toggleFav failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagsResponse {
|
||||
success: boolean;
|
||||
tags: import('$lib/types/item').Tag[];
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export async function addTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||
const body = new URLSearchParams({ tagname });
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}`, { method: 'POST', body });
|
||||
if (!res.ok && res.status !== 400) throw new Error(`addTag failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteTag(itemId: number, tagname: string): Promise<TagsResponse> {
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tagname)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(`deleteTag failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CycleRatingResponse {
|
||||
success: boolean;
|
||||
rating_tag_id: number;
|
||||
rating_label: 'SFW' | 'NSFW' | 'NSFL';
|
||||
rating_class: 'sfw' | 'nsfw' | 'nsfl';
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export async function cycleRating(itemId: number): Promise<CycleRatingResponse> {
|
||||
const res = await csrfFetch(`/api/v2/tags/${itemId}/cycle-rating`, { method: 'PUT' });
|
||||
if (!res.ok) throw new Error(`cycleRating failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function suggestTags(query: string): Promise<{ success: boolean; suggestions: Record<string, number> }> {
|
||||
const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) throw new Error('suggestTags failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Item management (mod/admin) ───────────────────────────────────────────────
|
||||
|
||||
export async function deleteItem(
|
||||
itemId: number,
|
||||
reason = ''
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
const body = new URLSearchParams({ postid: String(itemId), reason });
|
||||
const res = await csrfFetch('/api/v2/admin/deletepost', { method: 'POST', body });
|
||||
if (!res.ok) throw new Error(`deleteItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function pinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||
// Backend reads id from query string: req.url.qs.id
|
||||
const res = await csrfFetch(`/mod/pin/?id=${itemId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`pinItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function unpinItem(itemId: number): Promise<{ success: boolean; pinned: boolean }> {
|
||||
const res = await csrfFetch(`/mod/unpin/?id=${itemId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`unpinItem failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
29
f0ck-svelte/src/lib/api/auth.ts
Normal file
29
f0ck-svelte/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { SessionResponse } from '$lib/types/session';
|
||||
|
||||
export async function getSession(fetchFn: typeof fetch = fetch): Promise<SessionResponse> {
|
||||
const res = await fetchFn('/api/v2/session');
|
||||
if (!res.ok) return { loggedIn: false, user: null };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
kmsi = false
|
||||
): Promise<{ success: boolean; msg?: string }> {
|
||||
const body = new URLSearchParams({ username, password, ...(kmsi ? { kmsi: '1' } : {}) });
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'include'
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch('/logout', { method: 'POST', credentials: 'include' });
|
||||
}
|
||||
96
f0ck-svelte/src/lib/api/items.ts
Normal file
96
f0ck-svelte/src/lib/api/items.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ItemsResponse, ItemResponse, RandomResponse } from '$lib/types/item';
|
||||
|
||||
export interface GetItemsParams {
|
||||
page?: number;
|
||||
mode?: number;
|
||||
ratings?: string;
|
||||
mime?: string;
|
||||
tag?: string;
|
||||
hall?: string;
|
||||
user?: string;
|
||||
fav?: boolean;
|
||||
random?: boolean;
|
||||
strict?: boolean;
|
||||
min_xd?: number;
|
||||
newer?: number;
|
||||
tagger?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of grid items.
|
||||
* Called from +page.ts (server-side) and from the client for infinite-scroll.
|
||||
*/
|
||||
export async function getItems(
|
||||
params: GetItemsParams,
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<ItemsResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||
if (params.ratings) qs.set('ratings', params.ratings);
|
||||
if (params.mime) qs.set('mime', params.mime);
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.hall) qs.set('hall', params.hall);
|
||||
if (params.user) qs.set('user', params.user);
|
||||
if (params.fav) qs.set('fav', 'true');
|
||||
if (params.random) qs.set('random', '1');
|
||||
if (params.strict) qs.set('strict', '1');
|
||||
if (params.min_xd) qs.set('min_xd', String(params.min_xd));
|
||||
if (params.newer) qs.set('newer', String(params.newer));
|
||||
if (params.tagger) qs.set('tagger', params.tagger);
|
||||
|
||||
const res = await fetchFn(`/api/v2/items?${qs}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch items: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface GetItemParams {
|
||||
mode?: number;
|
||||
ratings?: string;
|
||||
tag?: string;
|
||||
hall?: string;
|
||||
user?: string;
|
||||
fav?: boolean;
|
||||
random?: boolean;
|
||||
strict?: boolean;
|
||||
mime?: string;
|
||||
userHall?: string;
|
||||
userHallOwner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single item with its neighbours and metadata.
|
||||
*/
|
||||
export async function getItem(
|
||||
id: number | string,
|
||||
params: GetItemParams = {},
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<ItemResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.mode !== undefined) qs.set('mode', String(params.mode));
|
||||
if (params.ratings) qs.set('ratings', params.ratings);
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.hall) qs.set('hall', params.hall);
|
||||
if (params.user) qs.set('user', params.user);
|
||||
if (params.fav) qs.set('fav', 'true');
|
||||
if (params.random) qs.set('random', '1');
|
||||
if (params.strict) qs.set('strict', '1');
|
||||
if (params.mime) qs.set('mime', params.mime);
|
||||
if (params.userHall) qs.set('userHall', params.userHall);
|
||||
if (params.userHallOwner) qs.set('userHallOwner', params.userHallOwner);
|
||||
|
||||
const suffix = qs.toString() ? `?${qs}` : '';
|
||||
const res = await fetchFn(`/api/v2/items/${id}${suffix}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch item ${id}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random item ID (optionally within a context).
|
||||
* Returns the JSON response; caller should navigate to response.url.
|
||||
*/
|
||||
export async function getRandom(fetchFn: typeof fetch = fetch): Promise<RandomResponse> {
|
||||
const res = await fetchFn('/api/v2/random');
|
||||
if (!res.ok) throw new Error(`Failed to get random item: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
1
f0ck-svelte/src/lib/assets/favicon.svg
Normal file
1
f0ck-svelte/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { getComments, postComment, deleteComment } from '$lib/api/actions';
|
||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { timeAgo, formatDate } from '$lib/utils/timeago';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
|
||||
interface Props {
|
||||
itemId: number;
|
||||
}
|
||||
let { itemId }: Props = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<Comment[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let text = $state('');
|
||||
let replyTo = $state<Comment | null>(null);
|
||||
let submitting = $state(false);
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await getComments(itemId);
|
||||
comments = data.comments ?? [];
|
||||
} catch {
|
||||
error = 'Failed to load comments.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run on mount and when itemId changes
|
||||
$effect(() => { load(); });
|
||||
|
||||
// ── Live updates via SSE ──────────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type !== 'comments') return;
|
||||
const d = event.data as { item_id: number; type?: string };
|
||||
if (d.item_id !== itemId) return;
|
||||
// Reload the full comment list on any comment activity for this item
|
||||
load();
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
// ── Post comment ──────────────────────────────────────────────────────────
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await postComment({
|
||||
itemId,
|
||||
content: text.trim(),
|
||||
replyTo: replyTo?.id ?? null
|
||||
});
|
||||
if (res.success) {
|
||||
text = '';
|
||||
replyTo = null;
|
||||
await load();
|
||||
flash('Comment posted', 'success');
|
||||
} else {
|
||||
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete comment ────────────────────────────────────────────────────────
|
||||
async function handleDelete(comment: Comment) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
try {
|
||||
const res = await deleteComment(comment.id);
|
||||
if (res.success) {
|
||||
await load();
|
||||
flash('Deleted', 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to delete', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function canDelete(comment: Comment) {
|
||||
return $session && ($isModOrAdmin || $session.user === comment.username);
|
||||
}
|
||||
|
||||
function avatarSrc(comment: Comment): string {
|
||||
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
|
||||
if (comment.avatar) return `/t/${comment.avatar}.webp`;
|
||||
return '/a/default.png';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
|
||||
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
|
||||
</span>
|
||||
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
|
||||
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment form -->
|
||||
{#if $session}
|
||||
<form onsubmit={submit} class="flex flex-col gap-2">
|
||||
{#if replyTo}
|
||||
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
|
||||
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
|
||||
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
|
||||
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
placeholder="Write a comment…"
|
||||
rows="2"
|
||||
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
|
||||
disabled={submitting}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm self-end"
|
||||
disabled={submitting || !text.trim()}
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="text-xs opacity-40">
|
||||
<a href="/login" class="link link-primary">Log in</a> to comment.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Comments list -->
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-4">
|
||||
<span class="loading loading-dots loading-sm opacity-40"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-xs text-error opacity-70">{error}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each comments as comment (comment.id)}
|
||||
<li
|
||||
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
|
||||
id="comment-{comment.id}"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
|
||||
<div class="avatar">
|
||||
<div class="w-7 rounded-full border border-base-300">
|
||||
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-1.5 flex-wrap">
|
||||
<a
|
||||
href="/user/{comment.username?.toLowerCase()}"
|
||||
class="font-semibold text-xs hover:text-primary transition-colors"
|
||||
style={comment.username_color ? `color: ${comment.username_color}` : ''}
|
||||
>
|
||||
{comment.display_name || comment.username}
|
||||
</a>
|
||||
<time
|
||||
datetime={comment.stamp_full}
|
||||
title={formatDate(comment.stamp_full)}
|
||||
class="text-xs opacity-40"
|
||||
>
|
||||
{comment.stamp}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if comment.is_deleted}
|
||||
<p class="text-xs italic opacity-50">— deleted —</p>
|
||||
{:else}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
|
||||
{@html comment.content}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !comment.is_deleted}
|
||||
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if $session}
|
||||
<button
|
||||
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
|
||||
class="btn btn-ghost btn-xs gap-1 text-xs"
|
||||
>
|
||||
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
|
||||
</button>
|
||||
{/if}
|
||||
{#if canDelete(comment)}
|
||||
<button
|
||||
onclick={() => handleDelete(comment)}
|
||||
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
83
f0ck-svelte/src/lib/components/item/ItemCard.svelte
Normal file
83
f0ck-svelte/src/lib/components/item/ItemCard.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { GridItem } from '$lib/types/item';
|
||||
import type { ItemLink } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
item: GridItem;
|
||||
link: ItemLink;
|
||||
}
|
||||
let { item, link }: Props = $props();
|
||||
|
||||
// Build the item URL respecting the current filter context
|
||||
const itemUrl = $derived(`${link.main}${item.id}${link.suffix}`);
|
||||
const thumbUrl = $derived(`/t/${item.id}.webp`);
|
||||
|
||||
// Rating badge
|
||||
const ratingClass = $derived(
|
||||
item.tag === 'NSFL' ? 'badge-nsfl' :
|
||||
item.tag === 'NSFW' ? 'badge-nsfw' :
|
||||
item.tag === 'SFW' ? 'badge-sfw' : 'badge-unt'
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={itemUrl}
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="
|
||||
group relative block overflow-hidden rounded-lg bg-base-200 border border-base-300
|
||||
hover:border-primary/50 transition-all duration-200
|
||||
hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/10
|
||||
{item.thumb_size === 2 ? 'col-span-2 row-span-2' : ''}
|
||||
"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-video w-full overflow-hidden bg-base-300">
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt="Item {item.id}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).src = '/s/img/404.gif'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Overlay on hover -->
|
||||
<div class="
|
||||
absolute inset-0 bg-gradient-to-t from-base-100/90 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||
flex flex-col justify-end p-2 gap-1
|
||||
">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-base-content/70 font-mono">#{item.id}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if item.is_oc}
|
||||
<span class="badge badge-xs badge-accent">OC</span>
|
||||
{/if}
|
||||
{#if item.is_pinned}
|
||||
<i class="fa-solid fa-thumbtack text-xs text-primary"></i>
|
||||
{/if}
|
||||
{#if item.xd_tier > 0}
|
||||
<span class="badge badge-xs badge-warning font-mono">{item.xd_label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if item.display_name || item.username}
|
||||
<span class="text-xs text-base-content/60 truncate">
|
||||
{item.display_name || item.username}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rating badge (always visible) -->
|
||||
{#if item.tag}
|
||||
<div class="absolute top-1.5 right-1.5">
|
||||
<span class="badge badge-xs {ratingClass}">{item.tag}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notification dot -->
|
||||
{#if item.has_notification}
|
||||
<div class="absolute top-1.5 left-1.5 w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
{/if}
|
||||
</a>
|
||||
503
f0ck-svelte/src/lib/components/item/ItemView.svelte
Normal file
503
f0ck-svelte/src/lib/components/item/ItemView.svelte
Normal file
@@ -0,0 +1,503 @@
|
||||
<script lang="ts">
|
||||
import type { FullItem, Pagination, ItemLink, FavoriteUser, Tag } from '$lib/types/item';
|
||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||
import AudioPlayer from '$lib/components/media/AudioPlayer.svelte';
|
||||
import ImageViewer from '$lib/components/media/ImageViewer.svelte';
|
||||
import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte';
|
||||
import TagList from '$lib/components/item/TagList.svelte';
|
||||
import Comments from '$lib/components/item/Comments.svelte';
|
||||
import { mimeCategory } from '$lib/utils/mimeHelpers';
|
||||
import { formatDate } from '$lib/utils/timeago';
|
||||
import { session, isModOrAdmin } from '$lib/stores/session';
|
||||
import { goto, preloadData } from '$app/navigation';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { onSSE } from '$lib/stores/sse';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { broadcastPin } from '$lib/stores/pin';
|
||||
import {
|
||||
toggleFav, addTag, deleteTag,
|
||||
deleteItem, pinItem, unpinItem, cycleRating
|
||||
} from '$lib/api/actions';
|
||||
|
||||
interface Props {
|
||||
item: FullItem;
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
isSubscribed?: boolean;
|
||||
}
|
||||
let { item, pagination, link, isSubscribed = false }: Props = $props();
|
||||
|
||||
// ── Reactive local state (mutable copies updated after actions) ───────────
|
||||
let favorites = $state(item.favorites);
|
||||
let tags = $state(item.tags);
|
||||
let isPinned = $state(item.is_pinned);
|
||||
let isOc = $state(item.is_oc);
|
||||
let isSfw = $state(item.is_sfw);
|
||||
let isNsfw = $state(item.is_nsfw);
|
||||
let isNsfl = $state(item.is_nsfl);
|
||||
|
||||
// Sync when item prop changes (navigation)
|
||||
$effect(() => {
|
||||
favorites = item.favorites;
|
||||
tags = item.tags;
|
||||
isPinned = item.is_pinned;
|
||||
isOc = item.is_oc;
|
||||
isSfw = item.is_sfw;
|
||||
isNsfw = item.is_nsfw;
|
||||
isNsfl = item.is_nsfl;
|
||||
});
|
||||
|
||||
// ── Live SSE updates ──────────────────────────────────────────────────────
|
||||
const unsubSSE = onSSE((event) => {
|
||||
if (event.type === 'favorites') {
|
||||
const d = event.data as { item_id: number; favs: FavoriteUser[] };
|
||||
if (d.item_id === item.id) favorites = d.favs;
|
||||
} else if (event.type === 'tags') {
|
||||
const d = event.data as { item_id: number; tags: Tag[] };
|
||||
if (d.item_id === item.id) {
|
||||
tags = d.tags;
|
||||
// Re-derive rating state from the fresh tag list
|
||||
const SFW_ID = 1, NSFW_ID = 2;
|
||||
const ids = d.tags.map(t => t.id);
|
||||
isSfw = ids.includes(SFW_ID);
|
||||
isNsfw = ids.includes(NSFW_ID);
|
||||
isNsfl = !isSfw && !isNsfw && d.tags.some(t => t.badge === 'badge-nsfl');
|
||||
}
|
||||
} else if (event.type === 'delete_item') {
|
||||
const d = event.data as { id: number };
|
||||
if (d.id === item.id) goto('/');
|
||||
}
|
||||
});
|
||||
onDestroy(unsubSSE);
|
||||
|
||||
const cat = $derived(mimeCategory(item.mime));
|
||||
const ytId = $derived(cat === 'youtube' ? item.dest.replace('yt:', '') : '');
|
||||
|
||||
const prevUrl = $derived(pagination.prev ? `${link.main}${pagination.prev}${link.suffix}` : null);
|
||||
const nextUrl = $derived(pagination.next ? `${link.main}${pagination.next}${link.suffix}` : null);
|
||||
|
||||
const isFaved = $derived(favorites.some(f => f.user === $session?.user));
|
||||
|
||||
// Rating derived from local mutable state (so cycle updates instantly)
|
||||
const ratingClass = $derived(
|
||||
isNsfl ? 'badge-nsfl' : isNsfw ? 'badge-nsfw' : isSfw ? 'badge-sfw' : 'badge-unt'
|
||||
);
|
||||
const ratingLabel = $derived(
|
||||
isNsfl ? 'NSFL' : isNsfw ? 'NSFW' : isSfw ? 'SFW' : '?'
|
||||
);
|
||||
|
||||
const canManage = $derived(!!($session && ($session.admin || $session.is_moderator || $session.user === item.username)));
|
||||
const isMod = $derived(!!($session && ($session.admin || $session.is_moderator)));
|
||||
|
||||
// ── Cycle rating (mod-only) ───────────────────────────────────────────────
|
||||
let ratingCycling = $state(false);
|
||||
async function handleCycleRating() {
|
||||
if (!isMod) return;
|
||||
ratingCycling = true;
|
||||
try {
|
||||
const res = await cycleRating(item.id);
|
||||
if (res.success) {
|
||||
isSfw = res.rating_class === 'sfw';
|
||||
isNsfw = res.rating_class === 'nsfw';
|
||||
isNsfl = res.rating_class === 'nsfl';
|
||||
flash(`Rating → ${res.rating_label}`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to cycle rating', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
ratingCycling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard navigation ───────────────────────────────────────────────────
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'ArrowLeft' && prevUrl) goto(prevUrl);
|
||||
if (e.key === 'ArrowRight' && nextUrl) goto(nextUrl);
|
||||
}
|
||||
|
||||
// ── Tag input state ───────────────────────────────────────────────────────
|
||||
let tagInput = $state('');
|
||||
let tagAdding = $state(false);
|
||||
let showTagInput = $state(false);
|
||||
let suggestions = $state<string[]>([]);
|
||||
|
||||
async function submitTag(e: Event) {
|
||||
e.preventDefault();
|
||||
const name = tagInput.trim();
|
||||
if (!name) return;
|
||||
tagAdding = true;
|
||||
try {
|
||||
const res = await addTag(item.id, name);
|
||||
if (res.success) {
|
||||
tags = res.tags;
|
||||
tagInput = '';
|
||||
showTagInput = false;
|
||||
flash(`Tag "${name}" added`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to add tag', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
} finally {
|
||||
tagAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTag(tag: Tag) {
|
||||
try {
|
||||
const res = await deleteTag(item.id, tag.tag);
|
||||
if (res.success) {
|
||||
tags = res.tags;
|
||||
flash(`Tag "${tag.tag}" removed`, 'success');
|
||||
} else {
|
||||
flash(res.msg ?? 'Failed to remove tag', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favorite ──────────────────────────────────────────────────────────────
|
||||
let favLoading = $state(false);
|
||||
async function handleFav() {
|
||||
if (!$session) { flash('Log in to favorite', 'warning'); return; }
|
||||
favLoading = true;
|
||||
try {
|
||||
const res = await toggleFav(item.id);
|
||||
if (res.success) favorites = res.favs;
|
||||
} catch {
|
||||
flash('Failed to update favorite', 'error');
|
||||
} finally {
|
||||
favLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function handleDelete() {
|
||||
const reason = prompt('Reason for deletion (optional):') ?? '';
|
||||
if (reason === null) return; // cancelled
|
||||
try {
|
||||
const res = await deleteItem(item.id, reason);
|
||||
if (res.success) {
|
||||
flash('Item deleted', 'success');
|
||||
goto('/');
|
||||
} else {
|
||||
flash(res.msg ?? 'Delete failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pin ───────────────────────────────────────────────────────────────────
|
||||
async function handlePin() {
|
||||
try {
|
||||
const res = isPinned ? await unpinItem(item.id) : await pinItem(item.id);
|
||||
if (res.success) {
|
||||
isPinned = res.pinned;
|
||||
broadcastPin(item.id, res.pinned); // patch grid card live
|
||||
flash(res.pinned ? 'Pinned' : 'Unpinned', 'success');
|
||||
} else {
|
||||
flash('Pin failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
flash('Network error', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 max-w-[1600px] mx-auto px-4 py-4">
|
||||
|
||||
<!-- ── LEFT SIDEBAR ────────────────────────────────────────────────────── -->
|
||||
<aside class="lg:w-72 xl:w-80 flex-shrink-0 flex flex-col gap-3 order-2 lg:order-1">
|
||||
|
||||
<!-- Tags card -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3 gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">Tags</span>
|
||||
{#if $session}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => showTagInput = !showTagInput}
|
||||
aria-label="Add tag"
|
||||
>
|
||||
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tag input -->
|
||||
{#if showTagInput}
|
||||
<form onsubmit={submitTag} class="flex gap-1">
|
||||
<input
|
||||
bind:value={tagInput}
|
||||
type="text"
|
||||
placeholder="tag name…"
|
||||
class="input input-xs input-bordered flex-1"
|
||||
disabled={tagAdding}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-xs btn-primary"
|
||||
disabled={tagAdding || !tagInput.trim()}
|
||||
>
|
||||
{#if tagAdding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
OK
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if tags.length > 0}
|
||||
<TagList {tags} canRemove={$isModOrAdmin} onRemove={handleRemoveTag} />
|
||||
{:else}
|
||||
<p class="text-xs opacity-40">No tags yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- xD Score -->
|
||||
{#if item.xd_tier > 0}
|
||||
<div class="badge badge-warning badge-lg gap-1 self-start font-mono">
|
||||
<i class="fa-solid fa-face-laugh-squint" aria-hidden="true"></i>
|
||||
{item.xd_label}
|
||||
<span class="opacity-70 text-xs">{item.xd_score}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3">
|
||||
<Comments itemId={item.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN CONTENT ────────────────────────────────────────────────────── -->
|
||||
<main class="flex-1 min-w-0 flex flex-col gap-3 order-1 lg:order-2">
|
||||
|
||||
{#if item.title}
|
||||
<h1 class="text-lg font-semibold leading-snug">{item.title}</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Media + prev/next arrows -->
|
||||
<div class="relative group">
|
||||
<!-- Prev -->
|
||||
<a
|
||||
href={prevUrl ?? '#'}
|
||||
aria-disabled={!prevUrl}
|
||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||
class="absolute left-0 top-0 bottom-0 w-16 z-10 flex items-center justify-start pl-2
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
{!prevUrl ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="rounded-lg overflow-hidden bg-base-300">
|
||||
{#if cat === 'youtube'}
|
||||
<YouTubeEmbed videoId={ytId} />
|
||||
{:else if cat === 'video'}
|
||||
<VideoPlayer src={item.dest} mime={item.mime} />
|
||||
{:else if cat === 'audio'}
|
||||
<AudioPlayer src={item.dest} mime={item.mime} coverart={item.coverart} />
|
||||
{:else if cat === 'image'}
|
||||
<ImageViewer src={item.dest} alt="Item {item.id}" />
|
||||
{:else if cat === 'pdf'}
|
||||
<iframe src="{item.dest}#toolbar=0" class="w-full h-[75dvh] border-0" title="PDF"></iframe>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center p-12 gap-4">
|
||||
<i class="fa-solid fa-file-zipper text-5xl opacity-40" aria-hidden="true"></i>
|
||||
<a href={item.dest} download class="btn btn-primary gap-2">
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next -->
|
||||
<a
|
||||
href={nextUrl ?? '#'}
|
||||
aria-disabled={!nextUrl}
|
||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||
class="absolute right-0 top-0 bottom-0 w-16 z-10 flex items-center justify-end pr-2
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
{!nextUrl ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<div class="btn btn-circle btn-sm bg-base-100/80 backdrop-blur border-0">
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Metadata / action bar -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-3 gap-2">
|
||||
|
||||
<!-- Prev / Random / Next -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<a href={prevUrl ?? '#'} aria-disabled={!prevUrl}
|
||||
onmouseenter={() => prevUrl && preloadData(prevUrl)}
|
||||
class="btn btn-sm btn-ghost gap-1 {!prevUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Prev
|
||||
</a>
|
||||
<a href="/random" class="btn btn-sm btn-ghost" aria-label="Random item">
|
||||
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
|
||||
</a>
|
||||
<a href={nextUrl ?? '#'} aria-disabled={!nextUrl}
|
||||
onmouseenter={() => nextUrl && preloadData(nextUrl)}
|
||||
class="btn btn-sm btn-ghost gap-1 {!nextUrl ? 'opacity-30 pointer-events-none' : ''}">
|
||||
Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- Item metadata row -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<a href="/{item.id}" class="font-mono text-xs opacity-60 hover:opacity-100 transition-opacity">
|
||||
#{item.id}
|
||||
</a>
|
||||
{#if isMod}
|
||||
<button
|
||||
onclick={handleCycleRating}
|
||||
disabled={ratingCycling}
|
||||
class="badge badge-sm {ratingClass} cursor-pointer hover:brightness-125 transition-all border-0 gap-1"
|
||||
title="Cycle rating: {ratingLabel} → {isNsfl ? 'SFW' : isNsfw ? 'NSFL' : isSfw ? 'NSFW' : 'SFW'}"
|
||||
aria-label="Cycle rating"
|
||||
>
|
||||
{#if ratingCycling}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-solid fa-rotate text-[9px]" aria-hidden="true"></i>
|
||||
{/if}
|
||||
{ratingLabel}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge badge-sm {ratingClass}">{ratingLabel}</span>
|
||||
{/if}
|
||||
<a href="/user/{item.username.toLowerCase()}"
|
||||
class="font-medium hover:text-primary transition-colors"
|
||||
style={item.author_color ? `color: ${item.author_color}` : ''}>
|
||||
{item.author_display_name || item.username}
|
||||
</a>
|
||||
{#if isOc}
|
||||
<span class="badge badge-sm badge-accent">OC</span>
|
||||
{/if}
|
||||
{#if isPinned}
|
||||
<i class="fa-solid fa-thumbtack text-primary text-xs" aria-label="Pinned"></i>
|
||||
{/if}
|
||||
{#if item.primaryHall}
|
||||
<a href="/h/{item.primaryHall.slug}" class="badge badge-sm badge-neutral gap-1 hover:brightness-125">
|
||||
<i class="fa-solid fa-layer-group text-[10px]" aria-hidden="true"></i>
|
||||
{item.primaryHall.name}
|
||||
</a>
|
||||
{/if}
|
||||
<time datetime={item.timestamp.timefull} title={formatDate(item.timestamp.timefull)} class="text-xs opacity-50">
|
||||
{item.timestamp.timeago}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center flex-wrap gap-1 pt-1">
|
||||
<!-- Favorite -->
|
||||
<button
|
||||
onclick={handleFav}
|
||||
disabled={favLoading || !$session}
|
||||
class="btn btn-ghost btn-xs gap-1 {isFaved ? 'text-error' : ''}"
|
||||
title="{isFaved ? 'Unfavorite' : 'Favorite'}"
|
||||
aria-label="{isFaved ? 'Remove favorite' : 'Add favorite'}"
|
||||
>
|
||||
{#if favLoading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<i class="fa-{isFaved ? 'solid' : 'regular'} fa-heart" aria-hidden="true"></i>
|
||||
{/if}
|
||||
{#if favorites.length > 0}
|
||||
<span class="text-xs">{favorites.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Subscribe (stub) -->
|
||||
<button class="btn btn-ghost btn-xs" title="{isSubscribed ? 'Subscribed' : 'Subscribe'}" aria-label="Subscribe">
|
||||
<i class="fa-{isSubscribed ? 'solid' : 'regular'} fa-bell" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Link copy -->
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Copy link"
|
||||
aria-label="Copy link"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(location.href);
|
||||
flash('Link copied!', 'success');
|
||||
}}
|
||||
>
|
||||
<i class="fa-solid fa-link text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mod / owner actions -->
|
||||
{#if canManage}
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<!-- Pin/Unpin -->
|
||||
<button
|
||||
onclick={handlePin}
|
||||
class="btn btn-ghost btn-xs {isPinned ? 'text-primary' : ''}"
|
||||
title="{isPinned ? 'Unpin' : 'Pin'}"
|
||||
aria-label="{isPinned ? 'Unpin item' : 'Pin item'}"
|
||||
>
|
||||
<i class="fa-solid fa-thumbtack text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorites avatars -->
|
||||
{#if favorites.length > 0}
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
{#each favorites as fav (fav.user)}
|
||||
<a href="/user/{fav.user.toLowerCase()}" title={fav.display_name || fav.user} class="tooltip" data-tip={fav.display_name || fav.user}>
|
||||
<div class="avatar">
|
||||
<div class="w-7 rounded-full border-2 {fav.username_color ? '' : 'border-base-300'}"
|
||||
style={fav.username_color ? `border-color: ${fav.username_color}` : ''}>
|
||||
{#if fav.avatar_file}
|
||||
<img src="/a/{fav.avatar_file}" alt={fav.user} loading="lazy" />
|
||||
{:else if fav.avatar}
|
||||
<img src="/t/{fav.avatar}.webp" alt={fav.user} loading="lazy" />
|
||||
{:else}
|
||||
<img src="/a/default.png" alt={fav.user} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
37
f0ck-svelte/src/lib/components/item/TagList.svelte
Normal file
37
f0ck-svelte/src/lib/components/item/TagList.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
canRemove?: boolean;
|
||||
onRemove?: (tag: Tag) => void;
|
||||
}
|
||||
let { tags, canRemove = false, onRemove }: Props = $props();
|
||||
|
||||
// Map badge class names from the existing tag badge values
|
||||
const badgeClass = (badge: string) => {
|
||||
if (badge === 'badge-sfw') return 'badge-sfw';
|
||||
if (badge === 'badge-nsfw') return 'badge-nsfw';
|
||||
if (badge === 'badge-nsfl') return 'badge-nsfl';
|
||||
return 'badge-neutral';
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each tags as tag (tag.id)}
|
||||
<span class="badge {badgeClass(tag.badge)} gap-1 cursor-pointer hover:brightness-125 transition-all">
|
||||
<a href="/tag/{encodeURIComponent(tag.normalized)}" class="no-underline">
|
||||
{tag.tag}
|
||||
</a>
|
||||
{#if canRemove && onRemove}
|
||||
<button
|
||||
class="hover:text-error ml-0.5 transition-colors"
|
||||
onclick={() => onRemove?.(tag)}
|
||||
aria-label="Remove tag {tag.tag}"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-[10px]"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
100
f0ck-svelte/src/lib/components/layout/Navbar.svelte
Normal file
100
f0ck-svelte/src/lib/components/layout/Navbar.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { session, loggedIn } from '$lib/stores/session';
|
||||
import { filterLabel, activeMode, activeRatings } from '$lib/stores/preferences';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getRandom } from '$lib/api/items';
|
||||
import { flash } from '$lib/stores/flash';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
async function goRandom() {
|
||||
try {
|
||||
const res = await getRandom();
|
||||
if (res.success) goto(res.url);
|
||||
} catch {
|
||||
flash('Could not fetch a random item', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// In Svelte 5 runes mode, subscribe to legacy stores via $state + store.subscribe
|
||||
// OR just use them in the template with $ prefix — both work, but $ in templates
|
||||
// is fine for Svelte writable stores even in runes mode.
|
||||
</script>
|
||||
|
||||
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-50 px-4 gap-2">
|
||||
<!-- Logo / Home -->
|
||||
<div class="navbar-start gap-2">
|
||||
<a href="/" class="flex items-center gap-2 group">
|
||||
<span class="text-xl font-black tracking-tight text-primary group-hover:text-accent transition-colors">
|
||||
f0ckm
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: actions -->
|
||||
<div class="navbar-center gap-1 hidden sm:flex">
|
||||
<!-- Random button -->
|
||||
<button
|
||||
class="btn btn-ghost btn-sm gap-1"
|
||||
onclick={goRandom}
|
||||
title="Random item"
|
||||
aria-label="Random item"
|
||||
>
|
||||
<i class="fa-solid fa-shuffle text-sm" aria-hidden="true"></i>
|
||||
<span class="hidden md:inline">Random</span>
|
||||
</button>
|
||||
|
||||
<!-- Rating filter badge -->
|
||||
<div class="dropdown dropdown-bottom">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="badge badge-lg cursor-pointer font-mono select-none badge-neutral"
|
||||
aria-label="Content filter"
|
||||
>
|
||||
{$filterLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: user area -->
|
||||
<div class="navbar-end gap-2">
|
||||
{#if $loggedIn}
|
||||
<a href="/upload" class="btn btn-primary btn-sm gap-1" title="Upload">
|
||||
<i class="fa-solid fa-upload text-sm" aria-hidden="true"></i>
|
||||
<span class="hidden sm:inline">Upload</span>
|
||||
</a>
|
||||
<!-- Avatar / user menu -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder" aria-label="User menu">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-8 grid place-items-center">
|
||||
{#if $session?.avatar_file}
|
||||
<img src="/a/{$session.avatar_file}" alt="avatar" class="rounded-full" />
|
||||
{:else if $session?.avatar}
|
||||
<img src="/t/{$session.avatar}.webp" alt="avatar" class="rounded-full" />
|
||||
{:else}
|
||||
<span class="text-xs font-bold">{($session?.user ?? '?').charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-xl border border-base-300">
|
||||
<li class="menu-title text-xs opacity-60">{$session?.display_name || $session?.user}</li>
|
||||
<li><a href="/user/{$session?.user}"><i class="fa-solid fa-user w-4" aria-hidden="true"></i> Profile</a></li>
|
||||
<li><a href="/settings"><i class="fa-solid fa-gear w-4" aria-hidden="true"></i> Settings</a></li>
|
||||
{#if $session?.admin || $session?.is_moderator}
|
||||
<li><a href="/admin"><i class="fa-solid fa-shield-halved w-4" aria-hidden="true"></i> Admin</a></li>
|
||||
{/if}
|
||||
<li class="divider my-1"></li>
|
||||
<li>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="text-error w-full text-left">
|
||||
<i class="fa-solid fa-right-from-bracket w-4" aria-hidden="true"></i> Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<a href="/login" class="btn btn-ghost btn-sm">Login</a>
|
||||
<a href="/register" class="btn btn-primary btn-sm">Register</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
24
f0ck-svelte/src/lib/components/media/AudioPlayer.svelte
Normal file
24
f0ck-svelte/src/lib/components/media/AudioPlayer.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
mime: string;
|
||||
coverart?: string;
|
||||
}
|
||||
let { src, mime, coverart = '/s/img/music.webp' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-full flex items-center justify-center rounded-lg overflow-hidden bg-black"
|
||||
style="background-image: url('{coverart}'); background-size: contain; background-repeat: no-repeat; background-position: center; min-height: 320px;"
|
||||
>
|
||||
<!-- Dark overlay over coverart -->
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<audio
|
||||
{src}
|
||||
loop
|
||||
controls
|
||||
preload="auto"
|
||||
class="relative z-10 w-full max-w-md"
|
||||
></audio>
|
||||
</div>
|
||||
58
f0ck-svelte/src/lib/components/media/ImageViewer.svelte
Normal file
58
f0ck-svelte/src/lib/components/media/ImageViewer.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
}
|
||||
let { src, alt = 'Image' }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let imgEl = $state<HTMLImageElement | undefined>();
|
||||
|
||||
function toggleExpand() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
// ESC to close modal
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded) expanded = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative flex items-center justify-center rounded-lg overflow-hidden bg-base-300 cursor-zoom-in">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
{src}
|
||||
{alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onclick={toggleExpand}
|
||||
class="max-h-[80dvh] w-auto max-w-full object-contain select-none"
|
||||
/>
|
||||
<button
|
||||
class="absolute bottom-2 right-2 btn btn-xs btn-ghost opacity-60 hover:opacity-100"
|
||||
onclick={toggleExpand}
|
||||
aria-label="Expand"
|
||||
>
|
||||
<i class="fa-solid fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen modal -->
|
||||
{#if expanded}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9998] bg-black/95 flex items-center justify-center cursor-zoom-out"
|
||||
onclick={toggleExpand}
|
||||
>
|
||||
<img {src} {alt} class="max-h-dvh max-w-dvw object-contain" />
|
||||
<button
|
||||
class="absolute top-4 right-4 btn btn-circle btn-ghost text-white"
|
||||
onclick={toggleExpand}
|
||||
aria-label="Close"
|
||||
>
|
||||
<i class="fa-solid fa-xmark text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
36
f0ck-svelte/src/lib/components/media/VideoPlayer.svelte
Normal file
36
f0ck-svelte/src/lib/components/media/VideoPlayer.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
mime: string;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
let { src, mime, autoplay = false }: Props = $props();
|
||||
|
||||
let videoEl = $state<HTMLVideoElement | undefined>();
|
||||
|
||||
// Keyboard shortcut: space to toggle play/pause
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.target !== document.body) return;
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
else videoEl?.pause();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative w-full bg-black flex items-center justify-center rounded-lg overflow-hidden">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
{src}
|
||||
{autoplay}
|
||||
loop
|
||||
playsinline
|
||||
controls
|
||||
class="max-h-[80dvh] w-full object-contain"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
18
f0ck-svelte/src/lib/components/media/YouTubeEmbed.svelte
Normal file
18
f0ck-svelte/src/lib/components/media/YouTubeEmbed.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
videoId: string; // e.g. "dQw4w9WgXcQ"
|
||||
}
|
||||
let { videoId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full rounded-lg overflow-hidden bg-black" style="padding-top: 56.25%">
|
||||
<iframe
|
||||
class="absolute inset-0 w-full h-full"
|
||||
src="https://www.youtube.com/embed/{videoId}"
|
||||
title="YouTube video"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
42
f0ck-svelte/src/lib/components/ui/FlashMessages.svelte
Normal file
42
f0ck-svelte/src/lib/components/ui/FlashMessages.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { flashes, dismiss, type FlashType } from '$lib/stores/flash';
|
||||
|
||||
const iconMap: Record<FlashType, string> = {
|
||||
info: 'fa-solid fa-circle-info',
|
||||
success: 'fa-solid fa-circle-check',
|
||||
warning: 'fa-solid fa-triangle-exclamation',
|
||||
error: 'fa-solid fa-circle-xmark'
|
||||
};
|
||||
|
||||
const alertMap: Record<FlashType, string> = {
|
||||
info: 'alert-info',
|
||||
success: 'alert-success',
|
||||
warning: 'alert-warning',
|
||||
error: 'alert-error'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
||||
{#each $flashes as msg (msg.id)}
|
||||
<div
|
||||
role="alert"
|
||||
class="alert {alertMap[msg.type]} pointer-events-auto shadow-lg min-w-64 max-w-sm
|
||||
animate-[slide-up_0.2s_ease-out]"
|
||||
>
|
||||
<i class="{iconMap[msg.type]}"></i>
|
||||
<span class="flex-1 text-sm">{msg.text}</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => dismiss(msg.id)}
|
||||
aria-label="Dismiss"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
62
f0ck-svelte/src/lib/components/ui/Pagination.svelte
Normal file
62
f0ck-svelte/src/lib/components/ui/Pagination.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Pagination, ItemLink } from '$lib/types/item';
|
||||
|
||||
interface Props {
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
}
|
||||
let { pagination, link }: Props = $props();
|
||||
|
||||
const base = $derived(link.main + link.path);
|
||||
const suffix = $derived(link.suffix ?? '');
|
||||
</script>
|
||||
|
||||
{#if pagination.end > 1}
|
||||
<nav class="flex justify-center items-center gap-1 py-4" aria-label="Pagination">
|
||||
<!-- Prev -->
|
||||
<a
|
||||
href={pagination.prev ? `${base}${pagination.prev}${suffix}` : undefined}
|
||||
class="btn btn-sm btn-ghost {!pagination.prev ? 'btn-disabled opacity-30' : ''}"
|
||||
aria-disabled={!pagination.prev}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
<!-- First page -->
|
||||
{#if pagination.cheat[0] > 1}
|
||||
<a href="{base}1{suffix}" class="btn btn-sm btn-ghost">1</a>
|
||||
{#if pagination.cheat[0] > 2}
|
||||
<span class="px-1 opacity-40">…</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Page range -->
|
||||
{#each pagination.cheat as p}
|
||||
<a
|
||||
href="{base}{p}{suffix}"
|
||||
class="btn btn-sm {p === pagination.current ? 'btn-primary' : 'btn-ghost'}"
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Last page -->
|
||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end}
|
||||
{#if pagination.cheat[pagination.cheat.length - 1] < pagination.end - 1}
|
||||
<span class="px-1 opacity-40">…</span>
|
||||
{/if}
|
||||
<a href="{base}{pagination.end}{suffix}" class="btn btn-sm btn-ghost">{pagination.end}</a>
|
||||
{/if}
|
||||
|
||||
<!-- Next -->
|
||||
<a
|
||||
href={pagination.next ? `${base}${pagination.next}${suffix}` : undefined}
|
||||
class="btn btn-sm btn-ghost {!pagination.next ? 'btn-disabled opacity-30' : ''}"
|
||||
aria-disabled={!pagination.next}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
1
f0ck-svelte/src/lib/index.ts
Normal file
1
f0ck-svelte/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
28
f0ck-svelte/src/lib/stores/flash.ts
Normal file
28
f0ck-svelte/src/lib/stores/flash.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── Flash message type ────────────────────────────────────────────────────────
|
||||
export type FlashType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface FlashMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: FlashType;
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
export const flashes = writable<FlashMessage[]>([]);
|
||||
|
||||
/**
|
||||
* Show a flash message. Auto-dismisses after `duration` ms.
|
||||
* Returns the message ID so callers can dismiss early if needed.
|
||||
*/
|
||||
export function flash(text: string, type: FlashType = 'info', duration = 2500): number {
|
||||
const id = ++_nextId;
|
||||
flashes.update((q) => [...q, { id, text, type }]);
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismiss(id: number): void {
|
||||
flashes.update((q) => q.filter((m) => m.id !== id));
|
||||
}
|
||||
20
f0ck-svelte/src/lib/stores/pin.ts
Normal file
20
f0ck-svelte/src/lib/stores/pin.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Lightweight broadcast store for pin/unpin events.
|
||||
* ItemView fires it after a successful pin action;
|
||||
* the main grid subscribes to patch the item card immediately.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface PinEvent {
|
||||
itemId: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
// Null when idle, set to an event when pin/unpin succeeds
|
||||
export const pinEvent = writable<PinEvent | null>(null);
|
||||
|
||||
export function broadcastPin(itemId: number, pinned: boolean) {
|
||||
pinEvent.set({ itemId, pinned });
|
||||
// Reset so subsequent identical events still trigger subscribers
|
||||
setTimeout(() => pinEvent.set(null), 0);
|
||||
}
|
||||
50
f0ck-svelte/src/lib/stores/preferences.ts
Normal file
50
f0ck-svelte/src/lib/stores/preferences.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
|
||||
// ── Content mode ─────────────────────────────────────────────────────────────
|
||||
// 0=SFW, 1=NSFW, 2=untagged, 3=ALL, 4=NSFL
|
||||
export const activeMode = writable<number>(3);
|
||||
|
||||
// ── Multi-rating filter ───────────────────────────────────────────────────────
|
||||
// When non-empty, overrides activeMode for filtering (OR logic across selected ratings)
|
||||
export type Rating = 'sfw' | 'nsfw' | 'nsfl' | 'untagged';
|
||||
export const activeRatings = writable<Rating[]>([]);
|
||||
|
||||
// ── MIME filter ───────────────────────────────────────────────────────────────
|
||||
export type MimeFilter = 'video' | 'audio' | 'image' | 'flash';
|
||||
export const activeMimes = writable<MimeFilter[]>([]);
|
||||
|
||||
// ── Derived: ratings query param string for API calls ─────────────────────────
|
||||
export const ratingsParam = derived(activeRatings, ($r) => $r.join('|') || null);
|
||||
|
||||
// ── Derived: readable label for the filter badge ─────────────────────────────
|
||||
export const filterLabel = derived(
|
||||
[activeMode, activeRatings],
|
||||
([$mode, $ratings]) => {
|
||||
if ($ratings.length > 0) {
|
||||
const abbr: Record<Rating, string> = { sfw: 'S', nsfw: 'N', nsfl: 'L', untagged: 'U' };
|
||||
return $ratings.map((r) => abbr[r]).join('+');
|
||||
}
|
||||
const labels: Record<number, string> = { 0: 'SFW', 1: 'NSFW', 2: 'UNT', 3: 'ALL', 4: 'NSFL' };
|
||||
return labels[$mode] ?? 'ALL';
|
||||
}
|
||||
);
|
||||
|
||||
// ── Persistence helpers ───────────────────────────────────────────────────────
|
||||
// Call these after receiving the session from the server to hydrate from cookie/session.
|
||||
export function initPreferences(opts: { mode: number; ratings?: string }) {
|
||||
activeMode.set(opts.mode ?? 3);
|
||||
if (opts.ratings) {
|
||||
const parsed = opts.ratings.split(/[|,]/).filter((r): r is Rating =>
|
||||
['sfw', 'nsfw', 'nsfl', 'untagged'].includes(r)
|
||||
);
|
||||
activeRatings.set(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a mode number → server mode number (for API calls when using multi-rating). */
|
||||
export function getServerMode(): number {
|
||||
const ratings = get(activeRatings);
|
||||
if (ratings.length === 0) return get(activeMode);
|
||||
const single: Record<Rating, number> = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 };
|
||||
return ratings.length === 1 ? (single[ratings[0]] ?? 3) : 3;
|
||||
}
|
||||
11
f0ck-svelte/src/lib/stores/session.ts
Normal file
11
f0ck-svelte/src/lib/stores/session.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { SessionUser } from '$lib/types/session';
|
||||
|
||||
/** The current authenticated user, or null if not logged in. */
|
||||
export const session = writable<SessionUser | null>(null);
|
||||
|
||||
/** Convenience derived — true when logged in. */
|
||||
export const loggedIn = derived(session, ($s) => $s !== null);
|
||||
|
||||
/** True when the user has admin or moderator role. */
|
||||
export const isModOrAdmin = derived(session, ($s) => !!($s?.admin || $s?.is_moderator));
|
||||
126
f0ck-svelte/src/lib/stores/sse.ts
Normal file
126
f0ck-svelte/src/lib/stores/sse.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* SSE store — connects to the backend's /api/notifications/stream endpoint.
|
||||
*
|
||||
* Emits typed events that components subscribe to with `onSSE()`.
|
||||
* Auto-reconnects with exponential backoff on disconnect.
|
||||
* Client-only: never runs during SSR.
|
||||
*/
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// ── Event types broadcast by the backend ─────────────────────────────────────
|
||||
export interface SSEComment {
|
||||
type: 'comment' | 'delete';
|
||||
id?: number;
|
||||
item_id: number;
|
||||
comment_id?: number;
|
||||
body?: string;
|
||||
username?: string;
|
||||
display_name?: string | null;
|
||||
avatar?: string | null;
|
||||
avatar_file?: string | null;
|
||||
username_color?: string | null;
|
||||
created_at?: string;
|
||||
parent_id?: number | null;
|
||||
xd_score?: number | null;
|
||||
}
|
||||
|
||||
export interface SSEFavorites {
|
||||
item_id: number;
|
||||
favs: Array<{
|
||||
user: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SSETags {
|
||||
item_id: number;
|
||||
fresh: boolean;
|
||||
tags: unknown[];
|
||||
}
|
||||
|
||||
export interface SSENewItem {
|
||||
id: number;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
export interface SSEDeleteItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: 'comments' | 'favorites' | 'tags' | 'new_item' | 'delete_item' | 'notify' | 'activity' | string;
|
||||
data: SSEComment | SSEFavorites | SSETags | SSENewItem | SSEDeleteItem | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Connection state ──────────────────────────────────────────────────────────
|
||||
export type SSEState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
export const sseState = writable<SSEState>('disconnected');
|
||||
|
||||
// ── Listener registry ─────────────────────────────────────────────────────────
|
||||
type SSEListener = (event: SSEEvent) => void;
|
||||
const listeners = new Set<SSEListener>();
|
||||
|
||||
/** Subscribe to SSE events. Returns an unsubscribe function. */
|
||||
export function onSSE(fn: SSEListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// ── Internal connection state ─────────────────────────────────────────────────
|
||||
let es: EventSource | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryDelay = 2000;
|
||||
const MAX_RETRY = 30_000;
|
||||
const tabId = browser ? Math.random().toString(36).slice(2) : 'ssr';
|
||||
|
||||
function dispatch(raw: SSEEvent) {
|
||||
for (const fn of listeners) {
|
||||
try { fn(raw); } catch (e) { console.error('[SSE] listener error', e); }
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connect, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 1.5, MAX_RETRY);
|
||||
}
|
||||
|
||||
export function connect() {
|
||||
if (!browser) return;
|
||||
if (es) { es.close(); es = null; }
|
||||
|
||||
sseState.set('connecting');
|
||||
es = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
|
||||
|
||||
es.onopen = () => {
|
||||
sseState.set('connected');
|
||||
retryDelay = 2000; // reset backoff on successful connect
|
||||
};
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as SSEEvent;
|
||||
dispatch(data);
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
sseState.set('error');
|
||||
es?.close();
|
||||
es = null;
|
||||
scheduleRetry();
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
es?.close();
|
||||
es = null;
|
||||
sseState.set('disconnected');
|
||||
}
|
||||
81
f0ck-svelte/src/lib/stores/ws.ts
Normal file
81
f0ck-svelte/src/lib/stores/ws.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── WebSocket message types ───────────────────────────────────────────────────
|
||||
export type WSMessage =
|
||||
| { type: 'pong' }
|
||||
| { type: 'notification'; count: number; item?: unknown }
|
||||
| { type: 'comment:new'; itemId: number; comment: unknown }
|
||||
| { type: 'comment:delete'; itemId: number; commentId: number }
|
||||
| { type: 'item:tag_added'; itemId: number; tag: unknown }
|
||||
| { type: 'item:favorited'; itemId: number; user: string }
|
||||
| { type: 'global:activity'; payload: unknown };
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
export const wsConnected = writable(false);
|
||||
|
||||
let _socket: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _reconnectDelay = 1000;
|
||||
|
||||
const _listeners = new Map<string, Set<(msg: WSMessage) => void>>();
|
||||
|
||||
/** Connect to the WebSocket server. Call once from the root layout. */
|
||||
export function wsConnect(): void {
|
||||
if (typeof window === 'undefined') return; // SSR guard
|
||||
if (_socket?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_socket = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
|
||||
_socket.onopen = () => {
|
||||
wsConnected.set(true);
|
||||
_reconnectDelay = 1000; // reset backoff on successful connect
|
||||
_ping();
|
||||
};
|
||||
|
||||
_socket.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data) as WSMessage;
|
||||
// Dispatch to typed listeners
|
||||
_listeners.get(msg.type)?.forEach((fn) => fn(msg));
|
||||
_listeners.get('*')?.forEach((fn) => fn(msg));
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
_socket.onclose = () => {
|
||||
wsConnected.set(false);
|
||||
_socket = null;
|
||||
// Exponential backoff reconnect
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
||||
wsConnect();
|
||||
}, _reconnectDelay);
|
||||
};
|
||||
|
||||
_socket.onerror = () => {
|
||||
_socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a typed message to the server. */
|
||||
export function wsSend(msg: object): void {
|
||||
if (_socket?.readyState === WebSocket.OPEN) {
|
||||
_socket.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to a specific message type. Returns an unsubscribe function. */
|
||||
export function wsOn(type: string, fn: (msg: WSMessage) => void): () => void {
|
||||
if (!_listeners.has(type)) _listeners.set(type, new Set());
|
||||
_listeners.get(type)!.add(fn);
|
||||
return () => _listeners.get(type)?.delete(fn);
|
||||
}
|
||||
|
||||
let _pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function _ping() {
|
||||
if (_pingTimer) clearInterval(_pingTimer);
|
||||
_pingTimer = setInterval(() => wsSend({ type: 'ping' }), 25_000);
|
||||
}
|
||||
21
f0ck-svelte/src/lib/types/comment.ts
Normal file
21
f0ck-svelte/src/lib/types/comment.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface Comment {
|
||||
id: number;
|
||||
item_id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
content: string; // may be HTML (server-rendered)
|
||||
stamp: string; // human-readable relative time
|
||||
stamp_full: string; // ISO timestamp
|
||||
is_deleted: boolean;
|
||||
reply_to: number | null;
|
||||
poll?: unknown;
|
||||
}
|
||||
|
||||
export interface CommentsResponse {
|
||||
success: boolean;
|
||||
comments: Comment[];
|
||||
total?: number;
|
||||
}
|
||||
137
f0ck-svelte/src/lib/types/item.ts
Normal file
137
f0ck-svelte/src/lib/types/item.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── Grid / list item (returned by /api/v2/items) ────────────────────────────
|
||||
export interface GridItem {
|
||||
id: number;
|
||||
mime: string;
|
||||
dest: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
is_pinned: boolean;
|
||||
is_oc: boolean;
|
||||
xd_score: number;
|
||||
xd_tier: number;
|
||||
xd_label: string;
|
||||
tag: 'SFW' | 'NSFW' | 'NSFL' | null;
|
||||
tag_id: number | null;
|
||||
thumb_size: 1 | 2;
|
||||
has_notification: boolean;
|
||||
}
|
||||
|
||||
// ── Full single item (returned by /api/v2/items/:id) ────────────────────────
|
||||
export interface FullItem {
|
||||
id: number;
|
||||
mime: string;
|
||||
dest: string;
|
||||
username: string;
|
||||
author_id: number | null;
|
||||
author_color: string | null;
|
||||
author_display_name: string | null;
|
||||
author_avatar: string | null;
|
||||
author_avatar_file: string | null;
|
||||
title: string | null;
|
||||
src: { long: string; short: string };
|
||||
thumbnail: string;
|
||||
og_thumbnail: string;
|
||||
og_description: string;
|
||||
coverart: string;
|
||||
size: string;
|
||||
checksum: string | null;
|
||||
timestamp: { timeago: string; timefull: string };
|
||||
favorites: FavoriteUser[];
|
||||
tags: Tag[];
|
||||
halls: Hall[];
|
||||
user_halls: Hall[];
|
||||
is_nsfw: boolean;
|
||||
is_nsfl: boolean;
|
||||
is_sfw: boolean;
|
||||
is_pinned: boolean;
|
||||
is_comments_locked: boolean;
|
||||
is_oc: boolean;
|
||||
is_repost: boolean;
|
||||
reposts: RepostItem[];
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
original_filename: string | null;
|
||||
xd_score: number;
|
||||
xd_tier: number;
|
||||
xd_label: string;
|
||||
primaryHall: Hall | null;
|
||||
otherHalls: Hall[];
|
||||
}
|
||||
|
||||
// ── Pagination ───────────────────────────────────────────────────────────────
|
||||
export interface Pagination {
|
||||
prev: number | null;
|
||||
next: number | null;
|
||||
start: number;
|
||||
end: number;
|
||||
current: number;
|
||||
page: number;
|
||||
cheat: number[];
|
||||
location?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
// ── Link context ─────────────────────────────────────────────────────────────
|
||||
export interface ItemLink {
|
||||
main: string;
|
||||
mainDisplay?: string;
|
||||
path: string;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
// ── Tags ─────────────────────────────────────────────────────────────────────
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tag: string;
|
||||
normalized: string;
|
||||
badge: string;
|
||||
display_name: string | null;
|
||||
user: string;
|
||||
}
|
||||
|
||||
// ── Halls ────────────────────────────────────────────────────────────────────
|
||||
export interface Hall {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||
export interface FavoriteUser {
|
||||
user: string;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
// ── Reposts ──────────────────────────────────────────────────────────────────
|
||||
export interface RepostItem {
|
||||
id: number;
|
||||
username: string;
|
||||
stamp: number;
|
||||
match_type: 'checksum' | 'phash';
|
||||
}
|
||||
|
||||
// ── API response wrappers ─────────────────────────────────────────────────────
|
||||
export interface ItemsResponse {
|
||||
success: boolean;
|
||||
items: GridItem[];
|
||||
pagination: Pagination;
|
||||
total: number;
|
||||
link: ItemLink;
|
||||
}
|
||||
|
||||
export interface ItemResponse {
|
||||
success: boolean;
|
||||
item: FullItem | null;
|
||||
pagination: Pagination;
|
||||
link: ItemLink;
|
||||
isSubscribed: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RandomResponse {
|
||||
success: boolean;
|
||||
id: number;
|
||||
url: string;
|
||||
}
|
||||
27
f0ck-svelte/src/lib/types/session.ts
Normal file
27
f0ck-svelte/src/lib/types/session.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
user: string;
|
||||
login: string;
|
||||
admin: boolean;
|
||||
is_moderator: boolean;
|
||||
mode: number;
|
||||
theme: string;
|
||||
avatar: string | null;
|
||||
avatar_file: string | null;
|
||||
username_color: string | null;
|
||||
display_name: string | null;
|
||||
use_new_layout: boolean;
|
||||
excluded_tags: number[];
|
||||
min_xd_score: number;
|
||||
uploads_remaining: number | undefined;
|
||||
pending_count?: number;
|
||||
language: string | null;
|
||||
disable_autoplay: boolean;
|
||||
disable_swiping: boolean;
|
||||
csrf_token: string;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
loggedIn: boolean;
|
||||
user: SessionUser | null;
|
||||
}
|
||||
23
f0ck-svelte/src/lib/utils/csrfFetch.ts
Normal file
23
f0ck-svelte/src/lib/utils/csrfFetch.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Wrapper around fetch that automatically injects the CSRF token as a header
|
||||
* on all state-changing requests (POST, PUT, PATCH, DELETE).
|
||||
*
|
||||
* The token is read from the session store at call time so it's always fresh.
|
||||
*/
|
||||
import { get } from 'svelte/store';
|
||||
import { session } from '$lib/stores/session';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export function csrfFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
if (SAFE_METHODS.has(method)) return fetch(input, init);
|
||||
|
||||
const token = get(session)?.csrf_token;
|
||||
if (!token) return fetch(input, init); // not logged in — let backend return 401
|
||||
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
headers.set('X-CSRF-Token', token);
|
||||
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
32
f0ck-svelte/src/lib/utils/mimeHelpers.ts
Normal file
32
f0ck-svelte/src/lib/utils/mimeHelpers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Map a MIME type to a simple category string. */
|
||||
export function mimeCategory(mime: string): 'video' | 'audio' | 'image' | 'flash' | 'pdf' | 'archive' | 'youtube' | 'unknown' {
|
||||
if (mime === 'video/youtube') return 'youtube';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime === 'application/pdf') return 'pdf';
|
||||
if (mime.includes('flash') || mime.includes('shockwave')) return 'flash';
|
||||
if (mime.startsWith('application/')) return 'archive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Return a Font Awesome icon class for a MIME category. */
|
||||
export function mimeIcon(mime: string): string {
|
||||
const cat = mimeCategory(mime);
|
||||
const map: Record<string, string> = {
|
||||
youtube: 'fa-brands fa-youtube',
|
||||
video: 'fa-solid fa-film',
|
||||
audio: 'fa-solid fa-music',
|
||||
image: 'fa-solid fa-image',
|
||||
flash: 'fa-solid fa-bolt',
|
||||
pdf: 'fa-solid fa-file-pdf',
|
||||
archive: 'fa-solid fa-file-zipper',
|
||||
unknown: 'fa-solid fa-file'
|
||||
};
|
||||
return map[cat] ?? map.unknown;
|
||||
}
|
||||
|
||||
/** True if the MIME type represents inline-playable media. */
|
||||
export function isPlayable(mime: string): boolean {
|
||||
return ['video', 'audio', 'youtube'].includes(mimeCategory(mime));
|
||||
}
|
||||
45
f0ck-svelte/src/lib/utils/timeago.ts
Normal file
45
f0ck-svelte/src/lib/utils/timeago.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Convert a Unix timestamp (seconds) or ISO string to a human-readable
|
||||
* relative time string, e.g. "3 hours ago".
|
||||
*/
|
||||
export function timeAgo(input: number | string | Date): string {
|
||||
const date =
|
||||
input instanceof Date
|
||||
? input
|
||||
: typeof input === 'number'
|
||||
? new Date(input * 1000)
|
||||
: new Date(input);
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
|
||||
const epochs: [string, number][] = [
|
||||
['year', 365 * 24 * 3600 * 1000],
|
||||
['month', 30 * 24 * 3600 * 1000],
|
||||
['week', 7 * 24 * 3600 * 1000],
|
||||
['day', 24 * 3600 * 1000],
|
||||
['hour', 3600 * 1000],
|
||||
['minute', 60 * 1000],
|
||||
['second', 1000]
|
||||
];
|
||||
|
||||
for (const [label, ms] of epochs) {
|
||||
const n = Math.floor(diff / ms);
|
||||
if (n >= 1) return `${n} ${label}${n !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp to a readable full date string.
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user