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:
258
backend/src/handlers/feed.rs
Normal file
258
backend/src/handlers/feed.rs
Normal file
@@ -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<Uuid>,
|
||||
pub limit: Option<i64>,
|
||||
pub hashtag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeedUpload {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub uploader_name: String,
|
||||
pub preview_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub caption: Option<String>,
|
||||
pub like_count: i64,
|
||||
pub comment_count: i64,
|
||||
pub liked_by_me: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeedResponse {
|
||||
pub uploads: Vec<FeedUpload>,
|
||||
pub next_cursor: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FeedRow {
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
uploader_name: String,
|
||||
preview_path: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
mime_type: String,
|
||||
caption: Option<String>,
|
||||
like_count: i64,
|
||||
comment_count: i64,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<FeedQuery>,
|
||||
) -> Result<Json<FeedResponse>, 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<FeedRow> = 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<Uuid> = 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<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeltaResponse {
|
||||
pub uploads: Vec<FeedUpload>,
|
||||
pub deleted_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn feed_delta(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<DeltaQuery>,
|
||||
) -> Result<Json<DeltaResponse>, 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<Uuid> = 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<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Vec<HashtagCount>>, 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<DateTime<Utc>> {
|
||||
let row: Option<(DateTime<Utc>,)> =
|
||||
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<Uuid> {
|
||||
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()
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod feed;
|
||||
pub mod social;
|
||||
pub mod sse;
|
||||
pub mod upload;
|
||||
|
||||
136
backend/src/handlers/social.rs
Normal file
136
backend/src/handlers/social.rs
Normal file
@@ -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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// 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<AppState>,
|
||||
_auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<CommentDto>>, 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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Json(body): Json<AddCommentRequest>,
|
||||
) -> Result<(StatusCode, Json<CommentDto>), 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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(comment_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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?;
|
||||
|
||||
72
backend/src/models/comment.rs
Normal file
72
backend/src/models/comment.rs
Normal file
@@ -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<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[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<Utc>,
|
||||
}
|
||||
|
||||
impl Comment {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
upload_id: Uuid,
|
||||
user_id: Uuid,
|
||||
body: &str,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
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<Vec<CommentDto>, 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<Option<Self>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod comment;
|
||||
pub mod event;
|
||||
pub mod hashtag;
|
||||
pub mod session;
|
||||
|
||||
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>
|
||||
86
frontend/src/lib/sse.ts
Normal file
86
frontend/src/lib/sse.ts
Normal 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
23
frontend/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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