From 2f2935a1cba452282df50fec683bcfa96a00811c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 16 Jun 2026 19:59:52 +0200 Subject: [PATCH] community tab redesign --- backend-rs/src/db.rs | 21 ++ backend-rs/src/dto.rs | 27 ++ backend-rs/src/entities/mod.rs | 2 + backend-rs/src/entities/rating_reaction.rs | 45 +++ backend-rs/src/routes/activity.rs | 44 +++ backend-rs/src/routes/admin.rs | 2 +- backend-rs/src/routes/mod.rs | 2 + backend-rs/src/routes/ratings.rs | 195 ++++++++++- backend-rs/src/routes/users.rs | 42 ++- backend-rs/src/service.rs | 199 ++++++++++- backend-rs/src/throttle.rs | 3 + community-redesign.md | 86 ++++- frontend/src/api/keys.ts | 2 + frontend/src/api/queries/useCommunityFeed.ts | 92 +++++ frontend/src/components/ReactionBar.tsx | 139 ++++++++ .../components/layout/DesktopCategoryBar.tsx | 14 +- frontend/src/i18n.ts | 48 +++ frontend/src/pages/CommunityFeed.tsx | 329 ++++++++++++++++-- 18 files changed, 1222 insertions(+), 70 deletions(-) create mode 100644 backend-rs/src/entities/rating_reaction.rs create mode 100644 backend-rs/src/routes/activity.rs create mode 100644 frontend/src/components/ReactionBar.tsx diff --git a/backend-rs/src/db.rs b/backend-rs/src/db.rs index 99c3cfb..65fc077 100644 --- a/backend-rs/src/db.rs +++ b/backend-rs/src/db.rs @@ -73,6 +73,27 @@ async fn ensure_schema(conn: &DatabaseConnection) -> anyhow::Result<()> { "ALTER TABLE api_user ADD COLUMN deletion_code_expires DATETIME", ) .await?; + // Rust-owned table (no Django model): emoji reactions on ratings. UNIQUE makes + // a duplicate react a no-op; the index keeps the per-feed count query cheap. + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS rating_reaction (\ + id INTEGER PRIMARY KEY AUTOINCREMENT, \ + user_id INTEGER NOT NULL, \ + rating_id INTEGER NOT NULL, \ + kind VARCHAR(32) NOT NULL, \ + created_at DATETIME NOT NULL, \ + UNIQUE(user_id, rating_id, kind))" + .to_owned(), + )) + .await?; + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "CREATE INDEX IF NOT EXISTS idx_rating_reaction_rating \ + ON rating_reaction(rating_id)" + .to_owned(), + )) + .await?; Ok(()) } diff --git a/backend-rs/src/dto.rs b/backend-rs/src/dto.rs index c61d170..1df29e9 100644 --- a/backend-rs/src/dto.rs +++ b/backend-rs/src/dto.rs @@ -40,6 +40,33 @@ pub struct RatingOut { pub comment: Option, pub created_at: String, pub replies: Vec, + /// Reaction counts keyed by kind (e.g. `{"fire": 3, "yum": 1}`). Empty when none. + pub reactions: BTreeMap, + /// Kinds the requesting user has reacted with (empty for anonymous callers). + pub my_reactions: Vec, +} + +/// One item in the community activity feed (`GET /api/activity/`). A heterogeneous +/// event — `kind` discriminates `new_flavor` (a fresh catalog drop) from +/// `milestone` (a flavor crossing a rating-count threshold). New endpoint with no +/// Django counterpart, so it isn't bound by DRF field parity. +#[derive(Serialize, ToSchema)] +pub struct ActivityOut { + pub kind: String, + /// Stable composite id (e.g. `flavor-12`, `milestone-12-100`) for React keys. + pub id: String, + pub created_at: String, + pub flavor_id: i32, + pub flavor_name: String, + pub flavor_image: Option, + pub category_name: String, + pub category_slug: String, + /// Threshold reached, for `milestone` items only. + #[serde(skip_serializing_if = "Option::is_none")] + pub milestone: Option, + /// Current rating count, for `milestone` items only. + #[serde(skip_serializing_if = "Option::is_none")] + pub rating_count: Option, } #[derive(Serialize, ToSchema)] diff --git a/backend-rs/src/entities/mod.rs b/backend-rs/src/entities/mod.rs index f777aca..13ceebb 100644 --- a/backend-rs/src/entities/mod.rs +++ b/backend-rs/src/entities/mod.rs @@ -12,6 +12,7 @@ pub mod job; pub mod notification; pub mod profile_comment; pub mod rating; +pub mod rating_reaction; pub mod reply; pub mod system_config; pub mod ticket; @@ -27,6 +28,7 @@ pub mod prelude { pub use super::notification::Entity as Notification; pub use super::profile_comment::Entity as ProfileComment; pub use super::rating::Entity as Rating; + pub use super::rating_reaction::Entity as RatingReaction; pub use super::reply::Entity as Reply; pub use super::system_config::Entity as SystemConfig; pub use super::ticket::Entity as Ticket; diff --git a/backend-rs/src/entities/rating_reaction.rs b/backend-rs/src/entities/rating_reaction.rs new file mode 100644 index 0000000..e9f4edd --- /dev/null +++ b/backend-rs/src/entities/rating_reaction.rs @@ -0,0 +1,45 @@ +//! Lightweight emoji reactions on ratings. No Django equivalent — this table is +//! owned by the Rust backend (created in `db::ensure_schema`). One row per +//! (user, rating, kind); the UNIQUE constraint makes a re-react a no-op. +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "rating_reaction")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub rating_id: i32, + pub kind: String, + pub created_at: ChronoDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::rating::Entity", + from = "Column::RatingId", + to = "super::rating::Column::Id" + )] + Rating, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Rating.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend-rs/src/routes/activity.rs b/backend-rs/src/routes/activity.rs new file mode 100644 index 0000000..db4b50a --- /dev/null +++ b/backend-rs/src/routes/activity.rs @@ -0,0 +1,44 @@ +//! Community activity feed (`GET /api/activity/`). A unified, time-sorted stream +//! of new-flavor drops + rating milestones, so the Community page stays alive even +//! when a user's follow graph is quiet. AllowAny; no Django counterpart. + +use axum::extract::{Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; + +use crate::dto::ActivityOut; +use crate::error::ApiResult; +use crate::pagination::{parse_page, Paginated}; +use crate::service::build_activity; +use crate::state::AppState; +use crate::web::RequestCtx; + +const ACTIVITY_PAGE_SIZE: u64 = 10; + +#[derive(Deserialize)] +struct PageParam { + page: Option, +} + +pub fn router() -> Router { + Router::new().route("/activity/", get(activity)) +} + +/// GET /api/activity/ — paginated activity stream, newest first. +async fn activity( + State(state): State, + ctx: RequestCtx, + Query(params): Query, +) -> ApiResult>> { + let page = parse_page(params.page.as_deref()); + let size = ACTIVITY_PAGE_SIZE; + + // The candidate set is bounded (recent drops + milestone flavors), so build + // the full sorted list once and slice the page in memory. + let all = build_activity(&state, &ctx).await?; + let count = all.len() as u64; + let start = ((page - 1) * size) as usize; + let items: Vec = all.into_iter().skip(start).take(size as usize).collect(); + Ok(Json(Paginated::build(&ctx, items, count, page, size))) +} diff --git a/backend-rs/src/routes/admin.rs b/backend-rs/src/routes/admin.rs index f77c91f..6885bf7 100644 --- a/backend-rs/src/routes/admin.rs +++ b/backend-rs/src/routes/admin.rs @@ -421,7 +421,7 @@ async fn build_user_detail( .order_by_desc(rating::Column::CreatedAt) .all(&state.db) .await?; - let ratings = build_ratings(state, ctx, ratings).await?; + let ratings = build_ratings(state, ctx, None, ratings).await?; let mut v = admin_user_json(u, &ips); v["ratings"] = serde_json::to_value(ratings).unwrap_or(Value::Null); Ok(v) diff --git a/backend-rs/src/routes/mod.rs b/backend-rs/src/routes/mod.rs index c034969..a3f90e3 100644 --- a/backend-rs/src/routes/mod.rs +++ b/backend-rs/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod activity; pub mod admin; pub mod auth; pub mod banners; @@ -18,6 +19,7 @@ use crate::state::AppState; /// slash, so routes are declared with it explicitly. pub fn api_router() -> Router { Router::new() + .merge(activity::router()) .merge(auth::router()) .merge(categories::router()) .merge(flavors::router()) diff --git a/backend-rs/src/routes/ratings.rs b/backend-rs/src/routes/ratings.rs index a82b99c..700062b 100644 --- a/backend-rs/src/routes/ratings.rs +++ b/backend-rs/src/routes/ratings.rs @@ -14,10 +14,10 @@ use sea_orm::{ use serde::Deserialize; use serde_json::{json, Value}; -use crate::auth::AuthUser; +use crate::auth::{AuthUser, OptionalUser}; use crate::dto::{RatingOut, ReplyOut}; use crate::entities::prelude::*; -use crate::entities::{notification, rating, reply, user}; +use crate::entities::{notification, rating, rating_reaction, reply, user}; use crate::error::{ApiError, ApiResult}; use crate::mentions::parse_mentions; use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; @@ -32,11 +32,22 @@ pub fn router() -> Router { .route("/ratings/", get(list).post(create)) .route("/ratings/feed/", get(feed)) .route("/ratings/recent/", get(recent)) + .route("/ratings/discover/", get(discover)) .route( "/ratings/{id}/", get(retrieve).put(update).patch(update).delete(destroy), ) .route("/ratings/{id}/reply/", post(reply)) + .route("/ratings/{id}/react/", post(react).delete(unreact)) +} + +/// Reaction kinds the API accepts. Keeping this an allow-list (rather than a free +/// string) stops the table filling with junk and keeps the frontend emoji map total. +const REACTION_KINDS: &[&str] = &["like", "love", "fire", "yum", "mind", "meh"]; + +#[derive(Deserialize)] +struct ReactBody { + kind: Option, } #[derive(Deserialize)] @@ -44,6 +55,15 @@ struct PageParam { page: Option, } +#[derive(Deserialize)] +struct DiscoverParams { + page: Option, + /// `recent` (default) or `top` (highest score first). + sort: Option, + /// Filter to a single category by its slug. + category: Option, +} + /// Load the acting user row (for superuser checks). 401 if the id is stale. async fn load_actor(state: &AppState, uid: i32) -> ApiResult { User::find_by_id(uid) @@ -61,6 +81,7 @@ fn as_i32(v: &Value) -> Option { async fn list( State(state): State, ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, Query(params): Query, ) -> ApiResult>> { let page = parse_page(params.page.as_deref()); @@ -72,7 +93,7 @@ async fn list( .offset((page - 1) * size) .all(&state.db) .await?; - let results = build_ratings(&state, &ctx, models).await?; + let results = build_ratings(&state, &ctx, viewer, models).await?; Ok(Json(Paginated::build(&ctx, results, count, page, size))) } @@ -80,13 +101,14 @@ async fn list( async fn retrieve( State(state): State, ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, Path(id): Path, ) -> ApiResult> { let model = Rating::find_by_id(id) .one(&state.db) .await? .ok_or(ApiError::NotFound)?; - let mut dtos = build_ratings(&state, &ctx, vec![model]).await?; + let mut dtos = build_ratings(&state, &ctx, viewer, vec![model]).await?; dtos.pop().map(Json).ok_or(ApiError::NotFound) } @@ -227,7 +249,7 @@ async fn create( } txn.commit().await?; - let mut dtos = build_ratings(&state, &ctx, vec![model]).await?; + let mut dtos = build_ratings(&state, &ctx, Some(uid), vec![model]).await?; let out = dtos.pop().ok_or(ApiError::Internal)?; Ok((StatusCode::CREATED, Json(out))) } @@ -325,7 +347,7 @@ async fn update( } let model = active.update(&state.db).await?; - let mut dtos = build_ratings(&state, &ctx, vec![model]).await?; + let mut dtos = build_ratings(&state, &ctx, Some(uid), vec![model]).await?; dtos.pop().map(Json).ok_or(ApiError::Internal) } @@ -410,12 +432,16 @@ async fn feed( .offset((page - 1) * size) .all(&state.db) .await?; - let results = build_ratings(&state, &ctx, models).await?; + let results = build_ratings(&state, &ctx, Some(uid), models).await?; Ok(Json(Paginated::build(&ctx, results, count, page, size))) } /// GET /api/ratings/recent/ — 10 newest with a non-empty comment (AllowAny). -async fn recent(State(state): State, ctx: RequestCtx) -> ApiResult>> { +async fn recent( + State(state): State, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, +) -> ApiResult>> { let models = Rating::find() .filter(rating::Column::Comment.is_not_null()) .filter(rating::Column::Comment.ne("")) @@ -423,7 +449,68 @@ async fn recent(State(state): State, ctx: RequestCtx) -> ApiResult`, `?page=`. Only ratings with a non-empty +/// comment, matching the prior Discover tab's quality bar. +async fn discover( + State(state): State, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, + Query(params): Query, +) -> ApiResult>> { + let page = parse_page(params.page.as_deref()); + let size = FEED_PAGE_SIZE; + let top = params.sort.as_deref() == Some("top"); + + let mut cond = Condition::all() + .add(rating::Column::Comment.is_not_null()) + .add(rating::Column::Comment.ne("")); + + // Category filter: resolve slug → category id → its flavor ids. An unknown + // slug or an empty category yields an empty feed rather than an error. + if let Some(slug) = params.category.as_deref().filter(|s| !s.is_empty()) { + let cat = Category::find() + .filter(crate::entities::category::Column::Slug.eq(slug)) + .one(&state.db) + .await?; + let Some(cat) = cat else { + return Ok(Json(Paginated::build(&ctx, Vec::new(), 0, page, size))); + }; + let flavor_ids: Vec = Flavor::find() + .filter(crate::entities::flavor::Column::CategoryId.eq(cat.id)) + .all(&state.db) + .await? + .into_iter() + .map(|f| f.id) + .collect(); + if flavor_ids.is_empty() { + return Ok(Json(Paginated::build(&ctx, Vec::new(), 0, page, size))); + } + cond = cond.add(rating::Column::FlavorId.is_in(flavor_ids)); + } + + let count = Rating::find() + .filter(cond.clone()) + .count(&state.db) + .await?; + let mut query = Rating::find().filter(cond); + query = if top { + query + .order_by_desc(rating::Column::Score) + .order_by_desc(rating::Column::CreatedAt) + } else { + query.order_by_desc(rating::Column::CreatedAt) + }; + let models = query + .limit(size) + .offset((page - 1) * size) + .all(&state.db) + .await?; + let results = build_ratings(&state, &ctx, viewer, models).await?; + Ok(Json(Paginated::build(&ctx, results, count, page, size))) } /// POST /api/ratings/{id}/reply/ — add a reply, notify the rating owner, and @@ -483,3 +570,93 @@ async fn reply( let out = dtos.pop().ok_or(ApiError::Internal)?; Ok((StatusCode::CREATED, Json(out))) } + +/// Validate `{kind}` against the allow-list, returning a DRF-style field error. +fn parse_kind(body: &Bytes) -> ApiResult { + let data: ReactBody = serde_json::from_slice(body).unwrap_or(ReactBody { kind: None }); + let kind = data.kind.unwrap_or_default(); + if REACTION_KINDS.contains(&kind.as_str()) { + Ok(kind) + } else { + Err(ApiError::Validation( + json!({ "kind": ["Unsupported reaction."] }), + )) + } +} + +/// Load the rating and re-render it for `uid` (so the response carries fresh +/// `reactions` + `my_reactions` for an optimistic UI to reconcile against). +async fn reaction_response( + state: &AppState, + ctx: &RequestCtx, + uid: i32, + rating: rating::Model, +) -> ApiResult> { + let mut dtos = build_ratings(state, ctx, Some(uid), vec![rating]).await?; + dtos.pop().map(Json).ok_or(ApiError::Internal) +} + +/// POST /api/ratings/{id}/react/ — add `{kind}` for the caller. Idempotent: a +/// repeat react is absorbed by the UNIQUE(user, rating, kind) index. +async fn react( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + state + .security + .check_rate(&format!("react:user:{uid}"), crate::throttle::REACTION)?; + let kind = parse_kind(&body)?; + let rating = Rating::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + + match (rating_reaction::ActiveModel { + id: NotSet, + user_id: Set(uid), + rating_id: Set(id), + kind: Set(kind), + created_at: Set(crate::datetime::now_micros()), + } + .insert(&state.db) + .await) + { + Ok(_) => {} + // Already reacted with this kind — treat as success (idempotent toggle-on). + Err(e) if crate::error::is_unique_violation(&e) => {} + Err(e) => return Err(e.into()), + } + + reaction_response(&state, &ctx, uid, rating).await +} + +/// DELETE /api/ratings/{id}/react/ — remove `{kind}` for the caller. A no-op when +/// the reaction isn't present. +async fn unreact( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + state + .security + .check_rate(&format!("react:user:{uid}"), crate::throttle::REACTION)?; + let kind = parse_kind(&body)?; + let rating = Rating::find_by_id(id) + .one(&state.db) + .await? + .ok_or(ApiError::NotFound)?; + + RatingReaction::delete_many() + .filter(rating_reaction::Column::UserId.eq(uid)) + .filter(rating_reaction::Column::RatingId.eq(id)) + .filter(rating_reaction::Column::Kind.eq(kind)) + .exec(&state.db) + .await?; + + reaction_response(&state, &ctx, uid, rating).await +} diff --git a/backend-rs/src/routes/users.rs b/backend-rs/src/routes/users.rs index 04d6ec6..179d9a5 100644 --- a/backend-rs/src/routes/users.rs +++ b/backend-rs/src/routes/users.rs @@ -69,6 +69,7 @@ pub fn router() -> Router { Router::new() .route("/users/", get(list)) .route("/users/following_list/", get(following_list)) + .route("/users/suggested/", get(suggested)) .route("/users/signup/", post(signup)) .route("/users/resend_verification/", post(resend_verification)) .route("/users/verify_signup/", post(verify_signup)) @@ -321,6 +322,41 @@ async fn following_list( Ok(Json(build_users(&state, &ctx, users, Some(uid)).await?)) } +/// GET /api/users/suggested/ — up to 5 most-active raters the caller doesn't +/// already follow (and not themselves). Powers the "who to follow" sidebar card. +async fn suggested( + State(state): State, + ctx: RequestCtx, + AuthUser(uid): AuthUser, +) -> ApiResult>> { + let mut exclude = following_ids(&state, uid).await?; + exclude.push(uid); + + // Rating counts per user, most-active first. The dataset is small, so rank + // in SQL and filter the caller's excludes in Rust rather than build a NOT IN. + let rows = state + .db + .query_all(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT user_id AS uid, COUNT(*) AS c FROM api_rating \ + GROUP BY user_id ORDER BY c DESC, uid ASC", + [], + )) + .await?; + let ranked: Vec = rows + .into_iter() + .filter_map(|r| r.try_get::("", "uid").ok()) + .filter(|id| !exclude.contains(id)) + .take(5) + .collect(); + + // users_by_ids orders by id; restore the activity ranking afterwards. + let users = users_by_ids(&state, &ranked).await?; + let mut out = build_users(&state, &ctx, users, Some(uid)).await?; + out.sort_by_key(|u| ranked.iter().position(|id| *id == u.id).unwrap_or(usize::MAX)); + Ok(Json(out)) +} + // ---- me / preferences / profile ----------------------------------------- /// GET /api/users/me/ — also logs the caller's IP. @@ -1280,7 +1316,7 @@ async fn public_profile( .order_by_desc(rating::Column::Score) .all(&state.db) .await?; - let ratings = build_ratings(&state, &ctx, ratings_models).await?; + let ratings = build_ratings(&state, &ctx, None, ratings_models).await?; let comment_models = ProfileComment::find() .filter(profile_comment::Column::ProfileOwnerId.eq(user.id)) @@ -1356,7 +1392,7 @@ async fn dashboard( .filter(|r| r.created_at.year() == this_year && r.created_at.month() == this_month) .count() as i64; - let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?; + let my_ratings = build_ratings(&state, &ctx, Some(uid), my_ratings_models).await?; let stats = dashboard_stats(&my_ratings, ratings_this_month); // "To try" count: flavors the user hasn't rated, excluding the "Packs and other" @@ -1521,6 +1557,8 @@ mod dashboard_stats_tests { comment: None, created_at: String::new(), replies: vec![], + reactions: std::collections::BTreeMap::new(), + my_reactions: vec![], } } diff --git a/backend-rs/src/service.rs b/backend-rs/src/service.rs index c6b1295..d76ffaf 100644 --- a/backend-rs/src/service.rs +++ b/backend-rs/src/service.rs @@ -5,13 +5,14 @@ use std::collections::{BTreeMap, HashMap}; use sea_orm::{ - ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, QueryFilter, QueryOrder, Statement, + ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, QueryFilter, QueryOrder, QuerySelect, + Statement, }; use serde_json::Value; use crate::dto::{ - FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, TicketMessageOut, - TicketOut, UserOut, + ActivityOut, FlavorOut, NotificationOut, ProfileCommentOut, RatingOut, ReplyOut, + TicketMessageOut, TicketOut, UserOut, }; use crate::entities::prelude::*; use crate::entities::{ @@ -88,6 +89,7 @@ pub async fn build_replies( pub async fn build_ratings( state: &AppState, ctx: &RequestCtx, + viewer_id: Option, ratings: Vec, ) -> ApiResult> { let media = &state.config.media_url; @@ -97,6 +99,8 @@ pub async fn build_ratings( let flavor_ids: Vec = ratings.iter().map(|r| r.flavor_id).collect(); let rating_ids: Vec = ratings.iter().map(|r| r.id).collect(); + let (mut counts_by_rating, mut mine_by_rating) = + load_reactions(state, &rating_ids, viewer_id).await?; let flavors = Flavor::find() .filter(flavor::Column::Id.is_in(flavor_ids)) @@ -184,11 +188,196 @@ pub async fn build_ratings( comment: r.comment.clone(), created_at: crate::datetime::drf_iso(&r.created_at), replies: replies_by_rating.remove(&r.id).unwrap_or_default(), + reactions: counts_by_rating.remove(&r.id).unwrap_or_default(), + my_reactions: mine_by_rating.remove(&r.id).unwrap_or_default(), }); } Ok(out) } +/// Build the community activity feed: recent new-flavor drops + flavors crossing +/// rating-count milestones, merged newest-first. There's no persistent event +/// table — items are synthesized from `flavor.created_at` and the crossing +/// rating's timestamp, so the feed is correct without a writer job. +pub async fn build_activity(state: &AppState, ctx: &RequestCtx) -> ApiResult> { + const MILESTONES: &[i64] = &[25, 50, 100, 250, 500, 1000]; + const DROP_LIMIT: u64 = 40; + let media = &state.config.media_url; + + // Recent catalog drops, newest first. + let drops = Flavor::find() + .order_by_desc(flavor::Column::CreatedAt) + .limit(DROP_LIMIT) + .all(&state.db) + .await?; + + // Milestone candidates: flavors at/over the smallest threshold. + let rows = state + .db + .query_all(Statement::from_string( + state.db.get_database_backend(), + format!( + "SELECT flavor_id AS fid, COUNT(*) AS c FROM api_rating \ + GROUP BY flavor_id HAVING c >= {} ORDER BY c DESC", + MILESTONES[0] + ), + )) + .await?; + let milestone_counts: Vec<(i32, i64)> = rows + .into_iter() + .filter_map(|r| { + Some(( + r.try_get::("", "fid").ok()?, + r.try_get::("", "c").ok()?, + )) + }) + .collect(); + + if drops.is_empty() && milestone_counts.is_empty() { + return Ok(Vec::new()); + } + + // Resolve flavors + categories for both sets up front (two queries). + let mut needed: Vec = drops.iter().map(|f| f.id).collect(); + needed.extend(milestone_counts.iter().map(|(fid, _)| *fid)); + needed.sort_unstable(); + needed.dedup(); + let flavors = Flavor::find() + .filter(flavor::Column::Id.is_in(needed)) + .all(&state.db) + .await?; + let flavor_map: HashMap = flavors.into_iter().map(|f| (f.id, f)).collect(); + let cat_ids: Vec = flavor_map.values().map(|f| f.category_id).collect(); + let cats = Category::find() + .filter(category::Column::Id.is_in(cat_ids)) + .all(&state.db) + .await?; + let cat_map: HashMap = + cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect(); + + let flavor_image = |f: &flavor::Model| -> Option { + if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) { + Some(image_field_url(ctx, media, img)) + } else { + f.image_url.clone() + } + }; + + // (sort_key, item) — sort_key is the event's raw timestamp. + let mut items = Vec::new(); + + for f in &drops { + let (cn, cs) = cat_map.get(&f.category_id).cloned().unwrap_or_default(); + items.push(( + f.created_at, + ActivityOut { + kind: "new_flavor".into(), + id: format!("flavor-{}", f.id), + created_at: crate::datetime::drf_iso(&f.created_at), + flavor_id: f.id, + flavor_name: f.name.clone(), + flavor_image: flavor_image(f), + category_name: cn, + category_slug: cs, + milestone: None, + rating_count: None, + }, + )); + } + + for (fid, count) in &milestone_counts { + let Some(f) = flavor_map.get(fid) else { + continue; + }; + // Highest threshold this flavor has crossed (one item per flavor). + let Some(threshold) = MILESTONES.iter().rev().copied().find(|m| *m <= *count) else { + continue; + }; + // Event time = timestamp of the rating that crossed the threshold. + let crossing = Rating::find() + .filter(rating::Column::FlavorId.eq(*fid)) + .order_by_asc(rating::Column::CreatedAt) + .offset((threshold - 1) as u64) + .limit(1) + .one(&state.db) + .await?; + let Some(crossing) = crossing else { continue }; + let (cn, cs) = cat_map.get(&f.category_id).cloned().unwrap_or_default(); + items.push(( + crossing.created_at, + ActivityOut { + kind: "milestone".into(), + id: format!("milestone-{fid}-{threshold}"), + created_at: crate::datetime::drf_iso(&crossing.created_at), + flavor_id: f.id, + flavor_name: f.name.clone(), + flavor_image: flavor_image(f), + category_name: cn, + category_slug: cs, + milestone: Some(threshold), + rating_count: Some(*count), + }, + )); + } + + items.sort_by(|a, b| b.0.cmp(&a.0)); + Ok(items.into_iter().map(|(_, a)| a).collect()) +} + +/// Batch-load reactions for a set of ratings. Returns `(counts, mine)`: +/// `counts[rating_id][kind] = n`, and `mine[rating_id]` the kinds `viewer_id` +/// reacted with (empty/absent for anonymous callers). One grouped query for +/// counts, plus one for the viewer's own rows. +pub async fn load_reactions( + state: &AppState, + rating_ids: &[i32], + viewer_id: Option, +) -> ApiResult<( + HashMap>, + HashMap>, +)> { + let mut counts: HashMap> = HashMap::new(); + let mut mine: HashMap> = HashMap::new(); + if rating_ids.is_empty() { + return Ok((counts, mine)); + } + let ids = id_list(rating_ids); + let rows = state + .db + .query_all(Statement::from_string( + DbBackend::Sqlite, + format!( + "SELECT rating_id, kind, COUNT(*) AS n FROM rating_reaction \ + WHERE rating_id IN ({ids}) GROUP BY rating_id, kind" + ), + )) + .await?; + for row in rows { + let rid: i32 = row.try_get("", "rating_id")?; + let kind: String = row.try_get("", "kind")?; + let n: i64 = row.try_get("", "n")?; + counts.entry(rid).or_default().insert(kind, n); + } + if let Some(uid) = viewer_id { + let rows = state + .db + .query_all(Statement::from_string( + DbBackend::Sqlite, + format!( + "SELECT rating_id, kind FROM rating_reaction \ + WHERE user_id = {uid} AND rating_id IN ({ids})" + ), + )) + .await?; + for row in rows { + let rid: i32 = row.try_get("", "rating_id")?; + let kind: String = row.try_get("", "kind")?; + mine.entry(rid).or_default().push(kind); + } + } + Ok((counts, mine)) +} + /// Per-flavor rating rollup accumulated in a single pass over the loaded ratings, /// replacing the previous O(flavors × ratings) re-scan. struct RatingAgg { @@ -381,6 +570,10 @@ pub async fn build_flavors( comment: r.comment.clone(), created_at: crate::datetime::drf_iso(&r.created_at), replies: replies_by_rating.remove(&r.id).unwrap_or_default(), + // Reactions surface on the community feed, not the flavor detail + // ratings list — keep this path's payload light. + reactions: std::collections::BTreeMap::new(), + my_reactions: Vec::new(), }); } diff --git a/backend-rs/src/throttle.rs b/backend-rs/src/throttle.rs index 7242dc1..1bde8f3 100644 --- a/backend-rs/src/throttle.rs +++ b/backend-rs/src/throttle.rs @@ -54,6 +54,9 @@ pub const PASSWORD_RESET_COMPLETE: Rate = Rate::per_hour(5); pub const ACCOUNT_DELETION_REQUEST: Rate = Rate::per_hour(3); /// `RatingViewSet.create` — key="user" rate="10/m". pub const RATING_CREATE: Rate = Rate::per_minute(10); +/// Reaction toggle (react/unreact) — key="user". No Django equivalent; generous +/// since a user clicking around a feed legitimately fires several per minute. +pub const REACTION: Rate = Rate::per_minute(60); // --- Added hardening (no Django equivalent) -------------------------------- /// Login brute-force guard, keyed by IP (Django had none). diff --git a/community-redesign.md b/community-redesign.md index 009bd57..574d3d8 100644 --- a/community-redesign.md +++ b/community-redesign.md @@ -53,43 +53,93 @@ No backend changes. Reuses the existing `GET /api/ratings/recent/` endpoint ## Phase 2 — needs backend -### 1. Reactions (highest engagement lever) +### 1. Reactions (highest engagement lever) — DONE Lightweight 👍 / emoji on ratings. Social proof, drives return visits. - New `rating_reaction` table (`user_id`, `rating_id`, `kind`, `created_at`; - unique on `user_id + rating_id + kind`). + unique on `user_id + rating_id + kind`). Created in `db.rs::ensure_schema` + (idempotent `CREATE TABLE IF NOT EXISTS` + index on `rating_id`). - Endpoints: `POST /api/ratings/{id}/react/`, `DELETE /api/ratings/{id}/react/`. -- Include reaction counts + the caller's own reaction in `RatingOut`. -- Frontend: reaction bar on each feed card; optimistic toggle. + Allow-listed `kind`, rate-limited (60/min/user), idempotent on the unique index. +- `RatingOut` carries `reactions` (kind → count) + `my_reactions` (caller's own). + Threaded through `build_ratings(viewer_id)` via the `OptionalUser` extractor. +- Frontend: `ReactionBar` on each feed card; optimistic toggle, server-reconciled. -### 2. Discover feed with sort + pagination +#### Files touched -`recent/` is fixed at 10 and unpaginated. Promote Discover to a real feed. +- `backend-rs/src/entities/rating_reaction.rs` (new), `entities/mod.rs`. +- `backend-rs/src/db.rs` (table + index), `throttle.rs` (`REACTION` rate), + `dto.rs` (`reactions`/`my_reactions`), `service.rs` (`load_reactions`, + `build_ratings` viewer param), `routes/ratings.rs` (`react`/`unreact`), + plus call-site updates in `routes/users.rs`, `routes/admin.rs`. +- `frontend/src/components/ReactionBar.tsx` (new), + `frontend/src/pages/CommunityFeed.tsx`, `api/queries/useCommunityFeed.ts`, + `frontend/src/i18n.ts`. -- Extend `ratings/feed/` (or a new `ratings/discover/`) to accept - `?sort=recent|top` and `?category=` and `?page=`. -- Frontend: sticky filter bar with `Recent | Top | `. Reuse the - `category_slug` already in the payload. +### 2. Discover feed with sort + pagination — DONE -### 3. Who-to-follow +`recent/` is fixed at 10 and unpaginated. Discover is now a real feed. + +- New `GET /api/ratings/discover/` (AllowAny) accepts `?sort=recent|top`, + `?category=`, `?page=`. Quality bar kept (non-empty comment only). + `top` orders by score desc then recency; category resolves slug → flavor ids. +- Frontend: sticky filter bar — `Recent | Top rated` sort chips + `All` plus a + chip per category (reuses `useCategories`). Discover now paginates like + Following; page resets on tab/sort/category change. + +#### Files touched + +- `backend-rs/src/routes/ratings.rs` (`discover` handler + route). +- `frontend/src/api/queries/useCommunityFeed.ts` (`useDiscoverFeed`), + `frontend/src/api/keys.ts` (`communityDiscover`), + `frontend/src/pages/CommunityFeed.tsx` (filter bar, discover pagination), + `frontend/src/i18n.ts` (`sortRecent`, `sortTop`, `allCategories`). + +### 3. Who-to-follow — DONE Sidebar card suggesting active raters the user doesn't follow yet. -- `GET /api/users/suggested/` — most-active raters excluding self + already-followed. -- Frontend: avatar + name + follow button; optimistic follow. +- `GET /api/users/suggested/` — up to 5 most-active raters (by rating count), + excluding self + already-followed. Returns `UserOut` (reuses `build_users`), + re-sorted to the activity ranking after the id-ordered load. +- Frontend: `SuggestedFollows` card (avatar + name + follower count + Follow + button), optimistic — drops the user from the list on follow, reverts on error. -### 4. Richer activity types +#### Files touched + +- `backend-rs/src/routes/users.rs` (`suggested` handler + route). +- `frontend/src/api/queries/useCommunityFeed.ts` (`useSuggestedUsers`), + `frontend/src/pages/CommunityFeed.tsx` (`SuggestedFollows` sidebar card), + `frontend/src/i18n.ts` (`whoToFollow`, `follow`, `followFailed`, + `followerCount`). + +### 4. Richer activity types — DONE Make the feed alive when friends are quiet. -- New-flavor drops (from the `sync_flavors` job) and milestones - ("X hit 100 ratings") as feed items. -- Likely a unified activity payload or a separate `activity/` endpoint. +- New `GET /api/activity/` (AllowAny, paginated): a unified, newest-first stream + of `new_flavor` drops + `milestone` events. No persistent event table — items + are synthesized on read from `flavor.created_at` and, for milestones, the + timestamp of the rating that crossed a threshold (25/50/100/250/500/1000; + one item per flavor at its highest crossed threshold). Candidate set is bounded + (latest 40 drops + flavors ≥25 ratings), built once and paged in memory. +- Frontend: third **Activity** tab with its own card style (drop vs. milestone + icon), paginated like the others. + +#### Files touched + +- `backend-rs/src/dto.rs` (`ActivityOut`), `service.rs` (`build_activity`), + `routes/activity.rs` (new), `routes/mod.rs` (mount). +- `frontend/src/api/queries/useCommunityFeed.ts` (`useActivityFeed`), + `frontend/src/api/keys.ts` (`communityActivity`), + `frontend/src/pages/CommunityFeed.tsx` (Activity tab + `renderActivityCard`), + `frontend/src/i18n.ts` (`tabActivity`, `activitySubtitle`, `activityEmpty`, + `activityEmptyHint`, `newDropLabel`, `milestoneLabel`, `milestoneText`). --- ## Suggested order Phase 2.1 (reactions) and 2.2 (Discover sort/filter) give the most UX lift per -unit of backend work. 2.3 and 2.4 follow. +unit of backend work. 2.3 and 2.4 follow. **All of Phase 2 is now complete.** diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index ad4cf12..5e610bd 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -17,4 +17,6 @@ export const queryKeys = { activeBanner: (username: string) => ['activeBanner', username] as const, notifications: () => ['notifications'] as const, communityFeed: (params: Record) => ['communityFeed', params] as const, + communityDiscover: (params: Record) => ['communityDiscover', params] as const, + communityActivity: (params: Record) => ['communityActivity', params] as const, }; diff --git a/frontend/src/api/queries/useCommunityFeed.ts b/frontend/src/api/queries/useCommunityFeed.ts index 223f902..35eb95d 100644 --- a/frontend/src/api/queries/useCommunityFeed.ts +++ b/frontend/src/api/queries/useCommunityFeed.ts @@ -23,6 +23,8 @@ export interface FeedRating { comment: string; created_at: string; replies: FeedReply[]; + reactions?: Record; + my_reactions?: string[]; } export interface FollowedUser { @@ -60,6 +62,77 @@ export const useCommunityFeed = (page: number) => placeholderData: (prev) => prev, }); +export interface ActivityItem { + kind: 'new_flavor' | 'milestone'; + id: string; + created_at: string; + flavor_id: number; + flavor_name: string; + flavor_image: string | null; + category_name: string; + category_slug: string; + milestone?: number; + rating_count?: number; +} + +export interface ActivityResult { + items: ActivityItem[]; + totalPages: number; +} + +/** Community activity stream — new-flavor drops + rating milestones, paginated. */ +export const useActivityFeed = (page: number, enabled = true) => + useQuery({ + queryKey: queryKeys.communityActivity({ page }), + queryFn: async (): Promise => { + const res = await api.get<{ results?: ActivityItem[]; count?: number } | ActivityItem[]>( + `activity/?page=${page}`, + ); + if (Array.isArray(res.data)) { + return { items: res.data, totalPages: 1 }; + } + const items = res.data.results ?? []; + const totalPages = Math.ceil((res.data.count ?? 0) / 10) || 1; + return { items, totalPages }; + }, + enabled, + placeholderData: (prev) => prev, + }); + +export type DiscoverSort = 'recent' | 'top'; + +export interface DiscoverParams { + page: number; + sort: DiscoverSort; + category: string | null; + enabled?: boolean; +} + +/** + * Global Discover feed (`ratings/discover/`) — paginated, sortable (recent|top), + * filterable by category slug. Unlike `useRecentRatings` this is a real paginated + * feed, so the Discover tab gets sort + filter + pages. + */ +export const useDiscoverFeed = ({ page, sort, category, enabled = true }: DiscoverParams) => + useQuery({ + queryKey: queryKeys.communityDiscover({ page, sort, category: category ?? '' }), + queryFn: async (): Promise => { + const qs = new URLSearchParams({ page: String(page), sort }); + if (category) qs.set('category', category); + const res = await api.get<{ results?: FeedRating[]; count?: number } | FeedRating[]>( + `ratings/discover/?${qs.toString()}`, + ); + if (Array.isArray(res.data)) { + return { ratings: res.data, totalPages: 1 }; + } + const ratings = res.data.results ?? []; + const totalPages = Math.ceil((res.data.count ?? 0) / 10) || 1; + return { ratings, totalPages }; + }, + enabled, + placeholderData: (prev) => prev, + }); + export const useRecentRatings = (enabled = true) => useQuery({ queryKey: ['recentRatings'], @@ -81,6 +154,25 @@ export const useFollowingList = () => }, }); +export interface SuggestedUser { + id: number; + username: string; + avatar: string | null; + followers_count: number; + is_following: boolean; +} + +/** Who-to-follow: most-active raters the caller doesn't follow yet (max 5). */ +export const useSuggestedUsers = (enabled = true) => + useQuery({ + queryKey: ['suggestedUsers'], + queryFn: async (): Promise => { + const res = await api.get('users/suggested/'); + return Array.isArray(res.data) ? res.data : (res.data.results ?? []); + }, + enabled, + }); + export const useFollowedTopFlavors = () => useQuery({ queryKey: ['followedTopFlavors'], diff --git a/frontend/src/components/ReactionBar.tsx b/frontend/src/components/ReactionBar.tsx new file mode 100644 index 0000000..811e6cc --- /dev/null +++ b/frontend/src/components/ReactionBar.tsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import { Box, Tooltip, ButtonBase } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import api from '../lib/api'; +import { useToast } from '../hooks/useToast'; + +/** Reaction kinds, in display order. Must match the backend allow-list + * (`REACTION_KINDS` in `routes/ratings.rs`). */ +export const REACTION_KINDS = ['like', 'love', 'fire', 'yum', 'mind', 'meh'] as const; +export type ReactionKind = (typeof REACTION_KINDS)[number]; + +const EMOJI: Record = { + like: '👍', + love: '❤️', + fire: '🔥', + yum: '😋', + mind: '🤯', + meh: '😐', +}; + +interface ServerRating { + reactions?: Record; + my_reactions?: string[]; +} + +interface ReactionBarProps { + ratingId: number; + reactions?: Record; + myReactions?: string[]; + /** When false the bar is read-only (counts only, no toggling). */ + canReact?: boolean; +} + +/** + * Emoji reaction bar for a rating. Owns its own optimistic state seeded from + * props, so a toggle feels instant; the server response (full reaction map) then + * reconciles the counts. Errors revert and surface a toast. + */ +const ReactionBar: React.FC = ({ + ratingId, + reactions = {}, + myReactions = [], + canReact = true, +}) => { + const { t } = useTranslation(); + const { notify } = useToast(); + const [counts, setCounts] = useState>(reactions); + const [mine, setMine] = useState>(new Set(myReactions)); + const [busy, setBusy] = useState(false); + + const toggle = async (kind: ReactionKind) => { + if (!canReact || busy) return; + const active = mine.has(kind); + const prevCounts = counts; + const prevMine = mine; + + // Optimistic: flip membership + nudge the count. + const nextMine = new Set(mine); + const nextCounts = { ...counts }; + if (active) { + nextMine.delete(kind); + nextCounts[kind] = Math.max(0, (nextCounts[kind] ?? 1) - 1); + } else { + nextMine.add(kind); + nextCounts[kind] = (nextCounts[kind] ?? 0) + 1; + } + setMine(nextMine); + setCounts(nextCounts); + setBusy(true); + + try { + const res = await api.request({ + url: `ratings/${ratingId}/react/`, + method: active ? 'delete' : 'post', + data: { kind }, + }); + // Reconcile against the server's authoritative map (covers others' reactions). + setCounts(res.data.reactions ?? {}); + setMine(new Set(res.data.my_reactions ?? [])); + } catch { + setCounts(prevCounts); + setMine(prevMine); + notify({ message: t('community.reactionFailed'), severity: 'error' }); + } finally { + setBusy(false); + } + }; + + return ( + + {REACTION_KINDS.map((kind) => { + const count = counts[kind] ?? 0; + const active = mine.has(kind); + // Hide zero-count reactions the viewer hasn't picked, to keep the bar tidy. + if (count === 0 && !active && !canReact) return null; + return ( + + toggle(kind)} + disabled={!canReact} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + px: 0.9, + py: 0.3, + borderRadius: 5, + fontSize: '0.95rem', + lineHeight: 1, + border: '1px solid', + borderColor: active ? 'primary.main' : 'divider', + bgcolor: active ? 'primary.main' : 'transparent', + color: active ? 'primary.contrastText' : 'text.secondary', + opacity: count === 0 && !active ? 0.55 : 1, + transition: 'all 0.15s ease', + cursor: canReact ? 'pointer' : 'default', + '&:hover': canReact + ? { + borderColor: 'primary.main', + bgcolor: active ? 'primary.dark' : 'action.hover', + } + : undefined, + }} + > + {EMOJI[kind]} + {count > 0 && ( + + {count} + + )} + + + ); + })} + + ); +}; + +export default ReactionBar; diff --git a/frontend/src/components/layout/DesktopCategoryBar.tsx b/frontend/src/components/layout/DesktopCategoryBar.tsx index bfb516f..0c3d0bd 100644 --- a/frontend/src/components/layout/DesktopCategoryBar.tsx +++ b/frontend/src/components/layout/DesktopCategoryBar.tsx @@ -67,12 +67,7 @@ export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => { scrollbarWidth: 'none', }} > - @@ -85,12 +80,7 @@ export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => { )} {user && ( - diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 27da4c5..e9a76f5 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -299,6 +299,30 @@ const resources = { discoverEmpty: 'No recent activity yet.', discoverEmptyHint: 'Be the first to rate a flavor!', browseDiscover: 'Browse Discover', + sortRecent: 'Recent', + sortTop: 'Top rated', + allCategories: 'All', + whoToFollow: 'Who to follow', + follow: 'Follow', + followFailed: 'Could not follow this user.', + followerCount_one: '{{count}} follower', + followerCount_other: '{{count}} followers', + tabActivity: 'Activity', + activitySubtitle: 'Fresh drops and milestones from across the catalog.', + activityEmpty: 'Nothing new yet.', + activityEmptyHint: 'New flavors and milestones will show up here.', + newDropLabel: 'New flavor drop', + milestoneLabel: 'Milestone', + milestoneText: '{{flavor}} hit {{count}} ratings', + reactionFailed: 'Could not save your reaction.', + reactions: { + like: 'Like', + love: 'Love', + fire: 'Fire', + yum: 'Yum', + mind: 'Mind blown', + meh: 'Meh', + }, }, support: { title: 'Help & Support', @@ -842,6 +866,30 @@ const resources = { discoverEmpty: 'Noch keine Aktivität.', discoverEmptyHint: 'Sei der Erste, der einen Geschmack bewertet!', browseDiscover: 'Entdecken durchstöbern', + sortRecent: 'Neueste', + sortTop: 'Top bewertet', + allCategories: 'Alle', + whoToFollow: 'Wem folgen', + follow: 'Folgen', + followFailed: 'Konnte diesem Nutzer nicht folgen.', + followerCount_one: '{{count}} Follower', + followerCount_other: '{{count}} Follower', + tabActivity: 'Aktivität', + activitySubtitle: 'Neue Sorten und Meilensteine aus dem ganzen Katalog.', + activityEmpty: 'Noch nichts Neues.', + activityEmptyHint: 'Neue Sorten und Meilensteine erscheinen hier.', + newDropLabel: 'Neue Sorte', + milestoneLabel: 'Meilenstein', + milestoneText: '{{flavor}} erreichte {{count}} Bewertungen', + reactionFailed: 'Reaktion konnte nicht gespeichert werden.', + reactions: { + like: 'Gefällt mir', + love: 'Liebe', + fire: 'Feuer', + yum: 'Lecker', + mind: 'Umgehauen', + meh: 'Naja', + }, }, support: { title: 'Hilfe & Support', diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 2d3b409..62fe74e 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -17,19 +17,31 @@ import { Tabs, Tab, Skeleton, + Chip, + Stack, } from '@mui/material'; import CommentIcon from '@mui/icons-material/Comment'; import SendIcon from '@mui/icons-material/Send'; import WhatshotIcon from '@mui/icons-material/Whatshot'; import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; import ExploreIcon from '@mui/icons-material/Explore'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import BoltIcon from '@mui/icons-material/Bolt'; +import NewReleasesIcon from '@mui/icons-material/NewReleases'; +import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import { Link, useNavigate } from 'react-router-dom'; import { useCommunityFeed, useFollowedTopFlavors, - useRecentRatings, + useDiscoverFeed, + useSuggestedUsers, + useActivityFeed, type FeedRating, + type DiscoverSort, + type ActivityItem, } from '../api/queries/useCommunityFeed'; +import { useCategories } from '../api/queries/useCategories'; +import { useFollowToggle } from '../api/mutations/useSocialMutations'; import { useCreateReply } from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; import { useAuth } from '../hooks/useAuth'; @@ -38,6 +50,7 @@ import { formatDate } from '../utils/date'; import { useTranslation } from 'react-i18next'; import MentionTextField from '../components/MentionTextField'; import RatingBadge from '../components/RatingBadge'; +import ReactionBar from '../components/ReactionBar'; import { PageShell, HeroBackdrop, @@ -49,7 +62,7 @@ import { } from '../components/ui'; import { useToast } from '../hooks/useToast'; -type FeedTab = 'following' | 'discover'; +type FeedTab = 'following' | 'discover' | 'activity'; const SidebarCard: React.FC<{ icon: React.ReactNode; @@ -76,6 +89,74 @@ const SidebarCard: React.FC<{ ); +/** Who-to-follow sidebar card. Optimistically drops a user from the list once + * the caller follows them; reverts + toasts on failure. */ +const SuggestedFollows: React.FC = () => { + const { t } = useTranslation(); + const { notify } = useToast(); + const { data: suggestions = [] } = useSuggestedUsers(); + const followToggle = useFollowToggle(); + const [followed, setFollowed] = useState>(new Set()); + + const visible = suggestions.filter((u) => !followed.has(u.id)); + if (visible.length === 0) return null; + + const handleFollow = async (userId: number) => { + setFollowed((prev) => new Set(prev).add(userId)); + try { + await followToggle.mutateAsync({ userId, currentlyFollowing: false }); + } catch { + setFollowed((prev) => { + const next = new Set(prev); + next.delete(userId); + return next; + }); + notify({ message: t('community.followFailed'), severity: 'error' }); + } + }; + + return ( + } + title={t('community.whoToFollow')} + > + + {visible.map((u) => ( + + + + {!u.avatar && u.username.charAt(0).toUpperCase()} + + + + + + {u.username} + + + + {t('community.followerCount', { count: u.followers_count })} + + + + + ))} + + + ); +}; + const FeedSkeleton: React.FC = () => ( {[0, 1, 2].map((i) => ( @@ -112,6 +193,8 @@ const CommunityFeed: React.FC = () => { const { notify } = useToast(); const [tab, setTab] = useState('following'); const [page, setPage] = useState(1); + const [discoverSort, setDiscoverSort] = useState('recent'); + const [discoverCategory, setDiscoverCategory] = useState(null); const [replyInputs, setReplyInputs] = useState>({}); const [expandedReplies, setExpandedReplies] = useState>({}); @@ -122,25 +205,59 @@ const CommunityFeed: React.FC = () => { data: discover, isLoading: discoverLoading, error: discoverError, - } = useRecentRatings(tab === 'discover'); + } = useDiscoverFeed({ + page, + sort: discoverSort, + category: discoverCategory, + enabled: tab === 'discover', + }); + const { + data: activity, + isLoading: activityLoading, + error: activityError, + } = useActivityFeed(page, tab === 'activity'); + const { data: categories = [] } = useCategories(); const { data: topFollowed = [] } = useFollowedTopFlavors(); const createReply = useCreateReply(); const followingRatings: FeedRating[] = feed?.ratings ?? []; - const discoverRatings: FeedRating[] = discover ?? []; - const ratings = tab === 'following' ? followingRatings : discoverRatings; - const totalPages = feed?.totalPages ?? 1; - const loading = tab === 'following' ? feedLoading : discoverLoading; + const discoverRatings: FeedRating[] = discover?.ratings ?? []; + const activityItems: ActivityItem[] = activity?.items ?? []; + const ratings = tab === 'discover' ? discoverRatings : followingRatings; + const totalPages = + (tab === 'following' + ? feed?.totalPages + : tab === 'discover' + ? discover?.totalPages + : activity?.totalPages) ?? 1; + const loading = + tab === 'following' ? feedLoading : tab === 'discover' ? discoverLoading : activityLoading; + + const handleTabChange = (next: FeedTab) => { + setTab(next); + setPage(1); + }; + + const handleSortChange = (next: DiscoverSort) => { + setDiscoverSort(next); + setPage(1); + }; + + const handleCategoryChange = (slug: string | null) => { + setDiscoverCategory(slug); + setPage(1); + }; useEffect(() => { if (!isAuthed) navigate('/login'); }, [isAuthed, navigate]); useEffect(() => { - const err = tab === 'following' ? feedError : discoverError; + const err = + tab === 'following' ? feedError : tab === 'discover' ? discoverError : activityError; const status = (err as { response?: { status?: number } } | null)?.response?.status; if (status === 401) navigate('/login'); - }, [feedError, discoverError, tab, navigate]); + }, [feedError, discoverError, activityError, tab, navigate]); const handlePageChange = (_: React.ChangeEvent, value: number) => { setPage(value); @@ -242,22 +359,42 @@ const CommunityFeed: React.FC = () => { )} - + - + + + {!expanded && latestReply && ( { minWidth: 0, }} > - + {latestReply.user} @@ -346,8 +486,63 @@ const CommunityFeed: React.FC = () => { ); }; + const renderActivityCard = (item: ActivityItem) => { + const isMilestone = item.kind === 'milestone'; + return ( + + + + + {isMilestone ? ( + + ) : ( + + )} + + + + {isMilestone ? t('community.milestoneLabel') : t('community.newDropLabel')} + + + {isMilestone + ? t('community.milestoneText', { + flavor: item.flavor_name, + count: item.milestone, + }) + : item.flavor_name} + + + {formatDate(item.created_at)} + + + + + + + + + ); + }; + const subtitle = - tab === 'discover' ? t('community.discoverSubtitle') : t('community.subtitle'); + tab === 'discover' + ? t('community.discoverSubtitle') + : tab === 'activity' + ? t('community.activitySubtitle') + : t('community.subtitle'); return ( }> @@ -357,7 +552,7 @@ const CommunityFeed: React.FC = () => { setTab(v)} + onChange={(_, v: FeedTab) => handleTabChange(v)} sx={{ mb: 3, '& .MuiTab-root': { textTransform: 'none', fontWeight: 700 } }} > { iconPosition="start" label={t('community.tabDiscover')} /> + } + iconPosition="start" + label={t('community.tabActivity')} + /> + {tab === 'discover' && ( + + + handleSortChange('recent')} + /> + handleSortChange('top')} + /> + + + + handleCategoryChange(null)} + /> + {categories.map((cat) => ( + handleCategoryChange(cat.slug)} + /> + ))} + + + )} + - {loading && ratings.length === 0 ? ( + {tab === 'activity' ? ( + loading && activityItems.length === 0 ? ( + + ) : activityItems.length === 0 ? ( + + {t('home.exploreFlavors')} + + } + /> + ) : ( + + {activityItems.map(renderActivityCard)} + {totalPages > 1 && ( + + + + )} + + ) + ) : loading && ratings.length === 0 ? ( ) : ratings.length === 0 ? ( tab === 'following' ? ( @@ -387,7 +665,7 @@ const CommunityFeed: React.FC = () => {