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:
@@ -2,36 +2,225 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken, clearAuth } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import FeedGrid from '$lib/components/FeedGrid.svelte';
|
||||
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
||||
import LightboxModal from '$lib/components/LightboxModal.svelte';
|
||||
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
|
||||
|
||||
onMount(() => {
|
||||
let uploads = $state<FeedUpload[]>([]);
|
||||
let hashtags = $state<HashtagCount[]>([]);
|
||||
let selectedHashtag = $state<string | null>(null);
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loadingMore = $state(false);
|
||||
let selectedUpload = $state<FeedUpload | null>(null);
|
||||
let sentinel: HTMLDivElement;
|
||||
|
||||
let unsubscribers: (() => void)[] = [];
|
||||
|
||||
onMount(async () => {
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadFeed(), loadHashtags()]);
|
||||
connectSse();
|
||||
|
||||
unsubscribers.push(
|
||||
onSseEvent('new-upload', (data) => {
|
||||
try {
|
||||
const upload: FeedUpload = JSON.parse(data);
|
||||
uploads = [upload, ...uploads];
|
||||
} catch { /* ignore */ }
|
||||
}),
|
||||
onSseEvent('upload-processed', () => {
|
||||
// Reload feed to get updated preview URLs
|
||||
loadFeed(true);
|
||||
}),
|
||||
onSseEvent('like-update', () => {
|
||||
loadFeed(true);
|
||||
}),
|
||||
onSseEvent('new-comment', () => {
|
||||
loadFeed(true);
|
||||
})
|
||||
);
|
||||
|
||||
// Infinite scroll via IntersectionObserver
|
||||
if (sentinel) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
onDestroy(() => {
|
||||
disconnectSse();
|
||||
for (const unsub of unsubscribers) unsub();
|
||||
});
|
||||
|
||||
async function loadFeed(refresh = false) {
|
||||
try {
|
||||
await api.delete('/session');
|
||||
const params = new URLSearchParams();
|
||||
if (!refresh && nextCursor) params.set('cursor', nextCursor);
|
||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||
params.set('limit', '20');
|
||||
|
||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||
|
||||
if (refresh) {
|
||||
uploads = res.uploads;
|
||||
} else {
|
||||
uploads = res.uploads;
|
||||
}
|
||||
nextCursor = res.next_cursor;
|
||||
} catch {
|
||||
// Ignore errors — clear local state regardless
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('cursor', nextCursor);
|
||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||
params.set('limit', '20');
|
||||
|
||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||
uploads = [...uploads, ...res.uploads];
|
||||
nextCursor = res.next_cursor;
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHashtags() {
|
||||
try {
|
||||
hashtags = await api.get<HashtagCount[]>('/hashtags');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function selectHashtag(tag: string | null) {
|
||||
selectedHashtag = tag;
|
||||
nextCursor = null;
|
||||
loadFeed();
|
||||
}
|
||||
|
||||
async function handleLike(id: string) {
|
||||
try {
|
||||
await api.post(`/upload/${id}/like`);
|
||||
// Toggle locally for instant feedback
|
||||
uploads = uploads.map((u) =>
|
||||
u.id === id
|
||||
? {
|
||||
...u,
|
||||
liked_by_me: !u.liked_by_me,
|
||||
like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1
|
||||
}
|
||||
: u
|
||||
);
|
||||
// Also update lightbox if open
|
||||
if (selectedUpload?.id === id) {
|
||||
selectedUpload = {
|
||||
...selectedUpload,
|
||||
liked_by_me: !selectedUpload.liked_by_me,
|
||||
like_count: selectedUpload.liked_by_me
|
||||
? selectedUpload.like_count - 1
|
||||
: selectedUpload.like_count + 1
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function openComments(id: string) {
|
||||
const u = uploads.find((u) => u.id === id);
|
||||
if (u) selectedUpload = u;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try { await api.delete('/session'); } catch { /* ignore */ }
|
||||
clearAuth();
|
||||
goto('/join');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 p-4">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-gray-900">Galerie</h1>
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="rounded-md bg-gray-200 px-3 py-1 text-sm text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
|
||||
<h1 class="text-lg font-bold text-gray-900">Galerie</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href="/upload"
|
||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
Hochladen
|
||||
</a>
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600">Die Galerie wird bald hier angezeigt.</p>
|
||||
|
||||
<!-- Hashtag filter chips -->
|
||||
<div class="mx-auto max-w-2xl px-4 pb-2">
|
||||
<HashtagChips {hashtags} selected={selectedHashtag} onselect={selectHashtag} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed grid -->
|
||||
<div class="mx-auto max-w-2xl p-4">
|
||||
{#if uploads.length === 0}
|
||||
<div class="py-16 text-center">
|
||||
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Sei der Erste und lade etwas hoch!</p>
|
||||
<a href="/upload" class="mt-4 inline-block rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">
|
||||
Jetzt hochladen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<FeedGrid
|
||||
{uploads}
|
||||
onlike={handleLike}
|
||||
oncomment={openComments}
|
||||
onselect={(u) => (selectedUpload = u)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Infinite scroll sentinel -->
|
||||
<div bind:this={sentinel} class="h-4"></div>
|
||||
|
||||
{#if loadingMore}
|
||||
<div class="py-4 text-center">
|
||||
<div class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
{#if selectedUpload}
|
||||
<LightboxModal
|
||||
upload={selectedUpload}
|
||||
onclose={() => (selectedUpload = null)}
|
||||
onlike={handleLike}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user