mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
community tab redesign
This commit is contained in:
1 parent
1fb318f6a1
commit
2f2935a1cb
18 files changed
+1222
-70
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.**
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in new issue
Block a user