svelte lol

This commit is contained in:
2026-07-19 19:40:25 +02:00
parent 3344064030
commit 1324512ebf
56 changed files with 5506 additions and 2 deletions

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