community tab redesign

This commit is contained in:
nvrl committed 2026-06-16 19:59:52 +02:00
1 parent 1fb318f6a1
commit 2f2935a1cb
18 files changed
+1222 -70

No files matched your search

+21
View File
@@ -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(())
}
+27
View File
@@ -40,6 +40,33 @@ pub struct RatingOut {
pub comment: Option<String>,
pub created_at: String,
pub replies: Vec<ReplyOut>,
/// Reaction counts keyed by kind (e.g. `{"fire": 3, "yum": 1}`). Empty when none.
pub reactions: BTreeMap<String, i64>,
/// Kinds the requesting user has reacted with (empty for anonymous callers).
pub my_reactions: Vec<String>,
}
/// 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<String>,
pub category_name: String,
pub category_slug: String,
/// Threshold reached, for `milestone` items only.
#[serde(skip_serializing_if = "Option::is_none")]
pub milestone: Option<i64>,
/// Current rating count, for `milestone` items only.
#[serde(skip_serializing_if = "Option::is_none")]
pub rating_count: Option<i64>,
}
#[derive(Serialize, ToSchema)]
+2
View File
@@ -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;
@@ -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<super::rating::Entity> for Entity {
fn to() -> RelationDef {
Relation::Rating.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+44
View File
@@ -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<String>,
}
pub fn router() -> Router<AppState> {
Router::new().route("/activity/", get(activity))
}
/// GET /api/activity/ — paginated activity stream, newest first.
async fn activity(
State(state): State<AppState>,
ctx: RequestCtx,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<ActivityOut>>> {
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<ActivityOut> = all.into_iter().skip(start).take(size as usize).collect();
Ok(Json(Paginated::build(&ctx, items, count, page, size)))
}
+1 -1
View File
@@ -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)
+2
View File
@@ -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<AppState> {
Router::new()
.merge(activity::router())
.merge(auth::router())
.merge(categories::router())
.merge(flavors::router())
+186 -9
View File
@@ -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<AppState> {
.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<String>,
}
#[derive(Deserialize)]
@@ -44,6 +55,15 @@ struct PageParam {
page: Option<String>,
}
#[derive(Deserialize)]
struct DiscoverParams {
page: Option<String>,
/// `recent` (default) or `top` (highest score first).
sort: Option<String>,
/// Filter to a single category by its slug.
category: Option<String>,
}
/// Load the acting user row (for superuser checks). 401 if the id is stale.
async fn load_actor(state: &AppState, uid: i32) -> ApiResult<user::Model> {
User::find_by_id(uid)
@@ -61,6 +81,7 @@ fn as_i32(v: &Value) -> Option<i32> {
async fn list(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Query(params): Query<PageParam>,
) -> ApiResult<Json<Paginated<RatingOut>>> {
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<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Path(id): Path<i32>,
) -> ApiResult<Json<RatingOut>> {
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<AppState>, ctx: RequestCtx) -> ApiResult<Json<Vec<RatingOut>>> {
async fn recent(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
) -> ApiResult<Json<Vec<RatingOut>>> {
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<AppState>, ctx: RequestCtx) -> ApiResult<Jso
.limit(10)
.all(&state.db)
.await?;
Ok(Json(build_ratings(&state, &ctx, models).await?))
Ok(Json(build_ratings(&state, &ctx, viewer, models).await?))
}
/// GET /api/ratings/discover/ — global community feed (AllowAny), paginated.
/// `?sort=recent|top`, `?category=<slug>`, `?page=`. Only ratings with a non-empty
/// comment, matching the prior Discover tab's quality bar.
async fn discover(
State(state): State<AppState>,
ctx: RequestCtx,
OptionalUser(viewer): OptionalUser,
Query(params): Query<DiscoverParams>,
) -> ApiResult<Json<Paginated<RatingOut>>> {
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<i32> = 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<String> {
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<Json<RatingOut>> {
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<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<Json<RatingOut>> {
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<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
Path(id): Path<i32>,
body: Bytes,
) -> ApiResult<Json<RatingOut>> {
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
}
+40 -2
View File
@@ -69,6 +69,7 @@ pub fn router() -> Router<AppState> {
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<AppState>,
ctx: RequestCtx,
AuthUser(uid): AuthUser,
) -> ApiResult<Json<Vec<UserOut>>> {
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<i32> = rows
.into_iter()
.filter_map(|r| r.try_get::<i32>("", "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![],
}
}
+196 -3
View File
@@ -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<i32>,
ratings: Vec<rating::Model>,
) -> ApiResult<Vec<RatingOut>> {
let media = &state.config.media_url;
@@ -97,6 +99,8 @@ pub async fn build_ratings(
let flavor_ids: Vec<i32> = ratings.iter().map(|r| r.flavor_id).collect();
let rating_ids: Vec<i32> = 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<Vec<ActivityOut>> {
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::<i32>("", "fid").ok()?,
r.try_get::<i64>("", "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<i32> = 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<i32, flavor::Model> = flavors.into_iter().map(|f| (f.id, f)).collect();
let cat_ids: Vec<i32> = 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<i32, (String, String)> =
cats.into_iter().map(|c| (c.id, (c.name, c.slug))).collect();
let flavor_image = |f: &flavor::Model| -> Option<String> {
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<i32>,
) -> ApiResult<(
HashMap<i32, BTreeMap<String, i64>>,
HashMap<i32, Vec<String>>,
)> {
let mut counts: HashMap<i32, BTreeMap<String, i64>> = HashMap::new();
let mut mine: HashMap<i32, Vec<String>> = 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(),
});
}
+3
View File
@@ -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).
+68 -18
View File
@@ -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=<slug>` and `?page=`.
- Frontend: sticky filter bar with `Recent | Top | <category chips>`. 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=<slug>`, `?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.**
+2
View File
@@ -17,4 +17,6 @@ export const queryKeys = {
activeBanner: (username: string) => ['activeBanner', username] as const,
notifications: () => ['notifications'] as const,
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
communityDiscover: (params: Record<string, unknown>) => ['communityDiscover', params] as const,
communityActivity: (params: Record<string, unknown>) => ['communityActivity', params] as const,
};
@@ -23,6 +23,8 @@ export interface FeedRating {
comment: string;
created_at: string;
replies: FeedReply[];
reactions?: Record<string, number>;
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<ActivityResult> => {
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<CommunityFeedResult> => {
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<SuggestedUser[]> => {
const res = await api.get<SuggestedUser[] | { results: SuggestedUser[] }>('users/suggested/');
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
enabled,
});
export const useFollowedTopFlavors = () =>
useQuery({
queryKey: ['followedTopFlavors'],
+139
View File
@@ -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<ReactionKind, string> = {
like: '👍',
love: '❤️',
fire: '🔥',
yum: '😋',
mind: '🤯',
meh: '😐',
};
interface ServerRating {
reactions?: Record<string, number>;
my_reactions?: string[];
}
interface ReactionBarProps {
ratingId: number;
reactions?: Record<string, number>;
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<ReactionBarProps> = ({
ratingId,
reactions = {},
myReactions = [],
canReact = true,
}) => {
const { t } = useTranslation();
const { notify } = useToast();
const [counts, setCounts] = useState<Record<string, number>>(reactions);
const [mine, setMine] = useState<Set<string>>(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<ServerRating>({
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 (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
{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 (
<Tooltip key={kind} title={t(`community.reactions.${kind}`)} arrow>
<ButtonBase
onClick={() => 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,
}}
>
<span>{EMOJI[kind]}</span>
{count > 0 && (
<Box component="span" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
{count}
</Box>
)}
</ButtonBase>
</Tooltip>
);
})}
</Box>
);
};
export default ReactionBar;
@@ -67,12 +67,7 @@ export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => {
scrollbarWidth: 'none',
}}
>
<Button
component={Link}
to="/"
disableRipple
sx={{ ...tabSx(isActive('/')), ml: -1.75 }}
>
<Button component={Link} to="/" disableRipple sx={{ ...tabSx(isActive('/')), ml: -1.75 }}>
<HomeOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
{t('nav.home')}
</Button>
@@ -85,12 +80,7 @@ export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => {
)}
{user && (
<Button
component={Link}
to="/dashboard"
disableRipple
sx={tabSx(isActive('/dashboard'))}
>
<Button component={Link} to="/dashboard" disableRipple sx={tabSx(isActive('/dashboard'))}>
<CollectionsBookmarkOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
{t('nav.dashboard')}
</Button>
+48
View File
@@ -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',
+304 -25
View File
@@ -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<{
</GlassCard>
);
/** 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<Set<number>>(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 (
<SidebarCard
icon={<PersonAddIcon color="primary" fontSize="small" />}
title={t('community.whoToFollow')}
>
<List disablePadding>
{visible.map((u) => (
<Box key={u.id} sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.25 }}>
<Link to={`/profile/${u.username}`} style={{ textDecoration: 'none' }}>
<Avatar src={u.avatar || undefined} sx={{ width: 36, height: 36 }}>
{!u.avatar && u.username.charAt(0).toUpperCase()}
</Avatar>
</Link>
<Box sx={{ minWidth: 0, flex: 1 }}>
<MuiLink
component={Link}
to={`/profile/${u.username}`}
sx={{ color: 'inherit', textDecoration: 'none', fontWeight: 700 }}
>
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
{u.username}
</Typography>
</MuiLink>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
{t('community.followerCount', { count: u.followers_count })}
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={() => handleFollow(u.id)}
sx={{ textTransform: 'none', borderRadius: 2, flexShrink: 0, fontWeight: 700 }}
>
{t('community.follow')}
</Button>
</Box>
))}
</List>
</SidebarCard>
);
};
const FeedSkeleton: React.FC = () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{[0, 1, 2].map((i) => (
@@ -112,6 +193,8 @@ const CommunityFeed: React.FC = () => {
const { notify } = useToast();
const [tab, setTab] = useState<FeedTab>('following');
const [page, setPage] = useState(1);
const [discoverSort, setDiscoverSort] = useState<DiscoverSort>('recent');
const [discoverCategory, setDiscoverCategory] = useState<string | null>(null);
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
@@ -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<unknown>, value: number) => {
setPage(value);
@@ -242,22 +359,42 @@ const CommunityFeed: React.FC = () => {
)}
</Box>
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
<FlavorThumb src={rating.flavor_image} name={rating.flavor_name} size={70} radius={1} />
<FlavorThumb
src={rating.flavor_image}
name={rating.flavor_name}
size={70}
radius={1}
/>
</Link>
</Box>
<Divider sx={{ my: 2 }} />
<Button
size="small"
startIcon={<CommentIcon fontSize="small" />}
onClick={() => handleReplyToggle(rating.id)}
sx={{ textTransform: 'none', color: 'text.secondary' }}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{rating.replies.length > 0
? `${rating.replies.length} ${t('common.replies')}`
: t('common.reply')}
</Button>
<ReactionBar
ratingId={rating.id}
reactions={rating.reactions}
myReactions={rating.my_reactions}
/>
<Button
size="small"
startIcon={<CommentIcon fontSize="small" />}
onClick={() => handleReplyToggle(rating.id)}
sx={{ textTransform: 'none', color: 'text.secondary', flexShrink: 0 }}
>
{rating.replies.length > 0
? `${rating.replies.length} ${t('common.replies')}`
: t('common.reply')}
</Button>
</Box>
{!expanded && latestReply && (
<Box
@@ -275,7 +412,10 @@ const CommunityFeed: React.FC = () => {
minWidth: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}
>
{latestReply.user}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap sx={{ minWidth: 0 }}>
@@ -346,8 +486,63 @@ const CommunityFeed: React.FC = () => {
);
};
const renderActivityCard = (item: ActivityItem) => {
const isMilestone = item.kind === 'milestone';
return (
<GlassCard key={item.id} intensity="default">
<CardContent sx={{ p: { xs: 2, sm: 2.5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: '50%',
flexShrink: 0,
color: isMilestone ? 'warning.main' : 'success.main',
bgcolor: isMilestone ? 'warning.main' : 'success.main',
opacity: 0.9,
}}
>
{isMilestone ? (
<EmojiEventsIcon sx={{ color: 'common.white' }} />
) : (
<NewReleasesIcon sx={{ color: 'common.white' }} />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary">
{isMilestone ? t('community.milestoneLabel') : t('community.newDropLabel')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{isMilestone
? t('community.milestoneText', {
flavor: item.flavor_name,
count: item.milestone,
})
: item.flavor_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(item.created_at)}
</Typography>
</Box>
<Link to={`/flavor/${item.flavor_id}`} style={{ flexShrink: 0 }}>
<FlavorThumb src={item.flavor_image} name={item.flavor_name} size={56} radius={1} />
</Link>
</Box>
</CardContent>
</GlassCard>
);
};
const subtitle =
tab === 'discover' ? t('community.discoverSubtitle') : t('community.subtitle');
tab === 'discover'
? t('community.discoverSubtitle')
: tab === 'activity'
? t('community.activitySubtitle')
: t('community.subtitle');
return (
<PageShell hero={<HeroBackdrop variant="minimal" />}>
@@ -357,7 +552,7 @@ const CommunityFeed: React.FC = () => {
<Tabs
value={tab}
onChange={(_, v: FeedTab) => setTab(v)}
onChange={(_, v: FeedTab) => handleTabChange(v)}
sx={{ mb: 3, '& .MuiTab-root': { textTransform: 'none', fontWeight: 700 } }}
>
<Tab
@@ -372,11 +567,94 @@ const CommunityFeed: React.FC = () => {
iconPosition="start"
label={t('community.tabDiscover')}
/>
<Tab
value="activity"
icon={<BoltIcon fontSize="small" />}
iconPosition="start"
label={t('community.tabActivity')}
/>
</Tabs>
{tab === 'discover' && (
<Box
sx={{
position: 'sticky',
top: 8,
zIndex: 2,
mb: 3,
py: 1,
display: 'flex',
flexWrap: 'wrap',
gap: 1,
alignItems: 'center',
}}
>
<Stack direction="row" spacing={1}>
<Chip
label={t('community.sortRecent')}
color={discoverSort === 'recent' ? 'primary' : 'default'}
variant={discoverSort === 'recent' ? 'filled' : 'outlined'}
onClick={() => handleSortChange('recent')}
/>
<Chip
label={t('community.sortTop')}
color={discoverSort === 'top' ? 'primary' : 'default'}
variant={discoverSort === 'top' ? 'filled' : 'outlined'}
onClick={() => handleSortChange('top')}
/>
</Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip
label={t('community.allCategories')}
color={discoverCategory === null ? 'secondary' : 'default'}
variant={discoverCategory === null ? 'filled' : 'outlined'}
onClick={() => handleCategoryChange(null)}
/>
{categories.map((cat) => (
<Chip
key={cat.slug}
label={t(`categories.${cat.slug}`, { defaultValue: cat.name })}
color={discoverCategory === cat.slug ? 'secondary' : 'default'}
variant={discoverCategory === cat.slug ? 'filled' : 'outlined'}
onClick={() => handleCategoryChange(cat.slug)}
/>
))}
</Box>
</Box>
)}
<Grid container spacing={4}>
<Grid size={{ xs: 12, lg: 8 }}>
{loading && ratings.length === 0 ? (
{tab === 'activity' ? (
loading && activityItems.length === 0 ? (
<FeedSkeleton />
) : activityItems.length === 0 ? (
<EmptyState
title={t('community.activityEmpty')}
subtitle={t('community.activityEmptyHint')}
action={
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
{t('home.exploreFlavors')}
</Button>
}
/>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{activityItems.map(renderActivityCard)}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box>
)}
</Box>
)
) : loading && ratings.length === 0 ? (
<FeedSkeleton />
) : ratings.length === 0 ? (
tab === 'following' ? (
@@ -387,7 +665,7 @@ const CommunityFeed: React.FC = () => {
<Button
variant="contained"
startIcon={<ExploreIcon />}
onClick={() => setTab('discover')}
onClick={() => handleTabChange('discover')}
sx={{ borderRadius: 2 }}
>
{t('community.browseDiscover')}
@@ -408,7 +686,7 @@ const CommunityFeed: React.FC = () => {
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{ratings.map(renderCard)}
{tab === 'following' && totalPages > 1 && (
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={totalPages}
@@ -424,6 +702,7 @@ const CommunityFeed: React.FC = () => {
<Grid size={{ xs: 12, lg: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<SuggestedFollows />
<SidebarCard
icon={<WhatshotIcon color="error" fontSize="small" />}
title={t('community.topRated')}