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:
fabi
2026-04-01 19:17:06 +02:00
parent 4e1f1d6426
commit 964598e41d
13 changed files with 1134 additions and 26 deletions

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import type { FeedUpload } from '$lib/types';
interface Props {
uploads: FeedUpload[];
onlike: (id: string) => void;
oncomment: (id: string) => void;
onselect: (upload: FeedUpload) => void;
}
let { uploads, onlike, oncomment, onselect }: Props = $props();
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
function imageUrl(upload: FeedUpload): string {
if (upload.thumbnail_url) return upload.thumbnail_url;
if (upload.preview_url) return upload.preview_url;
return '';
}
</script>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each uploads as upload (upload.id)}
<div class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100">
<button
onclick={() => onselect(upload)}
class="block h-full w-full"
aria-label="Upload anzeigen"
>
{#if isVideo(upload.mime_type)}
<div class="flex h-full items-center justify-center bg-gray-800">
{#if imageUrl(upload)}
<img src={imageUrl(upload)} alt="" class="h-full w-full object-cover" />
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{:else if imageUrl(upload)}
<img src={imageUrl(upload)} alt="" class="h-full w-full object-cover" loading="lazy" />
{:else}
<div class="flex h-full items-center justify-center text-gray-400">
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
</button>
<!-- Overlay with name and stats -->
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
<button
class="pointer-events-auto flex items-center gap-0.5"
onclick={(e) => { e.stopPropagation(); onlike(upload.id); }}
>
<svg class="h-3.5 w-3.5 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{upload.like_count}
</button>
<button
class="pointer-events-auto flex items-center gap-0.5"
onclick={(e) => { e.stopPropagation(); oncomment(upload.id); }}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{upload.comment_count}
</button>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
interface HashtagCount {
tag: string;
count: number;
}
interface Props {
hashtags: HashtagCount[];
selected: string | null;
onselect: (tag: string | null) => void;
}
let { hashtags, selected, onselect }: Props = $props();
</script>
{#if hashtags.length > 0}
<div class="flex gap-2 overflow-x-auto pb-2">
<button
onclick={() => onselect(null)}
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selected === null
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
>
Alle
</button>
{#each hashtags as h (h.tag)}
<button
onclick={() => onselect(h.tag)}
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selected === h.tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
>
#{h.tag}
<span class="ml-1 text-xs opacity-70">{h.count}</span>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import type { FeedUpload } from '$lib/types';
import { api, ApiError } from '$lib/api';
import { getUserId } from '$lib/auth';
interface CommentDto {
id: string;
upload_id: string;
user_id: string;
uploader_name: string;
body: string;
created_at: string;
}
interface Props {
upload: FeedUpload;
onclose: () => void;
onlike: (id: string) => void;
}
let { upload, onclose, onlike }: Props = $props();
let comments = $state<CommentDto[]>([]);
let newComment = $state('');
let loading = $state(false);
let userId = getUserId();
$effect(() => {
loadComments();
});
async function loadComments() {
try {
comments = await api.get<CommentDto[]>(`/upload/${upload.id}/comments`);
} catch {
// Ignore
}
}
async function submitComment() {
if (!newComment.trim()) return;
loading = true;
try {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comment`, {
body: newComment.trim()
});
comments = [...comments, comment];
newComment = '';
} catch {
// Ignore
} finally {
loading = false;
}
}
async function deleteComment(id: string) {
try {
await api.delete(`/comment/${id}`);
comments = comments.filter((c) => c.id !== id);
} catch {
// Ignore
}
}
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog">
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white">
<!-- Media -->
<div class="relative bg-black">
<button onclick={onclose} class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{#if isVideo(upload.mime_type)}
<video
src={upload.preview_url ?? ''}
controls
class="max-h-[50vh] w-full object-contain"
poster={upload.thumbnail_url ?? undefined}
></video>
{:else}
<img
src={upload.preview_url ?? ''}
alt=""
class="max-h-[50vh] w-full object-contain"
/>
{/if}
</div>
<!-- Info + Comments -->
<div class="flex flex-1 flex-col overflow-hidden">
<div class="border-b border-gray-100 p-3">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-gray-900">{upload.uploader_name}</span>
<span class="ml-2 text-xs text-gray-400">{formatTime(upload.created_at)}</span>
</div>
<button
onclick={() => onlike(upload.id)}
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
upload.liked_by_me
? 'bg-red-50 text-red-600'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}"
>
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{upload.like_count}
</button>
</div>
{#if upload.caption}
<p class="mt-1 text-sm text-gray-700">{upload.caption}</p>
{/if}
</div>
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-3">
{#if comments.length === 0}
<p class="text-center text-sm text-gray-400">Noch keine Kommentare.</p>
{:else}
<div class="space-y-3">
{#each comments as comment (comment.id)}
<div class="flex items-start gap-2">
<div class="flex-1">
<span class="text-sm font-medium text-gray-900">{comment.uploader_name}</span>
<span class="ml-1 text-sm text-gray-700">{comment.body}</span>
<div class="mt-0.5 text-xs text-gray-400">{formatTime(comment.created_at)}</div>
</div>
{#if comment.user_id === userId}
<button
onclick={() => deleteComment(comment.id)}
class="shrink-0 text-gray-400 hover:text-red-500"
aria-label="Löschen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Comment input -->
<form
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
class="flex gap-2 border-t border-gray-100 p-3"
>
<input
type="text"
bind:value={newComment}
placeholder="Kommentar schreiben..."
maxlength={500}
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={loading || !newComment.trim()}
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
Senden
</button>
</form>
</div>
</div>
</div>

86
frontend/src/lib/sse.ts Normal file
View 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();
}
});
}

23
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface FeedUpload {
id: string;
user_id: string;
uploader_name: string;
preview_url: string | null;
thumbnail_url: string | null;
mime_type: string;
caption: string | null;
like_count: number;
comment_count: number;
liked_by_me: boolean;
created_at: string;
}
export interface FeedResponse {
uploads: FeedUpload[];
next_cursor: string | null;
}
export interface HashtagCount {
tag: string;
count: number;
}

View File

@@ -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}