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(
|
||||
get,
|
||||
path = "/me/api-keys",
|
||||
path = "",
|
||||
responses(
|
||||
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||
@@ -36,7 +36,7 @@ async fn get_keys(
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/me/api-keys",
|
||||
path = "",
|
||||
request_body = ApiKeyRequest,
|
||||
responses(
|
||||
(status = 201, description = "API key created", body = ApiKeyResponse),
|
||||
@@ -63,7 +63,7 @@ async fn create_key(
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/me/api-keys/{key_id}",
|
||||
path = "/{key_id}",
|
||||
responses(
|
||||
(status = 204, description = "API key deleted"),
|
||||
(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 auth;
|
||||
pub mod feed;
|
||||
pub mod friends;
|
||||
pub mod root;
|
||||
pub mod tag;
|
||||
pub mod thought;
|
||||
@@ -28,6 +29,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.nest("/thoughts", create_thought_router())
|
||||
.nest("/feed", create_feed_router())
|
||||
.nest("/tags", tag::create_tag_router())
|
||||
.nest("/friends", friends::create_friends_router())
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, post},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
@@ -16,11 +16,40 @@ use sea_orm::prelude::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
extractor::{AuthUser, Json, Valid},
|
||||
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
|
||||
federation,
|
||||
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(
|
||||
post,
|
||||
path = "",
|
||||
@@ -77,7 +106,7 @@ async fn thoughts_delete(
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let thought = get_thought(&state.conn, id)
|
||||
let thought = get_thought(&state.conn, id, Some(auth_user.id))
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
|
||||
@@ -92,5 +121,5 @@ async fn thoughts_delete(
|
||||
pub fn create_thought_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.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)
|
||||
}
|
||||
|
||||
pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result<Option<thought::Model>, DbErr> {
|
||||
thought::Entity::find_by_id(thought_id).one(db).await
|
||||
pub async fn get_thought(
|
||||
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> {
|
||||
|
@@ -9,7 +9,7 @@ use models::params::user::{CreateUserParams, UpdateUserParams};
|
||||
use models::queries::user::UserQuery;
|
||||
|
||||
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(
|
||||
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> {
|
||||
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))
|
||||
.order_by_asc(top_friends::Column::Position)
|
||||
.all(db)
|
||||
.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> {
|
||||
let following_ids = get_following_ids(db, user_id).await?;
|
||||
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 auth;
|
||||
mod feed;
|
||||
mod friends;
|
||||
mod root;
|
||||
mod tag;
|
||||
mod thought;
|
||||
@@ -24,6 +25,7 @@ mod user;
|
||||
(path = "/thoughts", api = thought::ThoughtApi),
|
||||
(path = "/feed", api = feed::FeedApi),
|
||||
(path = "/tags", api = tag::TagApi),
|
||||
(path = "/friends", api = friends::FriendsApi),
|
||||
),
|
||||
tags(
|
||||
(name = "root", description = "Root API"),
|
||||
@@ -32,6 +34,7 @@ mod user;
|
||||
(name = "thought", description = "Thoughts API"),
|
||||
(name = "feed", description = "Feed API"),
|
||||
(name = "tag", description = "Tag Discovery API"),
|
||||
(name = "friends", description = "Friends API"),
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
)]
|
||||
|
@@ -7,7 +7,7 @@ use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(thoughts_post, thoughts_delete),
|
||||
paths(thoughts_post, thoughts_delete, get_thought_by_id),
|
||||
components(schemas(
|
||||
CreateThoughtParams,
|
||||
ThoughtSchema,
|
||||
|
@@ -122,3 +122,41 @@ async fn test_follow_lists() {
|
||||
assert_eq!(v["following"].as_array().unwrap().len(), 1);
|
||||
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
|
||||
let reply_body = json!({
|
||||
"content": "This is a reply.",
|
||||
"reply_to_id": original_thought_id
|
||||
"replyToId": original_thought_id
|
||||
})
|
||||
.to_string();
|
||||
let response =
|
||||
@@ -163,3 +163,89 @@ async fn test_thought_visibility() {
|
||||
"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
|
||||
let update_body = json!({
|
||||
"display_name": "Me User",
|
||||
"displayName": "Me User",
|
||||
"bio": "This is my updated bio.",
|
||||
"avatar_url": "https://example.com/avatar.png"
|
||||
"avatarUrl": "https://example.com/avatar.png"
|
||||
})
|
||||
.to_string();
|
||||
let response = make_jwt_request(
|
||||
@@ -137,7 +137,7 @@ async fn test_update_me_top_friends() {
|
||||
|
||||
// 3. Update profile to set top friends
|
||||
let update_body = json!({
|
||||
"top_friends": ["friend1", "friend2"]
|
||||
"topFriends": ["friend1", "friend2"]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
@@ -166,7 +166,7 @@ async fn test_update_me_top_friends() {
|
||||
|
||||
// 5. Update again with a different list to test replacement
|
||||
let update_body_2 = json!({
|
||||
"top_friends": ["friend2"]
|
||||
"topFriends": ["friend2"]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
@@ -203,7 +203,7 @@ async fn test_update_me_css_and_images() {
|
||||
|
||||
// 2. Attempt to update with an invalid avatar URL
|
||||
let invalid_body = json!({
|
||||
"avatar_url": "not-a-valid-url"
|
||||
"avatarUrl": "not-a-valid-url"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
@@ -219,9 +219,9 @@ async fn test_update_me_css_and_images() {
|
||||
|
||||
// 3. Update profile with valid URLs and custom CSS
|
||||
let valid_body = json!({
|
||||
"avatar_url": "https://example.com/new-avatar.png",
|
||||
"header_url": "https://example.com/new-header.jpg",
|
||||
"custom_css": "body { color: blue; }"
|
||||
"avatarUrl": "https://example.com/new-avatar.png",
|
||||
"headerUrl": "https://example.com/new-header.jpg",
|
||||
"customCss": "body { color: blue; }"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
|
@@ -16,8 +16,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Thoughts",
|
||||
description: "A social network for sharing thoughts",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
@@ -1,15 +1,13 @@
|
||||
// app/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { getFeed, getMe, getUserProfile, Me, Thought } from "@/lib/api";
|
||||
import { ThoughtCard } from "@/components/thought-card";
|
||||
import { getFeed, getMe, getUserProfile, Me, User } from "@/lib/api";
|
||||
import { PostThoughtForm } from "@/components/post-thought-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { PopularTags } from "@/components/popular-tags";
|
||||
import { ThoughtThread } from "@/components/thought-thread";
|
||||
import { buildThoughtThreads } from "@/lib/utils";
|
||||
import { TopFriends } from "@/components/top-friends";
|
||||
|
||||
// This is now an async Server Component
|
||||
export default async function Home() {
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
@@ -21,50 +19,66 @@ export default async function Home() {
|
||||
}
|
||||
|
||||
async function FeedPage({ token }: { token: string }) {
|
||||
const feedData = await getFeed(token);
|
||||
const me = (await getMe(token).catch(() => null)) as Me | null;
|
||||
const [feedData, me] = await Promise.all([
|
||||
getFeed(token),
|
||||
getMe(token).catch(() => null) as Promise<Me | null>,
|
||||
]);
|
||||
|
||||
const authors = [...new Set(feedData.thoughts.map((t) => t.authorUsername))];
|
||||
const userProfiles = await Promise.all(
|
||||
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||
);
|
||||
|
||||
const authorDetails = new Map(
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||
userProfiles
|
||||
.filter(Boolean)
|
||||
.map((user) => [user!.username, { avatarUrl: user!.avatarUrl }])
|
||||
.filter((u): u is User => !!u)
|
||||
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||
);
|
||||
|
||||
const allThoughts = feedData.thoughts;
|
||||
const { topLevelThoughts, repliesByParentId } =
|
||||
buildThoughtThreads(allThoughts);
|
||||
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(
|
||||
feedData.thoughts
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-4 sm:p-6 grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<main className="md:col-span-2 space-y-6">
|
||||
<header className="my-6">
|
||||
<h1 className="text-3xl font-bold">Your Feed</h1>
|
||||
</header>
|
||||
<PostThoughtForm />
|
||||
<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">
|
||||
Your feed is empty. Follow some users to see their thoughts here!
|
||||
</p>
|
||||
)}
|
||||
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<aside className="hidden lg:block lg:col-span-1">
|
||||
<div className="sticky top-20 space-y-6">
|
||||
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||||
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="col-span-1 lg:col-span-2 space-y-6">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Your Feed</h1>
|
||||
</header>
|
||||
<PostThoughtForm />
|
||||
<div 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">
|
||||
Your feed is empty. Follow some users to see their thoughts!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
<aside className="md:col-span-1 space-y-6 pt-20">
|
||||
<PopularTags />
|
||||
</aside>
|
||||
|
||||
<aside className="hidden lg:block lg:col-span-1">
|
||||
<div className="sticky top-20 space-y-6">
|
||||
{me?.topFriends && <TopFriends usernames={me.topFriends} />}
|
||||
<PopularTags />
|
||||
</div>
|
||||
</aside>
|
||||
</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 { redirect } from "next/navigation";
|
||||
import { getMe } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { EditProfileForm } from "@/components/edit-profile-form";
|
||||
|
||||
// This is a Server Component to fetch initial data
|
||||
export default async function EditProfilePage() {
|
||||
const token = (await cookies()).get("auth_token")?.value;
|
||||
|
||||
@@ -25,15 +19,13 @@ export default async function EditProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit Profile</CardTitle>
|
||||
<CardDescription>
|
||||
Update your public profile information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<EditProfileForm currentUser={me} />
|
||||
</Card>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is how others will see you on the site.
|
||||
</p>
|
||||
</div>
|
||||
<EditProfileForm currentUser={me} />
|
||||
</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 { Calendar, Settings } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -22,11 +29,21 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
const userProfilePromise = getUserProfile(username, token);
|
||||
const thoughtsPromise = getUserThoughts(username, token);
|
||||
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,
|
||||
thoughtsPromise,
|
||||
mePromise,
|
||||
followersPromise,
|
||||
followingPromise,
|
||||
]);
|
||||
|
||||
if (userResult.status === "rejected") {
|
||||
@@ -40,6 +57,15 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.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 isFollowing =
|
||||
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">
|
||||
<aside className="md:col-span-1 space-y-6 pt-24">
|
||||
<TopFriends usernames={user.topFriends} />
|
||||
</aside>
|
||||
<div className="md:col-span-2 mt-8 md:mt-0 space-y-4">
|
||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Left Sidebar (Profile Card & Top Friends) */}
|
||||
<aside className="col-span-1 lg:col-span-1 space-y-6">
|
||||
<div className="sticky top-20 space-y-6">
|
||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Button */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{user.displayName || user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@{user.username}
|
||||
</p>
|
||||
{isOwnProfile ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/profile">
|
||||
<Settings className="mr-2 h-4 w-4" /> Edit
|
||||
</Link>
|
||||
</Button>
|
||||
) : token ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
isInitiallyFollowing={isFollowing}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isOwnProfile ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</Button>
|
||||
) : token ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
isInitiallyFollowing={isFollowing}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{user.displayName || user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 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>
|
||||
<p className="mt-4 text-sm whitespace-pre-wrap">{user.bio}</p>
|
||||
|
||||
{/* Thoughts Feed */}
|
||||
<div className="mt-8 space-y-4">
|
||||
{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">
|
||||
Your feed is empty. Follow some users to see their thoughts
|
||||
here!
|
||||
</p>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<div className="flex items-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href={`/users/${user.username}/following`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<span className="font-bold">{followingCount}</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
Following
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/users/${user.username}/followers`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
|
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TopFriendsCombobox } from "@/components/top-friends-combobox";
|
||||
|
||||
interface EditProfileFormProps {
|
||||
currentUser: Me;
|
||||
@@ -47,11 +48,10 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
||||
try {
|
||||
await updateProfile(values, token);
|
||||
toast.success("Profile updated successfully!");
|
||||
// Redirect to the profile page to see the changes
|
||||
router.push(`/users/${currentUser.username}`);
|
||||
router.refresh(); // Ensure fresh data is loaded
|
||||
router.refresh();
|
||||
} 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"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Top Friends</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="username1, username2, ..."
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value.split(",").map((s) => s.trim())
|
||||
)
|
||||
}
|
||||
<TopFriendsCombobox
|
||||
value={field.value || []}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A comma-separated list of usernames.
|
||||
Select up to 8 of your friends to display on your profile.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@@ -1,37 +1,38 @@
|
||||
// components/header.tsx
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserNav } from "./user-nav";
|
||||
import { MainNav } from "./main-nav";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
const { token } = useAuth();
|
||||
|
||||
return (
|
||||
<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="mr-4 flex">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="font-bold">Thoughts</span>
|
||||
<div className="w-full flex h-14 items-center px-2">
|
||||
<div className="flex gap-2">
|
||||
<Link href="/" className="flex items-center gap-1">
|
||||
<span className="hidden font-bold sm:inline-block">Thoughts</span>
|
||||
</Link>
|
||||
<MainNav />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end space-x-4">
|
||||
<nav className="flex items-center space-x-2">
|
||||
{token ? (
|
||||
<UserNav />
|
||||
) : (
|
||||
<>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/register">Register</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||
<ThemeToggle />
|
||||
{token ? (
|
||||
<UserNav />
|
||||
) : (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/register">Register</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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.");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete thought:", error);
|
||||
toast.error("Failed to delete thought.");
|
||||
} finally {
|
||||
setIsAlertOpen(false);
|
||||
@@ -111,14 +112,14 @@ export function ThoughtCard({
|
||||
<span className="text-sm text-muted-foreground">{timeAgo}</span>
|
||||
</div>
|
||||
</Link>
|
||||
{isAuthor && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-2 rounded-full hover:bg-accent">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-2 rounded-full hover:bg-accent">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{isAuthor && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() => setIsAlertOpen(true)}
|
||||
@@ -126,24 +127,32 @@ export function ThoughtCard({
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</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>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap break-words">{thought.content}</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="border-t px-4 pt-2 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Reply
|
||||
</Button>
|
||||
</CardFooter>
|
||||
{token && (
|
||||
<CardFooter className="border-t px-4 pt-2 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Reply
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
{isReplyOpen && (
|
||||
<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>
|
||||
);
|
||||
}
|
@@ -192,4 +192,44 @@ export const updateProfile = (
|
||||
},
|
||||
UserSchema, // Expect the updated user object back
|
||||
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