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 { UserAvatar } from "@/components/user-avatar";
|
||||||
import { ThoughtCard } from "@/components/thought-card";
|
import { ThoughtCard } from "@/components/thought-card";
|
||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { FollowButton } from "@/components/follow-button";
|
||||||
|
|
||||||
interface ProfilePageProps {
|
interface ProfilePageProps {
|
||||||
params: { username: string };
|
params: { username: string };
|
||||||
@@ -14,22 +15,34 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
const { username } = params;
|
const { username } = params;
|
||||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
// Fetch data directly on the server.
|
// Fetch data in parallel
|
||||||
// The `loading.tsx` file will be shown to the user during this fetch.
|
const userProfilePromise = getUserProfile(username, token);
|
||||||
const [userResult, thoughtsResult] = await Promise.allSettled([
|
const thoughtsPromise = getUserThoughts(username, token);
|
||||||
getUserProfile(username, token),
|
// Fetch the logged-in user's data (if they exist)
|
||||||
getUserThoughts(username, token),
|
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 (userResult.status === "rejected") {
|
||||||
// If the user isn't found, render the Next.js 404 page
|
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userResult.value;
|
const user = userResult.value;
|
||||||
const thoughts =
|
const thoughts =
|
||||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -47,19 +60,31 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container mx-auto max-w-3xl p-4 -mt-16">
|
<main className="container mx-auto max-w-3xl p-4 -mt-16">
|
||||||
{/* Profile Info */}
|
|
||||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||||
<div className="flex items-end gap-4">
|
<div className="flex justify-between items-start">
|
||||||
<div className="w-24 h-24 rounded-full border-4 border-background">
|
<div className="flex items-end gap-4">
|
||||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||||
</div>
|
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||||
<div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">
|
<div>
|
||||||
{user.displayName || user.username}
|
<h1 className="text-2xl font-bold">
|
||||||
</h1>
|
{user.displayName || user.username}
|
||||||
<p className="text-sm text-muted-foreground">@{user.username}</p>
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
@{user.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Render the FollowButton if it's not the user's own profile */}
|
||||||
|
{!isOwnProfile && token && (
|
||||||
|
<FollowButton
|
||||||
|
username={user.username}
|
||||||
|
isInitiallyFollowing={isFollowing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 whitespace-pre-wrap">{user.bio}</p>
|
<p className="mt-4 whitespace-pre-wrap">{user.bio}</p>
|
||||||
<div className="flex items-center gap-2 mt-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 mt-4 text-sm text-muted-foreground">
|
||||||
<Calendar className="h-4 w-4" />
|
<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(),
|
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({
|
export const ThoughtSchema = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
authorUsername: z.string(),
|
authorUsername: z.string(),
|
||||||
@@ -39,6 +52,7 @@ export const CreateThoughtSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type User = z.infer<typeof UserSchema>;
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
export type Me = z.infer<typeof MeSchema>;
|
||||||
export type Thought = z.infer<typeof ThoughtSchema>;
|
export type Thought = z.infer<typeof ThoughtSchema>;
|
||||||
export type Register = z.infer<typeof RegisterSchema>;
|
export type Register = z.infer<typeof RegisterSchema>;
|
||||||
export type Login = z.infer<typeof LoginSchema>;
|
export type Login = z.infer<typeof LoginSchema>;
|
||||||
@@ -121,4 +135,23 @@ export const createThought = (
|
|||||||
},
|
},
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
token
|
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