feat: add follow/unfollow functionality with FollowButton component and update user profile to display follow status
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
@@ -47,19 +60,31 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
/>
|
||||
|
||||
<main className="container mx-auto max-w-3xl p-4 -mt-16">
|
||||
{/* Profile Info */}
|
||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-background">
|
||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{user.displayName || user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">@{user.username}</p>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{user.displayName || user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render the FollowButton if it's not the user's own profile */}
|
||||
{!isOwnProfile && token && (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
isInitiallyFollowing={isFollowing}
|
||||
/>
|
||||
)}
|
||||
</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" />
|
||||
|
65
thoughts-frontend/components/follow-button.tsx
Normal file
65
thoughts-frontend/components/follow-button.tsx
Normal file
@@ -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 (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
variant={isFollowing ? "secondary" : "default"}
|
||||
>
|
||||
{isFollowing ? (
|
||||
<>
|
||||
<UserMinus className="mr-2 h-4 w-4" /> Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Follow
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
@@ -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<typeof UserSchema>;
|
||||
export type Me = z.infer<typeof MeSchema>;
|
||||
export type Thought = z.infer<typeof ThoughtSchema>;
|
||||
export type Register = z.infer<typeof RegisterSchema>;
|
||||
export type Login = z.infer<typeof LoginSchema>;
|
||||
@@ -121,4 +135,23 @@ export const createThought = (
|
||||
},
|
||||
ThoughtSchema,
|
||||
token
|
||||
);
|
||||
);
|
||||
|
||||
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);
|
Reference in New Issue
Block a user