From 7348433b9c44554600d5a075cf85cb9920d41f0a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 19:47:29 +0200 Subject: [PATCH] feat: add follow/unfollow functionality with FollowButton component and update user profile to display follow status --- .../app/users/[username]/page.tsx | 61 ++++++++++++----- .../components/follow-button.tsx | 65 +++++++++++++++++++ thoughts-frontend/lib/api.ts | 35 +++++++++- 3 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 thoughts-frontend/components/follow-button.tsx diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index 066dc41..9741626 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -1,10 +1,11 @@ -import { getUserProfile, getUserThoughts } from "@/lib/api"; +import { getMe, getUserProfile, getUserThoughts } from "@/lib/api"; import { UserAvatar } from "@/components/user-avatar"; import { ThoughtCard } from "@/components/thought-card"; import { Calendar } from "lucide-react"; import { Card } from "@/components/ui/card"; import { notFound } from "next/navigation"; import { cookies } from "next/headers"; +import { FollowButton } from "@/components/follow-button"; interface ProfilePageProps { params: { username: string }; @@ -14,22 +15,34 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const { username } = params; const token = (await cookies()).get("auth_token")?.value ?? null; - // Fetch data directly on the server. - // The `loading.tsx` file will be shown to the user during this fetch. - const [userResult, thoughtsResult] = await Promise.allSettled([ - getUserProfile(username, token), - getUserThoughts(username, token), + // Fetch data in parallel + const userProfilePromise = getUserProfile(username, token); + const thoughtsPromise = getUserThoughts(username, token); + // Fetch the logged-in user's data (if they exist) + const mePromise = token ? getMe(token) : Promise.resolve(null); + + const [userResult, thoughtsResult, meResult] = await Promise.allSettled([ + userProfilePromise, + thoughtsPromise, + mePromise, ]); - // Handle errors from the server-side fetch if (userResult.status === "rejected") { - // If the user isn't found, render the Next.js 404 page notFound(); } const user = userResult.value; const thoughts = thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : []; + const me = meResult.status === "fulfilled" ? meResult.value : null; + + // *** SIMPLIFIED LOGIC *** + // The follow status is now directly available from the `me` object. + const isOwnProfile = me?.username === user.username; + const isFollowing = + me?.following?.some( + (followedUser) => followedUser.username === user.username + ) || false; return (
@@ -47,19 +60,31 @@ export default async function ProfilePage({ params }: ProfilePageProps) { />
- {/* Profile Info */} -
-
- -
-
-

- {user.displayName || user.username} -

-

@{user.username}

+
+
+
+ +
+
+

+ {user.displayName || user.username} +

+

+ @{user.username} +

+
+ + {/* Render the FollowButton if it's not the user's own profile */} + {!isOwnProfile && token && ( + + )}
+

{user.bio}

diff --git a/thoughts-frontend/components/follow-button.tsx b/thoughts-frontend/components/follow-button.tsx new file mode 100644 index 0000000..7c85c65 --- /dev/null +++ b/thoughts-frontend/components/follow-button.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; +import { followUser, unfollowUser } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { UserPlus, UserMinus } from "lucide-react"; + +interface FollowButtonProps { + username: string; + isInitiallyFollowing: boolean; +} + +export function FollowButton({ + username, + isInitiallyFollowing, +}: FollowButtonProps) { + const [isFollowing, setIsFollowing] = useState(isInitiallyFollowing); + const [isLoading, setIsLoading] = useState(false); + const { token } = useAuth(); + const router = useRouter(); + + const handleClick = async () => { + if (!token) { + toast.error("You must be logged in to follow users."); + return; + } + + setIsLoading(true); + const action = isFollowing ? unfollowUser : followUser; + + try { + // Optimistic update + setIsFollowing(!isFollowing); + await action(username, token); + router.refresh(); // Re-fetch server component data to get the latest follower count etc. + } catch (err) { + // Revert on error + setIsFollowing(isFollowing); + toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index a4893d9..dabb99a 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -12,6 +12,19 @@ export const UserSchema = z.object({ joinedAt: z.coerce.date(), }); +export const MeSchema = z.object({ + id: z.uuid(), + username: z.string(), + displayName: z.string().nullable(), + bio: z.string().nullable(), + avatarUrl: z.url().nullable(), + headerUrl: z.url().nullable(), + customCss: z.string().nullable(), + topFriends: z.array(z.string()), + joinedAt: z.coerce.date(), + following: z.array(UserSchema), +}); + export const ThoughtSchema = z.object({ id: z.uuid(), authorUsername: z.string(), @@ -39,6 +52,7 @@ export const CreateThoughtSchema = z.object({ }); export type User = z.infer; +export type Me = z.infer; export type Thought = z.infer; export type Register = z.infer; export type Login = z.infer; @@ -121,4 +135,23 @@ export const createThought = ( }, ThoughtSchema, token - ); \ No newline at end of file + ); + + export const followUser = (username: string, token: string) => + apiFetch( + `/users/${username}/follow`, + { method: "POST" }, + z.null(), // Expect a 204 No Content response, which we treat as null + token + ); + +export const unfollowUser = (username: string, token: string) => + apiFetch( + `/users/${username}/follow`, + { method: "DELETE" }, + z.null(), // Expect a 204 No Content response + token + ); + + export const getMe = (token: string) => + apiFetch("/users/me", {}, MeSchema, token); \ No newline at end of file