svelte lol
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user