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

@@ -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<AppState>,
_auth: AuthUser,
Query(q): Query<SseQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, 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(