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:
80
frontend/src/lib/components/FeedGrid.svelte
Normal file
80
frontend/src/lib/components/FeedGrid.svelte
Normal 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>
|
||||
42
frontend/src/lib/components/HashtagChips.svelte
Normal file
42
frontend/src/lib/components/HashtagChips.svelte
Normal 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}
|
||||
186
frontend/src/lib/components/LightboxModal.svelte
Normal file
186
frontend/src/lib/components/LightboxModal.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user