Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions app/app/api/users/[id]/follow/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 94 additions & 14 deletions app/app/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
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<any>(null);
const [profileUser, setProfileUser] = useState<ProfileUser | null>(null);
const [userPosts, setUserPosts] = useState<Post[]>([]);
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<string | null>(null);
const [isUpdatingFollow, setIsUpdatingFollow] = useState(false);
const [showFollowList, setShowFollowList] = useState<{ open: boolean; type: 'followers' | 'following' }>({
open: false,
type: 'followers',
Expand All @@ -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' }),
Expand All @@ -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) {
Expand All @@ -68,37 +97,77 @@ 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);
}
};

if (userId) loadProfile();
}, [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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Loading profile...</h1>
<p className="text-gray-600 dark:text-gray-400">
Fetching user details and relationship data.
</p>
</div>
</div>
);
}

if (!profileUser) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">User not found</h1>
<p className="text-gray-600 dark:text-gray-400">
The user you're looking for doesn't exist.
{profileError || "The user you're looking for doesn't exist."}
</p>
</div>
</div>
Expand Down Expand Up @@ -151,14 +220,14 @@ export default function ProfilePage() {
)}
</div>
<p className="text-gray-600 dark:text-gray-400 mb-2">
@{profileUser.username}
{profileUser.username ? `@${profileUser.username}` : 'No username yet'}
</p>
<UserRankBadge rank={profileUser.rank} />
</div>

{/* Bio */}
<p className="text-gray-700 dark:text-gray-300 max-w-md">
{profileUser.bio}
{profileUser.bio || 'No bio yet.'}
</p>

<div className="flex justify-center gap-8 text-sm">
Expand Down Expand Up @@ -217,7 +286,7 @@ export default function ProfilePage() {
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<Calendar className="w-4 h-4" />
Joined{' '}
{profileUser.createdAt.toLocaleDateString('en-US', {
{new Date(profileUser.createdAt).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})}
Expand All @@ -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'}
</Button>
)}

{profileError ? (
<p className="text-sm text-red-500">{profileError}</p>
) : null}
</div>
</CardContent>
</Card>
Expand Down
55 changes: 45 additions & 10 deletions app/components/follow-list-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,20 +34,41 @@ export function FollowListDialog({
}: FollowListDialogProps) {
const [users, setUsers] = useState<FollowUser[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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]);

Expand All @@ -56,12 +78,22 @@ export function FollowListDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[70vh] flex flex-col">
<DialogHeader className="pb-2">
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
<DialogTitle className="text-xl font-semibold">
{title}
{!loading && total > 0 ? ` (${total})` : ''}
</DialogTitle>
</DialogHeader>

<div className="flex-1 overflow-y-auto space-y-4 pr-2">
{loading ? (
<div className="text-center text-sm text-gray-500 py-4">Loading...</div>
) : error ? (
<div className="text-center py-6 space-y-3">
<p className="text-sm text-red-500">{error}</p>
<Button type="button" variant="outline" size="sm" onClick={loadUsers}>
Retry
</Button>
</div>
) : users.length > 0 ? (
users.map((user) => (
<div key={user.id} className="flex items-center gap-3">
Expand All @@ -78,6 +110,9 @@ export function FollowListDialog({
</h4>
</Link>
<p className="text-xs text-gray-500 truncate">@{user.username}</p>
{user.bio ? (
<p className="text-xs text-gray-500 truncate">{user.bio}</p>
) : null}
</div>
</div>
))
Expand Down
Loading
Loading