Compare commits
6 Commits
8b82a5e48e
...
c520690f1e
Author | SHA1 | Date | |
---|---|---|---|
c520690f1e | |||
8ddbf45a09 | |||
dc92945962 | |||
bf7c6501c6 | |||
85e3425d4b | |||
5344e0d6a8 |
@@ -16,7 +16,7 @@ use sea_orm::prelude::Uuid;
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/me/api-keys",
|
path = "",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
||||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
@@ -36,7 +36,7 @@ async fn get_keys(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/me/api-keys",
|
path = "",
|
||||||
request_body = ApiKeyRequest,
|
request_body = ApiKeyRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 201, description = "API key created", body = ApiKeyResponse),
|
(status = 201, description = "API key created", body = ApiKeyResponse),
|
||||||
@@ -63,7 +63,7 @@ async fn create_key(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/me/api-keys/{key_id}",
|
path = "/{key_id}",
|
||||||
responses(
|
responses(
|
||||||
(status = 204, description = "API key deleted"),
|
(status = 204, description = "API key deleted"),
|
||||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
24
thoughts-backend/api/src/routers/friends.rs
Normal file
24
thoughts-backend/api/src/routers/friends.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
use app::{persistence::user, state::AppState};
|
||||||
|
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||||
|
use models::schemas::user::UserListSchema;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of authenticated user's friends", body = UserListSchema)
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
async fn get_friends_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let friends = user::get_friends(&state.conn, auth_user.id).await?;
|
||||||
|
Ok(Json(UserListSchema::from(friends)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_friends_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(get_friends_list))
|
||||||
|
}
|
@@ -3,6 +3,7 @@ use axum::Router;
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
pub mod friends;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
@@ -28,6 +29,7 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
.nest("/thoughts", create_thought_router())
|
.nest("/thoughts", create_thought_router())
|
||||||
.nest("/feed", create_feed_router())
|
.nest("/feed", create_feed_router())
|
||||||
.nest("/tags", tag::create_tag_router())
|
.nest("/tags", tag::create_tag_router())
|
||||||
|
.nest("/friends", friends::create_friends_router())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,11 +16,40 @@ use sea_orm::prelude::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
extractor::{AuthUser, Json, Valid},
|
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
|
||||||
federation,
|
federation,
|
||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought found", body = ThoughtSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_by_id(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thought = get_thought(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let author = app::persistence::user::get_user(&state.conn, thought.author_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||||
|
Ok(Json(schema))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "",
|
path = "",
|
||||||
@@ -77,7 +106,7 @@ async fn thoughts_delete(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let thought = get_thought(&state.conn, id)
|
let thought = get_thought(&state.conn, id, Some(auth_user.id))
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
@@ -92,5 +121,5 @@ async fn thoughts_delete(
|
|||||||
pub fn create_thought_router() -> Router<AppState> {
|
pub fn create_thought_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(thoughts_post))
|
.route("/", post(thoughts_post))
|
||||||
.route("/{id}", delete(thoughts_delete))
|
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
||||||
}
|
}
|
||||||
|
@@ -45,8 +45,36 @@ pub async fn create_thought(
|
|||||||
Ok(new_thought)
|
Ok(new_thought)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result<Option<thought::Model>, DbErr> {
|
pub async fn get_thought(
|
||||||
thought::Entity::find_by_id(thought_id).one(db).await
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<thought::Model>, DbErr> {
|
||||||
|
let thought = thought::Entity::find_by_id(thought_id).one(db).await?;
|
||||||
|
|
||||||
|
match thought {
|
||||||
|
Some(t) => {
|
||||||
|
if t.visibility == thought::Visibility::Public {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
if t.author_id == viewer {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.visibility == thought::Visibility::FriendsOnly {
|
||||||
|
let author_friends = follow::get_friend_ids(db, t.author_id).await?;
|
||||||
|
if author_friends.contains(&viewer) {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
|
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
|
||||||
|
@@ -9,7 +9,7 @@ use models::params::user::{CreateUserParams, UpdateUserParams};
|
|||||||
use models::queries::user::UserQuery;
|
use models::queries::user::UserQuery;
|
||||||
|
|
||||||
use crate::error::UserError;
|
use crate::error::UserError;
|
||||||
use crate::persistence::follow::{get_follower_ids, get_following_ids};
|
use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids};
|
||||||
|
|
||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
@@ -132,13 +132,24 @@ pub async fn update_user_profile(
|
|||||||
|
|
||||||
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
user::Entity::find()
|
user::Entity::find()
|
||||||
.join(JoinType::InnerJoin, top_friends::Relation::User.def().rev())
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
top_friends::Relation::Friend.def().rev(),
|
||||||
|
)
|
||||||
.filter(top_friends::Column::UserId.eq(user_id))
|
.filter(top_friends::Column::UserId.eq(user_id))
|
||||||
.order_by_asc(top_friends::Column::Position)
|
.order_by_asc(top_friends::Column::Position)
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let friend_ids = get_friend_ids(db, user_id).await?;
|
||||||
|
if friend_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
get_users_by_ids(db, friend_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
let following_ids = get_following_ids(db, user_id).await?;
|
let following_ids = get_following_ids(db, user_id).await?;
|
||||||
if following_ids.is_empty() {
|
if following_ids.is_empty() {
|
||||||
|
12
thoughts-backend/doc/src/friends.rs
Normal file
12
thoughts-backend/doc/src/friends.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||||
|
use api::routers::friends::*;
|
||||||
|
use models::schemas::user::{UserListSchema, UserSchema};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(get_friends_list,),
|
||||||
|
components(schemas(UserListSchema, ApiErrorResponse, ParamsErrorResponse, UserSchema))
|
||||||
|
)]
|
||||||
|
pub(super) struct FriendsApi;
|
@@ -9,6 +9,7 @@ use utoipa_swagger_ui::SwaggerUi;
|
|||||||
mod api_key;
|
mod api_key;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
|
mod friends;
|
||||||
mod root;
|
mod root;
|
||||||
mod tag;
|
mod tag;
|
||||||
mod thought;
|
mod thought;
|
||||||
@@ -24,6 +25,7 @@ mod user;
|
|||||||
(path = "/thoughts", api = thought::ThoughtApi),
|
(path = "/thoughts", api = thought::ThoughtApi),
|
||||||
(path = "/feed", api = feed::FeedApi),
|
(path = "/feed", api = feed::FeedApi),
|
||||||
(path = "/tags", api = tag::TagApi),
|
(path = "/tags", api = tag::TagApi),
|
||||||
|
(path = "/friends", api = friends::FriendsApi),
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "root", description = "Root API"),
|
(name = "root", description = "Root API"),
|
||||||
@@ -32,6 +34,7 @@ mod user;
|
|||||||
(name = "thought", description = "Thoughts API"),
|
(name = "thought", description = "Thoughts API"),
|
||||||
(name = "feed", description = "Feed API"),
|
(name = "feed", description = "Feed API"),
|
||||||
(name = "tag", description = "Tag Discovery API"),
|
(name = "tag", description = "Tag Discovery API"),
|
||||||
|
(name = "friends", description = "Friends API"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
|
@@ -7,7 +7,7 @@ use utoipa::OpenApi;
|
|||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(thoughts_post, thoughts_delete),
|
paths(thoughts_post, thoughts_delete, get_thought_by_id),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateThoughtParams,
|
CreateThoughtParams,
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
|
@@ -122,3 +122,41 @@ async fn test_follow_lists() {
|
|||||||
assert_eq!(v["following"].as_array().unwrap().len(), 1);
|
assert_eq!(v["following"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(v["following"][0]["username"], "userB");
|
assert_eq!(v["following"][0]["username"], "userB");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_friends_list() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
|
||||||
|
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
|
||||||
|
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
|
||||||
|
|
||||||
|
// --- Create relationships ---
|
||||||
|
// A and B are friends (reciprocal follow)
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_b.id, user_a.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A follows C, but C does not follow A back
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_c.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// --- Test as user_a ---
|
||||||
|
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/friends", "GET", None, &jwt_a).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let friends_list = v["users"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(friends_list.len(), 1, "User A should only have one friend");
|
||||||
|
assert_eq!(
|
||||||
|
friends_list[0]["username"], "userB",
|
||||||
|
"User B should be in User A's friend list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -69,7 +69,7 @@ async fn test_thought_replies() {
|
|||||||
// 2. User 2 replies to the original thought
|
// 2. User 2 replies to the original thought
|
||||||
let reply_body = json!({
|
let reply_body = json!({
|
||||||
"content": "This is a reply.",
|
"content": "This is a reply.",
|
||||||
"reply_to_id": original_thought_id
|
"replyToId": original_thought_id
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let response =
|
let response =
|
||||||
@@ -163,3 +163,89 @@ async fn test_thought_visibility() {
|
|||||||
"Unauthenticated guest should see only public posts"
|
"Unauthenticated guest should see only public posts"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn post_thought_and_get_id(
|
||||||
|
router: &Router,
|
||||||
|
content: &str,
|
||||||
|
visibility: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> String {
|
||||||
|
let body = json!({ "content": content, "visibility": visibility }).to_string();
|
||||||
|
let response = make_jwt_request(router.clone(), "/thoughts", "POST", Some(body), token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
v["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_by_id_visibility() {
|
||||||
|
let app = setup().await;
|
||||||
|
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
|
||||||
|
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
|
||||||
|
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
|
||||||
|
|
||||||
|
// Make author and friend follow each other
|
||||||
|
follow::follow_user(&app.db, author.id, friend.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
follow::follow_user(&app.db, friend.id, author.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
|
||||||
|
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
|
||||||
|
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
|
||||||
|
|
||||||
|
// Author posts one of each visibility
|
||||||
|
let public_id = post_thought_and_get_id(&app.router, "public", "Public", &author_jwt).await;
|
||||||
|
let friends_id =
|
||||||
|
post_thought_and_get_id(&app.router, "friends", "FriendsOnly", &author_jwt).await;
|
||||||
|
let private_id = post_thought_and_get_id(&app.router, "private", "Private", &author_jwt).await;
|
||||||
|
|
||||||
|
// --- Test Assertions ---
|
||||||
|
|
||||||
|
// 1. Public thought
|
||||||
|
let public_url = format!("/thoughts/{}", public_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_get_request(app.router.clone(), &public_url, None)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Guest should see public thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Friends-only thought
|
||||||
|
let friends_url = format!("/thoughts/{}", friends_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &friend_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Friend should see friends-only thought"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &stranger_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Stranger should NOT see friends-only thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Private thought
|
||||||
|
let private_url = format!("/thoughts/{}", private_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &private_url, "GET", None, &author_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Author should see their private thought"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &private_url, "GET", None, &friend_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Friend should NOT see private thought"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -90,9 +90,9 @@ async fn test_me_endpoints() {
|
|||||||
|
|
||||||
// 4. PUT /users/me to update the profile
|
// 4. PUT /users/me to update the profile
|
||||||
let update_body = json!({
|
let update_body = json!({
|
||||||
"display_name": "Me User",
|
"displayName": "Me User",
|
||||||
"bio": "This is my updated bio.",
|
"bio": "This is my updated bio.",
|
||||||
"avatar_url": "https://example.com/avatar.png"
|
"avatarUrl": "https://example.com/avatar.png"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let response = make_jwt_request(
|
let response = make_jwt_request(
|
||||||
@@ -137,7 +137,7 @@ async fn test_update_me_top_friends() {
|
|||||||
|
|
||||||
// 3. Update profile to set top friends
|
// 3. Update profile to set top friends
|
||||||
let update_body = json!({
|
let update_body = json!({
|
||||||
"top_friends": ["friend1", "friend2"]
|
"topFriends": ["friend1", "friend2"]
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ async fn test_update_me_top_friends() {
|
|||||||
|
|
||||||
// 5. Update again with a different list to test replacement
|
// 5. Update again with a different list to test replacement
|
||||||
let update_body_2 = json!({
|
let update_body_2 = json!({
|
||||||
"top_friends": ["friend2"]
|
"topFriends": ["friend2"]
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ async fn test_update_me_css_and_images() {
|
|||||||
|
|
||||||
// 2. Attempt to update with an invalid avatar URL
|
// 2. Attempt to update with an invalid avatar URL
|
||||||
let invalid_body = json!({
|
let invalid_body = json!({
|
||||||
"avatar_url": "not-a-valid-url"
|
"avatarUrl": "not-a-valid-url"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -219,9 +219,9 @@ async fn test_update_me_css_and_images() {
|
|||||||
|
|
||||||
// 3. Update profile with valid URLs and custom CSS
|
// 3. Update profile with valid URLs and custom CSS
|
||||||
let valid_body = json!({
|
let valid_body = json!({
|
||||||
"avatar_url": "https://example.com/new-avatar.png",
|
"avatarUrl": "https://example.com/new-avatar.png",
|
||||||
"header_url": "https://example.com/new-header.jpg",
|
"headerUrl": "https://example.com/new-header.jpg",
|
||||||
"custom_css": "body { color: blue; }"
|
"customCss": "body { color: blue; }"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
@@ -16,8 +16,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Thoughts",
|
||||||
description: "Generated by create next app",
|
description: "A social network for sharing thoughts",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
@@ -1,15 +1,13 @@
|
|||||||
// app/page.tsx
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getFeed, getMe, getUserProfile, Me, Thought } from "@/lib/api";
|
import { getFeed, getMe, getUserProfile, Me, User } from "@/lib/api";
|
||||||
import { ThoughtCard } from "@/components/thought-card";
|
|
||||||
import { PostThoughtForm } from "@/components/post-thought-form";
|
import { PostThoughtForm } from "@/components/post-thought-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PopularTags } from "@/components/popular-tags";
|
import { PopularTags } from "@/components/popular-tags";
|
||||||
import { ThoughtThread } from "@/components/thought-thread";
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
import { buildThoughtThreads } from "@/lib/utils";
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { TopFriends } from "@/components/top-friends";
|
||||||
|
|
||||||
// This is now an async Server Component
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
@@ -21,50 +19,66 @@ export default async function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function FeedPage({ token }: { token: string }) {
|
async function FeedPage({ token }: { token: string }) {
|
||||||
const feedData = await getFeed(token);
|
const [feedData, me] = await Promise.all([
|
||||||
const me = (await getMe(token).catch(() => null)) as Me | null;
|
getFeed(token),
|
||||||
|
getMe(token).catch(() => null) as Promise<Me | null>,
|
||||||
|
]);
|
||||||
|
|
||||||
const authors = [...new Set(feedData.thoughts.map((t) => t.authorUsername))];
|
const authors = [...new Set(feedData.thoughts.map((t) => t.authorUsername))];
|
||||||
const userProfiles = await Promise.all(
|
const userProfiles = await Promise.all(
|
||||||
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||||
);
|
);
|
||||||
|
|
||||||
const authorDetails = new Map(
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||||
userProfiles
|
userProfiles
|
||||||
.filter(Boolean)
|
.filter((u): u is User => !!u)
|
||||||
.map((user) => [user!.username, { avatarUrl: user!.avatarUrl }])
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
);
|
);
|
||||||
|
|
||||||
const allThoughts = feedData.thoughts;
|
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(
|
||||||
const { topLevelThoughts, repliesByParentId } =
|
feedData.thoughts
|
||||||
buildThoughtThreads(allThoughts);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl p-4 sm:p-6 grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||||
<main className="md:col-span-2 space-y-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
<header className="my-6">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<h1 className="text-3xl font-bold">Your Feed</h1>
|
<div className="sticky top-20 space-y-6">
|
||||||
</header>
|
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||||||
<PostThoughtForm />
|
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
||||||
<main className="space-y-6">
|
</div>
|
||||||
{topLevelThoughts.map((thought) => (
|
</aside>
|
||||||
<ThoughtThread
|
|
||||||
key={thought.id}
|
<main className="col-span-1 lg:col-span-2 space-y-6">
|
||||||
thought={thought}
|
<header className="mb-6">
|
||||||
repliesByParentId={repliesByParentId}
|
<h1 className="text-3xl font-bold">Your Feed</h1>
|
||||||
authorDetails={authorDetails}
|
</header>
|
||||||
currentUser={me}
|
<PostThoughtForm />
|
||||||
/>
|
<div className="space-y-6">
|
||||||
))}
|
{topLevelThoughts.map((thought) => (
|
||||||
{topLevelThoughts.length === 0 && (
|
<ThoughtThread
|
||||||
<p className="text-center text-muted-foreground pt-8">
|
key={thought.id}
|
||||||
Your feed is empty. Follow some users to see their thoughts here!
|
thought={thought}
|
||||||
</p>
|
repliesByParentId={repliesByParentId}
|
||||||
)}
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{topLevelThoughts.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
Your feed is empty. Follow some users to see their thoughts!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</main>
|
|
||||||
<aside className="md:col-span-1 space-y-6 pt-20">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<PopularTags />
|
<div className="sticky top-20 space-y-6">
|
||||||
</aside>
|
{me?.topFriends && <TopFriends usernames={me.topFriends} />}
|
||||||
|
<PopularTags />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
35
thoughts-frontend/app/settings/layout.tsx
Normal file
35
thoughts-frontend/app/settings/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// app/settings/layout.tsx
|
||||||
|
import { SettingsNav } from "@/components/settings-nav";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
const sidebarNavItems = [
|
||||||
|
{
|
||||||
|
title: "Profile",
|
||||||
|
href: "/settings/profile",
|
||||||
|
},
|
||||||
|
// You can add more links here later, e.g., "Account", "API Keys"
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl space-y-6 p-10 pb-16">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your account settings and profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||||
|
<aside className="-mx-4 lg:w-1/5">
|
||||||
|
<SettingsNav items={sidebarNavItems} />
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1 lg:max-w-2xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,15 +1,9 @@
|
|||||||
|
// app/settings/profile/page.tsx
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getMe } from "@/lib/api";
|
import { getMe } from "@/lib/api";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { EditProfileForm } from "@/components/edit-profile-form";
|
import { EditProfileForm } from "@/components/edit-profile-form";
|
||||||
|
|
||||||
// This is a Server Component to fetch initial data
|
|
||||||
export default async function EditProfilePage() {
|
export default async function EditProfilePage() {
|
||||||
const token = (await cookies()).get("auth_token")?.value;
|
const token = (await cookies()).get("auth_token")?.value;
|
||||||
|
|
||||||
@@ -25,15 +19,13 @@ export default async function EditProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
<h3 className="text-lg font-medium">Profile</h3>
|
||||||
<CardTitle>Edit Profile</CardTitle>
|
<p className="text-sm text-muted-foreground">
|
||||||
<CardDescription>
|
This is how others will see you on the site.
|
||||||
Update your public profile information.
|
</p>
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<EditProfileForm currentUser={me} />
|
||||||
<EditProfileForm currentUser={me} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
68
thoughts-frontend/app/tags/[tagName]/page.tsx
Normal file
68
thoughts-frontend/app/tags/[tagName]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// app/tags/[tagName]/page.tsx
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api";
|
||||||
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Hash } from "lucide-react";
|
||||||
|
|
||||||
|
interface TagPageProps {
|
||||||
|
params: { tagName: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TagPage({ params }: TagPageProps) {
|
||||||
|
const { tagName } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const [thoughtsResult, meResult] = await Promise.allSettled([
|
||||||
|
getThoughtsByTag(tagName, token),
|
||||||
|
token ? getMe(token) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (thoughtsResult.status === "rejected") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allThoughts = thoughtsResult.value.thoughts;
|
||||||
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
|
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||||
|
const userProfiles = await Promise.all(
|
||||||
|
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||||
|
);
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||||
|
userProfiles
|
||||||
|
.filter((u): u is User => !!u)
|
||||||
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { topLevelThoughts, repliesByParentId } =
|
||||||
|
buildThoughtThreads(allThoughts);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="flex items-center gap-2 text-3xl font-bold">
|
||||||
|
<Hash className="h-7 w-7" />
|
||||||
|
{tagName}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<main className="space-y-6">
|
||||||
|
{topLevelThoughts.map((thought) => (
|
||||||
|
<ThoughtThread
|
||||||
|
key={thought.id}
|
||||||
|
thought={thought}
|
||||||
|
repliesByParentId={repliesByParentId}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{topLevelThoughts.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No thoughts found for this tag.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
85
thoughts-frontend/app/thoughts/[thoughtId]/page.tsx
Normal file
85
thoughts-frontend/app/thoughts/[thoughtId]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import {
|
||||||
|
getThoughtById,
|
||||||
|
getUserThoughts,
|
||||||
|
getUserProfile,
|
||||||
|
getMe,
|
||||||
|
Me,
|
||||||
|
Thought,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
interface ThoughtPageProps {
|
||||||
|
params: { thoughtId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findConversationRoot(
|
||||||
|
startThought: Thought,
|
||||||
|
token: string | null
|
||||||
|
): Promise<Thought> {
|
||||||
|
let currentThought = startThought;
|
||||||
|
while (currentThought.replyToId) {
|
||||||
|
const parentThought = await getThoughtById(
|
||||||
|
currentThought.replyToId,
|
||||||
|
token
|
||||||
|
).catch(() => null);
|
||||||
|
if (!parentThought) break;
|
||||||
|
currentThought = parentThought;
|
||||||
|
}
|
||||||
|
return currentThought;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
||||||
|
const { thoughtId } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const initialThought = await getThoughtById(thoughtId, token).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!initialThought) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootThought = await findConversationRoot(initialThought, token);
|
||||||
|
|
||||||
|
const [thoughtsResult, meResult] = await Promise.allSettled([
|
||||||
|
getUserThoughts(rootThought.authorUsername, token),
|
||||||
|
token ? getMe(token) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (thoughtsResult.status === "rejected") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allThoughts = thoughtsResult.value.thoughts;
|
||||||
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
|
const author = await getUserProfile(rootThought.authorUsername, token).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
|
if (author) {
|
||||||
|
authorDetails.set(author.username, { avatarUrl: author.avatarUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repliesByParentId } = buildThoughtThreads(allThoughts);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Thoughts</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<ThoughtThread
|
||||||
|
thought={rootThought}
|
||||||
|
repliesByParentId={repliesByParentId}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
thoughts-frontend/app/users/[username]/followers/page.tsx
Normal file
33
thoughts-frontend/app/users/[username]/followers/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getFollowersList } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
|
||||||
|
interface FollowersPageProps {
|
||||||
|
params: { username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||||
|
const { username } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const followersData = await getFollowersList(username, token).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!followersData) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Followers</h1>
|
||||||
|
<p className="text-muted-foreground">Users following @{username}.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<UserListCard users={followersData.users} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
thoughts-frontend/app/users/[username]/following/page.tsx
Normal file
33
thoughts-frontend/app/users/[username]/following/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getFollowingList } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
|
||||||
|
interface FollowingPageProps {
|
||||||
|
params: { username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||||
|
const { username } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const followingData = await getFollowingList(username, token).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!followingData) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Following</h1>
|
||||||
|
<p className="text-muted-foreground">Users that @{username} follows.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<UserListCard users={followingData.users} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,4 +1,11 @@
|
|||||||
import { getMe, getUserProfile, getUserThoughts, Me } from "@/lib/api";
|
import {
|
||||||
|
getFollowersList,
|
||||||
|
getFollowingList,
|
||||||
|
getMe,
|
||||||
|
getUserProfile,
|
||||||
|
getUserThoughts,
|
||||||
|
Me,
|
||||||
|
} from "@/lib/api";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
import { Calendar, Settings } from "lucide-react";
|
import { Calendar, Settings } from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -22,11 +29,21 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
const userProfilePromise = getUserProfile(username, token);
|
const userProfilePromise = getUserProfile(username, token);
|
||||||
const thoughtsPromise = getUserThoughts(username, token);
|
const thoughtsPromise = getUserThoughts(username, token);
|
||||||
const mePromise = token ? getMe(token) : Promise.resolve(null);
|
const mePromise = token ? getMe(token) : Promise.resolve(null);
|
||||||
|
const followersPromise = getFollowersList(username, token);
|
||||||
|
const followingPromise = getFollowingList(username, token);
|
||||||
|
|
||||||
const [userResult, thoughtsResult, meResult] = await Promise.allSettled([
|
const [
|
||||||
|
userResult,
|
||||||
|
thoughtsResult,
|
||||||
|
meResult,
|
||||||
|
followersResult,
|
||||||
|
followingResult,
|
||||||
|
] = await Promise.allSettled([
|
||||||
userProfilePromise,
|
userProfilePromise,
|
||||||
thoughtsPromise,
|
thoughtsPromise,
|
||||||
mePromise,
|
mePromise,
|
||||||
|
followersPromise,
|
||||||
|
followingPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (userResult.status === "rejected") {
|
if (userResult.status === "rejected") {
|
||||||
@@ -40,6 +57,15 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
|
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
|
||||||
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(thoughts);
|
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(thoughts);
|
||||||
|
|
||||||
|
const followersCount =
|
||||||
|
followersResult.status === "fulfilled"
|
||||||
|
? followersResult.value.users.length
|
||||||
|
: 0;
|
||||||
|
const followingCount =
|
||||||
|
followingResult.status === "fulfilled"
|
||||||
|
? followingResult.value.users.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
const isOwnProfile = me?.username === user.username;
|
const isOwnProfile = me?.username === user.username;
|
||||||
const isFollowing =
|
const isFollowing =
|
||||||
me?.following?.some(
|
me?.following?.some(
|
||||||
@@ -62,69 +88,97 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container mx-auto max-w-3xl p-4 -mt-16 grid grid-cols-1 md:grid-cols-3 gap-8">
|
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
<aside className="md:col-span-1 space-y-6 pt-24">
|
{/* Left Sidebar (Profile Card & Top Friends) */}
|
||||||
<TopFriends usernames={user.topFriends} />
|
<aside className="col-span-1 lg:col-span-1 space-y-6">
|
||||||
</aside>
|
<div className="sticky top-20 space-y-6">
|
||||||
<div className="md:col-span-2 mt-8 md:mt-0 space-y-4">
|
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex items-end gap-4">
|
||||||
<div className="flex items-end gap-4">
|
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||||
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Action Button */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">
|
{isOwnProfile ? (
|
||||||
{user.displayName || user.username}
|
<Button asChild variant="outline" size="sm">
|
||||||
</h1>
|
<Link href="/settings/profile">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Settings className="mr-2 h-4 w-4" /> Edit
|
||||||
@{user.username}
|
</Link>
|
||||||
</p>
|
</Button>
|
||||||
|
) : token ? (
|
||||||
|
<FollowButton
|
||||||
|
username={user.username}
|
||||||
|
isInitiallyFollowing={isFollowing}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="mt-4">
|
||||||
{isOwnProfile ? (
|
<h1 className="text-2xl font-bold">
|
||||||
<Button asChild variant="outline">
|
{user.displayName || user.username}
|
||||||
<Link href="/settings/profile">
|
</h1>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<p className="text-sm text-muted-foreground">
|
||||||
Settings
|
@{user.username}
|
||||||
</Link>
|
</p>
|
||||||
</Button>
|
|
||||||
) : token ? (
|
|
||||||
<FollowButton
|
|
||||||
username={user.username}
|
|
||||||
isInitiallyFollowing={isFollowing}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-4 whitespace-pre-wrap">{user.bio}</p>
|
<p className="mt-4 text-sm whitespace-pre-wrap">{user.bio}</p>
|
||||||
<div className="flex items-center gap-2 mt-4 text-sm text-muted-foreground">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
<span>Joined {new Date(user.joinedAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Thoughts Feed */}
|
{isOwnProfile && (
|
||||||
<div className="mt-8 space-y-4">
|
<div className="flex items-center gap-4 mt-4 text-sm">
|
||||||
{topLevelThoughts.map((thought) => (
|
<Link
|
||||||
<ThoughtThread
|
href={`/users/${user.username}/following`}
|
||||||
key={thought.id}
|
className="hover:underline"
|
||||||
thought={thought}
|
>
|
||||||
repliesByParentId={repliesByParentId}
|
<span className="font-bold">{followingCount}</span>
|
||||||
authorDetails={authorDetails}
|
<span className="text-muted-foreground ml-1">
|
||||||
currentUser={me}
|
Following
|
||||||
/>
|
</span>
|
||||||
))}
|
</Link>
|
||||||
{topLevelThoughts.length === 0 && (
|
<Link
|
||||||
<p className="text-center text-muted-foreground pt-8">
|
href={`/users/${user.username}/followers`}
|
||||||
Your feed is empty. Follow some users to see their thoughts
|
className="hover:underline"
|
||||||
here!
|
>
|
||||||
</p>
|
<span className="font-bold">{followersCount}</span>
|
||||||
)}
|
<span className="text-muted-foreground ml-1">
|
||||||
|
Followers
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Joined {new Date(user.joinedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TopFriends usernames={user.topFriends} />
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="col-span-1 lg:col-span-3 space-y-4">
|
||||||
|
{topLevelThoughts.map((thought) => (
|
||||||
|
<ThoughtThread
|
||||||
|
key={thought.id}
|
||||||
|
thought={thought}
|
||||||
|
repliesByParentId={repliesByParentId}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{topLevelThoughts.length === 0 && (
|
||||||
|
<Card className="flex items-center justify-center h-48">
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
This user hasn't posted any public thoughts yet.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { TopFriendsCombobox } from "@/components/top-friends-combobox";
|
||||||
|
|
||||||
interface EditProfileFormProps {
|
interface EditProfileFormProps {
|
||||||
currentUser: Me;
|
currentUser: Me;
|
||||||
@@ -47,11 +48,10 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
|||||||
try {
|
try {
|
||||||
await updateProfile(values, token);
|
await updateProfile(values, token);
|
||||||
toast.success("Profile updated successfully!");
|
toast.success("Profile updated successfully!");
|
||||||
// Redirect to the profile page to see the changes
|
|
||||||
router.push(`/users/${currentUser.username}`);
|
router.push(`/users/${currentUser.username}`);
|
||||||
router.refresh(); // Ensure fresh data is loaded
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Failed to update profile.");
|
toast.error(`Failed to update profile. ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,21 +139,16 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
|||||||
name="topFriends"
|
name="topFriends"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="flex flex-col">
|
||||||
<FormLabel>Top Friends</FormLabel>
|
<FormLabel>Top Friends</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<TopFriendsCombobox
|
||||||
placeholder="username1, username2, ..."
|
value={field.value || []}
|
||||||
{...field}
|
onChange={field.onChange}
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(
|
|
||||||
e.target.value.split(",").map((s) => s.trim())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
A comma-separated list of usernames.
|
Select up to 8 of your friends to display on your profile.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@@ -1,37 +1,38 @@
|
|||||||
// components/header.tsx
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { UserNav } from "./user-nav";
|
import { UserNav } from "./user-nav";
|
||||||
|
import { MainNav } from "./main-nav";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container flex h-14 items-center">
|
<div className="w-full flex h-14 items-center px-2">
|
||||||
<div className="mr-4 flex">
|
<div className="flex gap-2">
|
||||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
<Link href="/" className="flex items-center gap-1">
|
||||||
<span className="font-bold">Thoughts</span>
|
<span className="hidden font-bold sm:inline-block">Thoughts</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<MainNav />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-end space-x-4">
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
<nav className="flex items-center space-x-2">
|
<ThemeToggle />
|
||||||
{token ? (
|
{token ? (
|
||||||
<UserNav />
|
<UserNav />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="ghost">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild size="sm">
|
||||||
<Link href="/register">Register</Link>
|
<Link href="/register">Register</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
22
thoughts-frontend/components/main-nav.tsx
Normal file
22
thoughts-frontend/components/main-nav.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function MainNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80",
|
||||||
|
pathname === "/" ? "text-foreground" : "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Feed
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
43
thoughts-frontend/components/settings-nav.tsx
Normal file
43
thoughts-frontend/components/settings-nav.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface SettingsNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
items: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsNav({ className, items, ...props }: SettingsNavProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
"flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
pathname === item.href
|
||||||
|
? "bg-muted hover:bg-muted"
|
||||||
|
: "hover:bg-transparent hover:underline",
|
||||||
|
"justify-start"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
11
thoughts-frontend/components/theme-provider.tsx
Normal file
11
thoughts-frontend/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ThemeProvider as NextThemesProvider,
|
||||||
|
ThemeProviderProps,
|
||||||
|
} from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
40
thoughts-frontend/components/theme-toggle.tsx
Normal file
40
thoughts-frontend/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
@@ -72,6 +72,7 @@ export function ThoughtCard({
|
|||||||
toast.success("Thought deleted successfully.");
|
toast.success("Thought deleted successfully.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Failed to delete thought:", error);
|
||||||
toast.error("Failed to delete thought.");
|
toast.error("Failed to delete thought.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsAlertOpen(false);
|
setIsAlertOpen(false);
|
||||||
@@ -111,14 +112,14 @@ export function ThoughtCard({
|
|||||||
<span className="text-sm text-muted-foreground">{timeAgo}</span>
|
<span className="text-sm text-muted-foreground">{timeAgo}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{isAuthor && (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<button className="p-2 rounded-full hover:bg-accent">
|
||||||
<button className="p-2 rounded-full hover:bg-accent">
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
</button>
|
||||||
</button>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuContent>
|
{isAuthor && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onSelect={() => setIsAlertOpen(true)}
|
onSelect={() => setIsAlertOpen(true)}
|
||||||
@@ -126,24 +127,32 @@ export function ThoughtCard({
|
|||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
)}
|
||||||
</DropdownMenu>
|
<DropdownMenuItem>
|
||||||
)}
|
<Link href={`/thoughts/${thought.id}`} className="flex gap-2">
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="whitespace-pre-wrap break-words">{thought.content}</p>
|
<p className="whitespace-pre-wrap break-words">{thought.content}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="border-t px-4 pt-2 pb-2">
|
{token && (
|
||||||
<Button
|
<CardFooter className="border-t px-4 pt-2 pb-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
size="sm"
|
||||||
>
|
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
>
|
||||||
Reply
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Reply
|
||||||
</CardFooter>
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
|
||||||
{isReplyOpen && (
|
{isReplyOpen && (
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4">
|
||||||
|
105
thoughts-frontend/components/top-friends-combobox.tsx
Normal file
105
thoughts-frontend/components/top-friends-combobox.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { getFriends, User } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
|
||||||
|
interface TopFriendsComboboxProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopFriendsCombobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: TopFriendsComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [friends, setFriends] = React.useState<User[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
getFriends(token)
|
||||||
|
.then((data) => setFriends(data.users))
|
||||||
|
.catch(() => console.error("Failed to fetch friends"))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-10 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{value.length > 0
|
||||||
|
? `${value.length} friend(s) selected`
|
||||||
|
: "Select up to 8 friends..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search friends..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No friends found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{friends.map((friend) => (
|
||||||
|
<CommandItem
|
||||||
|
key={friend.id}
|
||||||
|
value={friend.username}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
const newValue = value.includes(currentValue)
|
||||||
|
? value.filter((v) => v !== currentValue)
|
||||||
|
: [...value, currentValue];
|
||||||
|
|
||||||
|
if (newValue.length <= 8) {
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value.includes(friend.username)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{friend.username}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
38
thoughts-frontend/components/user-list-card.tsx
Normal file
38
thoughts-frontend/components/user-list-card.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { User } from "@/lib/api";
|
||||||
|
import { UserAvatar } from "./user-avatar";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
|
||||||
|
interface UserListCardProps {
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserListCard({ users }: UserListCardProps) {
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No users to display.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="divide-y">
|
||||||
|
{users.map((user) => (
|
||||||
|
<Link
|
||||||
|
href={`/users/${user.username}`}
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center gap-4 p-4 -mx-6 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{user.displayName || user.username}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@@ -193,3 +193,43 @@ export const updateProfile = (
|
|||||||
UserSchema, // Expect the updated user object back
|
UserSchema, // Expect the updated user object back
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getThoughtsByTag = (tagName: string, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/tags/${tagName}`,
|
||||||
|
{},
|
||||||
|
z.object({ thoughts: z.array(ThoughtSchema) }),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getThoughtById = (thoughtId: string, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/thoughts/${thoughtId}`,
|
||||||
|
{},
|
||||||
|
ThoughtSchema, // Expect a single thought object
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFollowingList = (username: string, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/users/${username}/following`,
|
||||||
|
{},
|
||||||
|
z.object({ users: z.array(UserSchema) }),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFollowersList = (username: string, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/users/${username}/followers`,
|
||||||
|
{},
|
||||||
|
z.object({ users: z.array(UserSchema) }),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFriends = (token: string) =>
|
||||||
|
apiFetch(
|
||||||
|
"/friends",
|
||||||
|
{},
|
||||||
|
z.object({ users: z.array(UserSchema) }),
|
||||||
|
token
|
||||||
|
);
|
Reference in New Issue
Block a user