diff --git a/app/app/api/users/[id]/follow/route.ts b/app/app/api/users/[id]/follow/route.ts index 09664c0..c689921 100644 --- a/app/app/api/users/[id]/follow/route.ts +++ b/app/app/api/users/[id]/follow/route.ts @@ -37,18 +37,40 @@ export async function POST( } }); + let follow = existing; + if (!existing) { - const follow = await prisma.follow.create({ + follow = await prisma.follow.create({ data: { userId: currentUser.id, followingId: targetUserId, } }); checkAndAwardBadges(targetUserId).catch(console.error); - return apiSuccess({ success: true, follow }, undefined, 201); } - return apiSuccess({ success: true, follow: existing }, undefined, 200); + const [followersCount, followingCount] = await Promise.all([ + prisma.follow.count({ + where: { followingId: targetUserId }, + }), + prisma.follow.count({ + where: { userId: targetUserId, followingId: { not: null } }, + }), + ]); + + return apiSuccess( + { + success: true, + isFollowing: true, + follow, + counts: { + followers: followersCount, + following: followingCount, + }, + }, + undefined, + existing ? 200 : 201, + ); } catch (error) { console.error('Failed to follow user:', error); return apiError('Failed to follow user', 500); @@ -78,7 +100,27 @@ export async function DELETE( // Ignore if not found } - return apiSuccess({ success: true }, undefined, 200); + const [followersCount, followingCount] = await Promise.all([ + prisma.follow.count({ + where: { followingId: targetUserId }, + }), + prisma.follow.count({ + where: { userId: targetUserId, followingId: { not: null } }, + }), + ]); + + return apiSuccess( + { + success: true, + isFollowing: false, + counts: { + followers: followersCount, + following: followingCount, + }, + }, + undefined, + 200, + ); } catch (error) { console.error('Failed to unfollow user:', error); return apiError('Failed to unfollow user', 500); diff --git a/app/app/profile/[userId]/page.tsx b/app/app/profile/[userId]/page.tsx index 291bea3..0412ea2 100644 --- a/app/app/profile/[userId]/page.tsx +++ b/app/app/profile/[userId]/page.tsx @@ -16,17 +16,38 @@ import { useAppContext } from '@/contexts/app-context'; import { mapApiPostToClientPost } from '@/lib/map-api-post'; import type { Post } from '@/lib/types'; import { useParams } from 'next/navigation'; +import { toast } from 'sonner'; import { useState, useEffect } from 'react'; +type ProfileUser = { + id: string; + name: string; + username: string | null; + avatarUrl: string | null; + bio: string | null; + badges: Array>; + isFollowing?: boolean; + isVerified?: boolean; + rank?: unknown; + createdAt: string | Date; + _count?: { + followers?: number; + followings?: number; + }; +}; + export default function ProfilePage() { const params = useParams(); const { user: currentUser } = useAppContext(); - const [profileUser, setProfileUser] = useState(null); + const [profileUser, setProfileUser] = useState(null); const [userPosts, setUserPosts] = useState([]); const [showAchievements, setShowAchievements] = useState(false); const [isFollowing, setIsFollowing] = useState(false); const [followerCount, setFollowerCount] = useState(0); const [followingCount, setFollowingCount] = useState(0); + const [isLoadingProfile, setIsLoadingProfile] = useState(true); + const [profileError, setProfileError] = useState(null); + const [isUpdatingFollow, setIsUpdatingFollow] = useState(false); const [showFollowList, setShowFollowList] = useState<{ open: boolean; type: 'followers' | 'following' }>({ open: false, type: 'followers', @@ -42,6 +63,9 @@ export default function ProfilePage() { useEffect(() => { const loadProfile = async () => { + setIsLoadingProfile(true); + setProfileError(null); + try { const [userRes, postsRes] = await Promise.all([ fetch(`/api/users/${userId}`, { cache: 'no-store' }), @@ -54,6 +78,11 @@ export default function ProfilePage() { setIsFollowing(!!userData.data.isFollowing); setFollowerCount(userData.data._count?.followers || 0); setFollowingCount(userData.data._count?.followings || 0); + } else if (userRes.status === 404) { + setProfileUser(null); + setProfileError("The user you're looking for doesn't exist."); + } else { + throw new Error('Failed to load profile'); } if (postsRes.ok) { @@ -68,6 +97,11 @@ export default function ProfilePage() { } } catch (error) { console.error('Failed to load profile:', error); + setProfileError( + error instanceof Error ? error.message : 'Failed to load profile', + ); + } finally { + setIsLoadingProfile(false); } }; @@ -75,30 +109,65 @@ export default function ProfilePage() { }, [userId]); const handleFollowToggle = async () => { - if (!currentUser) return; + if (!currentUser || isUpdatingFollow) return; - const newIsFollowing = !isFollowing; - setIsFollowing(newIsFollowing); - setFollowerCount((prev) => (newIsFollowing ? prev + 1 : prev - 1)); + const nextIsFollowing = !isFollowing; + setIsUpdatingFollow(true); try { - const method = newIsFollowing ? 'POST' : 'DELETE'; + const method = nextIsFollowing ? 'POST' : 'DELETE'; const res = await fetch(`/api/users/${userId}/follow`, { method }); - if (!res.ok) throw new Error('Failed to toggle follow status'); + const data = await res.json(); + + if (!res.ok || !data.success) { + throw new Error(data.error || 'Failed to toggle follow status'); + } + + setIsFollowing(Boolean(data.data?.isFollowing)); + setFollowerCount(data.data?.counts?.followers ?? followerCount); + setFollowingCount(data.data?.counts?.following ?? followingCount); + setProfileUser((prev) => + prev + ? { + ...prev, + isFollowing: Boolean(data.data?.isFollowing), + _count: { + followers: data.data?.counts?.followers ?? followerCount, + followings: data.data?.counts?.following ?? followingCount, + }, + } + : prev, + ); } catch (error) { console.error(error); - setIsFollowing(!newIsFollowing); - setFollowerCount((prev) => (newIsFollowing ? prev - 1 : prev + 1)); + toast.error( + error instanceof Error ? error.message : 'Failed to update follow state', + ); + } finally { + setIsUpdatingFollow(false); } }; + if (isLoadingProfile) { + return ( +
+
+

Loading profile...

+

+ Fetching user details and relationship data. +

+
+
+ ); + } + if (!profileUser) { return (

User not found

- The user you're looking for doesn't exist. + {profileError || "The user you're looking for doesn't exist."}

@@ -151,14 +220,14 @@ export default function ProfilePage() { )}

- @{profileUser.username} + {profileUser.username ? `@${profileUser.username}` : 'No username yet'}

{/* Bio */}

- {profileUser.bio} + {profileUser.bio || 'No bio yet.'}

@@ -217,7 +286,7 @@ export default function ProfilePage() {
Joined{' '} - {profileUser.createdAt.toLocaleDateString('en-US', { + {new Date(profileUser.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric', })} @@ -229,10 +298,21 @@ export default function ProfilePage() { className="mt-2" variant={isFollowing ? "outline" : "default"} onClick={handleFollowToggle} + disabled={isUpdatingFollow} > - {isFollowing ? 'Unfollow' : 'Follow'} + {isUpdatingFollow + ? isFollowing + ? 'Unfollowing...' + : 'Following...' + : isFollowing + ? 'Unfollow' + : 'Follow'} )} + + {profileError ? ( +

{profileError}

+ ) : null}
diff --git a/app/components/follow-list-dialog.tsx b/app/components/follow-list-dialog.tsx index 965e6ce..c1e212e 100644 --- a/app/components/follow-list-dialog.tsx +++ b/app/components/follow-list-dialog.tsx @@ -9,6 +9,7 @@ import { import { useEffect, useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import Link from 'next/link'; +import { Button } from '@/components/ui/button'; interface FollowUser { id: string; @@ -33,20 +34,41 @@ export function FollowListDialog({ }: FollowListDialogProps) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + + const loadUsers = async () => { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/users/${userId}/${type}?limit=50`, { + cache: 'no-store', + }); + const data = await res.json(); + + if (!res.ok || !data.success) { + throw new Error(data.error || `Failed to load ${type}`); + } + + setUsers(data.data.items || []); + setTotal(data.data.total || 0); + } catch (err) { + setUsers([]); + setTotal(0); + setError(err instanceof Error ? err.message : `Failed to load ${type}`); + } finally { + setLoading(false); + } + }; useEffect(() => { if (open) { - setLoading(true); - fetch(`/api/users/${userId}/${type}`) - .then((res) => res.json()) - .then((data) => { - if (data.success) { - setUsers(data.data.items || []); - } - }) - .finally(() => setLoading(false)); + loadUsers(); } else { setUsers([]); + setTotal(0); + setError(null); } }, [open, userId, type]); @@ -56,12 +78,22 @@ export function FollowListDialog({ - {title} + + {title} + {!loading && total > 0 ? ` (${total})` : ''} +
{loading ? (
Loading...
+ ) : error ? ( +
+

{error}

+ +
) : users.length > 0 ? ( users.map((user) => (
@@ -78,6 +110,9 @@ export function FollowListDialog({

@{user.username}

+ {user.bio ? ( +

{user.bio}

+ ) : null}
)) diff --git a/app/docs/follow-management.md b/app/docs/follow-management.md new file mode 100644 index 0000000..bc70462 --- /dev/null +++ b/app/docs/follow-management.md @@ -0,0 +1,44 @@ +# Follow Management UI + +## Overview + +The profile page now supports managing social relationships directly from the frontend. + +Users can: + +- follow another user from their profile page +- unfollow a user from their profile page +- open followers and following lists in a dialog +- see follower and following counts update after each mutation + +## Frontend Surfaces + +- `app/app/profile/[userId]/page.tsx` + Handles profile-level follow state, loading states, errors, and count refresh after follow mutations. + +- `app/components/follow-list-dialog.tsx` + Shows followers and following in a modal dialog with loading, retry, and empty states. + +## Backend Routes Used + +- `POST /api/users/:id/follow` + Follows the target user and returns fresh follower/following counts. + +- `DELETE /api/users/:id/follow` + Unfollows the target user and returns fresh follower/following counts. + +- `GET /api/users/:id/followers` + Returns a paginated list of users who follow the target user. + +- `GET /api/users/:id/following` + Returns a paginated list of users followed by the target user. + +## UI Sync Behavior + +After a follow or unfollow action: + +- the profile button updates to reflect the latest relationship state +- follower counts are refreshed from the mutation response +- following counts stay aligned with the backend response + +This avoids relying only on optimistic updates and keeps the page consistent with server state.