From 964598e41d38019765a6ce5f30d3e5f7ffd16d71 Mon Sep 17 00:00:00 2001 From: fabi Date: Wed, 1 Apr 2026 19:17:06 +0200 Subject: [PATCH] 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 --- backend/src/handlers/feed.rs | 258 ++++++++++++++++++ backend/src/handlers/mod.rs | 2 + backend/src/handlers/social.rs | 136 +++++++++ backend/src/handlers/sse.rs | 37 ++- backend/src/main.rs | 16 ++ backend/src/models/comment.rs | 72 +++++ backend/src/models/mod.rs | 1 + frontend/src/lib/components/FeedGrid.svelte | 80 ++++++ .../src/lib/components/HashtagChips.svelte | 42 +++ .../src/lib/components/LightboxModal.svelte | 186 +++++++++++++ frontend/src/lib/sse.ts | 86 ++++++ frontend/src/lib/types.ts | 23 ++ frontend/src/routes/feed/+page.svelte | 221 +++++++++++++-- 13 files changed, 1134 insertions(+), 26 deletions(-) create mode 100644 backend/src/handlers/feed.rs create mode 100644 backend/src/handlers/social.rs create mode 100644 backend/src/models/comment.rs create mode 100644 frontend/src/lib/components/FeedGrid.svelte create mode 100644 frontend/src/lib/components/HashtagChips.svelte create mode 100644 frontend/src/lib/components/LightboxModal.svelte create mode 100644 frontend/src/lib/sse.ts create mode 100644 frontend/src/lib/types.ts diff --git a/backend/src/handlers/feed.rs b/backend/src/handlers/feed.rs new file mode 100644 index 0000000..74323dc --- /dev/null +++ b/backend/src/handlers/feed.rs @@ -0,0 +1,258 @@ +use axum::extract::{Query, State}; +use axum::Json; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::middleware::AuthUser; +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct FeedQuery { + pub cursor: Option, + pub limit: Option, + pub hashtag: Option, +} + +#[derive(Serialize)] +pub struct FeedUpload { + pub id: Uuid, + pub user_id: Uuid, + pub uploader_name: String, + pub preview_url: Option, + pub thumbnail_url: Option, + pub mime_type: String, + pub caption: Option, + pub like_count: i64, + pub comment_count: i64, + pub liked_by_me: bool, + pub created_at: DateTime, +} + +#[derive(Serialize)] +pub struct FeedResponse { + pub uploads: Vec, + pub next_cursor: Option, +} + +#[derive(sqlx::FromRow)] +struct FeedRow { + id: Uuid, + user_id: Uuid, + uploader_name: String, + preview_path: Option, + thumbnail_path: Option, + mime_type: String, + caption: Option, + like_count: i64, + comment_count: i64, + created_at: DateTime, +} + +pub async fn feed( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> Result, AppError> { + let limit = q.limit.unwrap_or(20).min(100); + + let rows = if let Some(hashtag) = &q.hashtag { + let tag = hashtag.trim().trim_start_matches('#').to_lowercase(); + sqlx::query_as::<_, FeedRow>( + "SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path, + v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at + FROM v_feed v + JOIN upload_hashtag uh ON uh.upload_id = v.id + JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1 + WHERE v.event_id = $2 + AND ($3::timestamptz IS NULL OR v.created_at < $3) + ORDER BY v.created_at DESC + LIMIT $4", + ) + .bind(&tag) + .bind(auth.event_id) + .bind( + if let Some(cursor) = q.cursor { + get_cursor_time(&state.pool, cursor).await + } else { + None + }, + ) + .bind(limit + 1) + .fetch_all(&state.pool) + .await? + } else { + sqlx::query_as::<_, FeedRow>( + "SELECT id, user_id, uploader_name, preview_path, thumbnail_path, + mime_type, caption, like_count, comment_count, created_at + FROM v_feed + WHERE event_id = $1 + AND ($2::timestamptz IS NULL OR created_at < $2) + ORDER BY created_at DESC + LIMIT $3", + ) + .bind(auth.event_id) + .bind( + if let Some(cursor) = q.cursor { + get_cursor_time(&state.pool, cursor).await + } else { + None + }, + ) + .bind(limit + 1) + .fetch_all(&state.pool) + .await? + }; + + let has_more = rows.len() as i64 > limit; + let rows: Vec = rows.into_iter().take(limit as usize).collect(); + let next_cursor = if has_more { rows.last().map(|r| r.id) } else { None }; + + // Batch check which uploads the current user has liked + let upload_ids: Vec = rows.iter().map(|r| r.id).collect(); + let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await; + + let uploads = rows + .into_iter() + .map(|r| { + let preview_url = r.preview_path.map(|p| format!("/media/{p}")); + let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}")); + FeedUpload { + liked_by_me: liked_set.contains(&r.id), + id: r.id, + user_id: r.user_id, + uploader_name: r.uploader_name, + preview_url, + thumbnail_url, + mime_type: r.mime_type, + caption: r.caption, + like_count: r.like_count, + comment_count: r.comment_count, + created_at: r.created_at, + } + }) + .collect(); + + Ok(Json(FeedResponse { + uploads, + next_cursor, + })) +} + +#[derive(Deserialize)] +pub struct DeltaQuery { + pub since: DateTime, +} + +#[derive(Serialize)] +pub struct DeltaResponse { + pub uploads: Vec, + pub deleted_ids: Vec, +} + +pub async fn feed_delta( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> Result, AppError> { + let rows = sqlx::query_as::<_, FeedRow>( + "SELECT id, user_id, uploader_name, preview_path, thumbnail_path, + mime_type, caption, like_count, comment_count, created_at + FROM v_feed + WHERE event_id = $1 AND created_at > $2 + ORDER BY created_at DESC", + ) + .bind(auth.event_id) + .bind(q.since) + .fetch_all(&state.pool) + .await?; + + let deleted_ids: Vec<(Uuid,)> = sqlx::query_as( + "SELECT id FROM upload + WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2", + ) + .bind(auth.event_id) + .bind(q.since) + .fetch_all(&state.pool) + .await?; + + let upload_ids: Vec = rows.iter().map(|r| r.id).collect(); + let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await; + + let uploads = rows + .into_iter() + .map(|r| FeedUpload { + liked_by_me: liked_set.contains(&r.id), + id: r.id, + user_id: r.user_id, + uploader_name: r.uploader_name, + preview_url: r.preview_path.map(|p| format!("/media/{p}")), + thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")), + mime_type: r.mime_type, + caption: r.caption, + like_count: r.like_count, + comment_count: r.comment_count, + created_at: r.created_at, + }) + .collect(); + + Ok(Json(DeltaResponse { + uploads, + deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(), + })) +} + +#[derive(Serialize)] +pub struct HashtagCount { + pub tag: String, + pub count: i64, +} + +pub async fn hashtags( + State(state): State, + auth: AuthUser, +) -> Result>, AppError> { + let rows: Vec<(String, i64)> = sqlx::query_as( + "SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1", + ) + .bind(auth.event_id) + .fetch_all(&state.pool) + .await?; + + Ok(Json( + rows.into_iter() + .map(|(tag, count)| HashtagCount { tag, count }) + .collect(), + )) +} + +async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option> { + let row: Option<(DateTime,)> = + sqlx::query_as("SELECT created_at FROM upload WHERE id = $1") + .bind(cursor_id) + .fetch_optional(pool) + .await + .ok()?; + row.map(|r| r.0) +} + +async fn get_liked_set( + pool: &sqlx::PgPool, + user_id: Uuid, + upload_ids: &[Uuid], +) -> std::collections::HashSet { + if upload_ids.is_empty() { + return std::collections::HashSet::new(); + } + let rows: Vec<(Uuid,)> = sqlx::query_as( + "SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)", + ) + .bind(user_id) + .bind(upload_ids) + .fetch_all(pool) + .await + .unwrap_or_default(); + + rows.into_iter().map(|r| r.0).collect() +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 3a4f17c..82971d0 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,2 +1,4 @@ +pub mod feed; +pub mod social; pub mod sse; pub mod upload; diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs new file mode 100644 index 0000000..520af4b --- /dev/null +++ b/backend/src/handlers/social.rs @@ -0,0 +1,136 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::middleware::AuthUser; +use crate::error::AppError; +use crate::models::comment::{Comment, CommentDto}; +use crate::models::hashtag::{self, Hashtag}; +use crate::state::AppState; + +pub async fn toggle_like( + State(state): State, + auth: AuthUser, + Path(upload_id): Path, +) -> Result { + // Check if user is banned + let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id) + .await? + .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; + if user.is_banned { + return Err(AppError::Forbidden("Du bist gesperrt.".into())); + } + + // Try to insert; if conflict, delete (toggle) + let result = sqlx::query( + "INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2) + ON CONFLICT (upload_id, user_id) DO NOTHING", + ) + .bind(upload_id) + .bind(auth.user_id) + .execute(&state.pool) + .await?; + + if result.rows_affected() == 0 { + // Already liked — remove + sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2") + .bind(upload_id) + .bind(auth.user_id) + .execute(&state.pool) + .await?; + } + + // Broadcast SSE + let _ = state.sse_tx.send(crate::state::SseEvent { + event_type: "like-update".to_string(), + data: serde_json::json!({ "upload_id": upload_id }).to_string(), + }); + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_comments( + State(state): State, + _auth: AuthUser, + Path(upload_id): Path, +) -> Result>, AppError> { + let comments = Comment::list_for_upload(&state.pool, upload_id).await?; + Ok(Json(comments)) +} + +#[derive(Deserialize)] +pub struct AddCommentRequest { + pub body: String, +} + +pub async fn add_comment( + State(state): State, + auth: AuthUser, + Path(upload_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id) + .await? + .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; + if user.is_banned { + return Err(AppError::Forbidden("Du bist gesperrt.".into())); + } + + let text = body.body.trim(); + if text.is_empty() || text.len() > 500 { + return Err(AppError::BadRequest( + "Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(), + )); + } + + let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?; + + // Process hashtags in comment body + let tags = hashtag::extract_hashtags(text); + for tag in &tags { + let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?; + sqlx::query( + "INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(comment.id) + .bind(h.id) + .execute(&state.pool) + .await?; + } + + // Broadcast SSE + let _ = state.sse_tx.send(crate::state::SseEvent { + event_type: "new-comment".to_string(), + data: serde_json::json!({ "upload_id": upload_id }).to_string(), + }); + + let dto = CommentDto { + id: comment.id, + upload_id, + user_id: auth.user_id, + uploader_name: user.display_name, + body: comment.body, + created_at: comment.created_at, + }; + + Ok((StatusCode::CREATED, Json(dto))) +} + +pub async fn delete_comment( + State(state): State, + auth: AuthUser, + Path(comment_id): Path, +) -> Result { + let comment = Comment::find_by_id(&state.pool, comment_id) + .await? + .ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?; + + if comment.user_id != auth.user_id { + return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into())); + } + + Comment::soft_delete(&state.pool, comment_id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/handlers/sse.rs b/backend/src/handlers/sse.rs index 783e5cb..f5626ca 100644 --- a/backend/src/handlers/sse.rs +++ b/backend/src/handlers/sse.rs @@ -1,28 +1,45 @@ use std::convert::Infallible; use std::time::Duration; -use axum::extract::State; +use axum::extract::{Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; use futures::stream::Stream; +use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; -use crate::auth::middleware::AuthUser; +use crate::auth::jwt; use crate::error::AppError; +use crate::models::session::Session; use crate::state::AppState; +#[derive(Deserialize)] +pub struct SseQuery { + pub token: String, +} + +/// SSE stream endpoint. Accepts JWT via query param since EventSource +/// doesn't support custom headers. pub async fn stream( State(state): State, - _auth: AuthUser, + Query(q): Query, ) -> Result>>, AppError> { + // Verify token + let _claims = jwt::verify_token(&q.token, &state.config.jwt_secret) + .map_err(|_| AppError::Unauthorized("Token ungültig.".into()))?; + + let token_hash = jwt::hash_token(&q.token); + Session::find_by_token_hash(&state.pool, &token_hash) + .await + .map_err(|e| AppError::Internal(e.into()))? + .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?; + let rx = state.sse_tx.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|msg| { - match msg { - Ok(sse_event) => Some(Ok(Event::default() - .event(sse_event.event_type) - .data(sse_event.data))), - Err(_) => None, // Lagged — skip missed events - } + let stream = BroadcastStream::new(rx).filter_map(|msg| match msg { + Ok(sse_event) => Some(Ok(Event::default() + .event(sse_event.event_type) + .data(sse_event.data))), + Err(_) => None, }); Ok(Sse::new(stream).keep_alive( diff --git a/backend/src/main.rs b/backend/src/main.rs index e2ffce2..39c27c1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Result; use axum::routing::{delete, get, patch, post}; use axum::Router; +use tower_http::services::ServeDir; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod auth; @@ -45,12 +46,27 @@ async fn main() -> Result<()> { "/api/v1/upload/{id}", patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload), ) + // Feed + .route("/api/v1/feed", get(handlers::feed::feed)) + .route("/api/v1/feed/delta", get(handlers::feed::feed_delta)) + .route("/api/v1/hashtags", get(handlers::feed::hashtags)) + // Social + .route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like)) + .route( + "/api/v1/upload/{id}/comments", + get(handlers::social::list_comments).post(handlers::social::add_comment), + ) + .route("/api/v1/comment/{id}", delete(handlers::social::delete_comment)) // SSE .route("/api/v1/stream", get(handlers::sse::stream)); + // Serve media files from disk + let media_service = ServeDir::new(&config.media_path); + let router = Router::new() .route("/health", get(|| async { "ok" })) .merge(api) + .nest_service("/media", media_service) .with_state(state); let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?; diff --git a/backend/src/models/comment.rs b/backend/src/models/comment.rs new file mode 100644 index 0000000..1ef06ec --- /dev/null +++ b/backend/src/models/comment.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow)] +pub struct Comment { + pub id: Uuid, + pub upload_id: Uuid, + pub user_id: Uuid, + pub body: String, + pub created_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct CommentDto { + pub id: Uuid, + pub upload_id: Uuid, + pub user_id: Uuid, + pub uploader_name: String, + pub body: String, + pub created_at: DateTime, +} + +impl Comment { + pub async fn create( + pool: &PgPool, + upload_id: Uuid, + user_id: Uuid, + body: &str, + ) -> Result { + sqlx::query_as::<_, Self>( + "INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *", + ) + .bind(upload_id) + .bind(user_id) + .bind(body) + .fetch_one(pool) + .await + } + + pub async fn list_for_upload(pool: &PgPool, upload_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, CommentDto>( + "SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name, c.body, c.created_at + FROM comment c + JOIN \"user\" u ON u.id = c.user_id + WHERE c.upload_id = $1 AND c.deleted_at IS NULL + ORDER BY c.created_at ASC", + ) + .bind(upload_id) + .fetch_all(pool) + .await + } + + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>( + "SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE comment SET deleted_at = NOW() WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index f51d106..978a4e4 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod comment; pub mod event; pub mod hashtag; pub mod session; diff --git a/frontend/src/lib/components/FeedGrid.svelte b/frontend/src/lib/components/FeedGrid.svelte new file mode 100644 index 0000000..44a71df --- /dev/null +++ b/frontend/src/lib/components/FeedGrid.svelte @@ -0,0 +1,80 @@ + + +
+ {#each uploads as upload (upload.id)} +
+ + + +
+

{upload.uploader_name}

+
+ + +
+
+
+ {/each} +
diff --git a/frontend/src/lib/components/HashtagChips.svelte b/frontend/src/lib/components/HashtagChips.svelte new file mode 100644 index 0000000..971016f --- /dev/null +++ b/frontend/src/lib/components/HashtagChips.svelte @@ -0,0 +1,42 @@ + + +{#if hashtags.length > 0} +
+ + {#each hashtags as h (h.tag)} + + {/each} +
+{/if} diff --git a/frontend/src/lib/components/LightboxModal.svelte b/frontend/src/lib/components/LightboxModal.svelte new file mode 100644 index 0000000..363e1ff --- /dev/null +++ b/frontend/src/lib/components/LightboxModal.svelte @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts new file mode 100644 index 0000000..73de9f6 --- /dev/null +++ b/frontend/src/lib/sse.ts @@ -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 = 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(); + } + }); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..6284560 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -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; +} diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index 47e551e..9d4a156 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -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([]); + let hashtags = $state([]); + let selectedHashtag = $state(null); + let nextCursor = $state(null); + let loadingMore = $state(false); + let selectedUpload = $state(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(`/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(`/feed?${params}`); + uploads = [...uploads, ...res.uploads]; + nextCursor = res.next_cursor; + } catch { + // Ignore + } finally { + loadingMore = false; + } + } + + async function loadHashtags() { + try { + hashtags = await api.get('/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'); } -
-
-
-

Galerie

- +
+ +
+
+

Galerie

+
+ + Hochladen + + +
-

Die Galerie wird bald hier angezeigt.

+ + +
+ +
+
+ + +
+ {#if uploads.length === 0} +
+

Noch keine Fotos.

+

Sei der Erste und lade etwas hoch!

+ + Jetzt hochladen + +
+ {:else} + (selectedUpload = u)} + /> + {/if} + + +
+ + {#if loadingMore} +
+
+
+ {/if}
+ + +{#if selectedUpload} + (selectedUpload = null)} + onlike={handleLike} + /> +{/if}