Compare commits

..

6 Commits

31 changed files with 1037 additions and 186 deletions

View File

@@ -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),

View 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))
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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> {

View File

@@ -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() {

View 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;

View File

@@ -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),
)]

View File

@@ -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,

View File

@@ -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"
);
}

View File

@@ -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"
);
}

View File

@@ -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();

View File

@@ -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({

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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&apos;t posted any public thoughts yet.
</p>
</Card>
)}
</div>
</main>
</div>

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View File

@@ -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">

View 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>
);
}

View 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>
);
}

View File

@@ -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
);