feat: implement gallery feed with social features and SSE
- Cursor-based feed endpoint using v_feed view with hashtag filtering - Like toggle (INSERT ON CONFLICT), comments CRUD - Feed delta endpoint for SSE-driven incremental updates - SSE client with Page Visibility API (pause/reconnect) - Responsive photo/video grid with infinite scroll - Hashtag filter chips, lightbox modal with comments - Media file serving via tower-http ServeDir Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
86
frontend/src/lib/sse.ts
Normal file
86
frontend/src/lib/sse.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getToken } from './auth';
|
||||
|
||||
type EventHandler = (data: string) => void;
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let lastEventTime: string | null = null;
|
||||
const handlers: Map<string, EventHandler[]> = new Map();
|
||||
|
||||
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
|
||||
if (!handlers.has(eventType)) {
|
||||
handlers.set(eventType, []);
|
||||
}
|
||||
handlers.get(eventType)!.push(handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const list = handlers.get(eventType);
|
||||
if (list) {
|
||||
const idx = list.indexOf(handler);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function connectSse(): void {
|
||||
const token = getToken();
|
||||
if (!token || eventSource) return;
|
||||
|
||||
// EventSource doesn't support custom headers, so pass token as query param
|
||||
// The backend will need to accept this — or we use a polyfill / fetch-based SSE
|
||||
// For simplicity, use native EventSource with token in URL
|
||||
eventSource = new EventSource(`/api/v1/stream?token=${encodeURIComponent(token)}`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
lastEventTime = new Date().toISOString();
|
||||
};
|
||||
|
||||
eventSource.addEventListener('new-upload', (e) => dispatch('new-upload', e.data));
|
||||
eventSource.addEventListener('upload-processed', (e) => dispatch('upload-processed', e.data));
|
||||
eventSource.addEventListener('like-update', (e) => dispatch('like-update', e.data));
|
||||
eventSource.addEventListener('new-comment', (e) => dispatch('new-comment', e.data));
|
||||
eventSource.addEventListener('export-available', (e) => dispatch('export-available', e.data));
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects, but we track the time for delta-fetch
|
||||
disconnectSse();
|
||||
// Reconnect after a short delay
|
||||
setTimeout(connectSse, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnectSse(): void {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastEventTime(): string | null {
|
||||
return lastEventTime;
|
||||
}
|
||||
|
||||
export function setLastEventTime(time: string): void {
|
||||
lastEventTime = time;
|
||||
}
|
||||
|
||||
function dispatch(eventType: string, data: string): void {
|
||||
lastEventTime = new Date().toISOString();
|
||||
const list = handlers.get(eventType);
|
||||
if (list) {
|
||||
for (const handler of list) {
|
||||
handler(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page Visibility API integration
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
disconnectSse();
|
||||
} else {
|
||||
connectSse();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user