diff --git a/thoughts-frontend/app/actions/profile.ts b/thoughts-frontend/app/actions/profile.ts new file mode 100644 index 0000000..214fcdf --- /dev/null +++ b/thoughts-frontend/app/actions/profile.ts @@ -0,0 +1,23 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { cookies } from "next/headers"; +import { updateProfile as apiUpdateProfile, UpdateProfileSchema } from "@/lib/api"; +import { z } from "zod"; + +async function getToken(): Promise { + const token = (await cookies()).get("auth_token")?.value; + if (!token) throw new Error("Not authenticated"); + return token; +} + +export async function updateProfile( + username: string, + data: z.infer +) { + const token = await getToken(); + const updated = await apiUpdateProfile(data, token); + revalidateTag(`profile:${username}`); + revalidateTag("me"); + return updated; +} diff --git a/thoughts-frontend/app/actions/social.ts b/thoughts-frontend/app/actions/social.ts new file mode 100644 index 0000000..adb0f7e --- /dev/null +++ b/thoughts-frontend/app/actions/social.ts @@ -0,0 +1,28 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { cookies } from "next/headers"; +import { + followUser as apiFollowUser, + unfollowUser as apiUnfollowUser, +} from "@/lib/api"; + +async function getToken(): Promise { + const token = (await cookies()).get("auth_token")?.value; + if (!token) throw new Error("Not authenticated"); + return token; +} + +export async function followUser(username: string) { + const token = await getToken(); + await apiFollowUser(username, token); + revalidateTag(`profile:${username}`); + revalidateTag("feed"); +} + +export async function unfollowUser(username: string) { + const token = await getToken(); + await apiUnfollowUser(username, token); + revalidateTag(`profile:${username}`); + revalidateTag("feed"); +} diff --git a/thoughts-frontend/app/actions/thoughts.ts b/thoughts-frontend/app/actions/thoughts.ts new file mode 100644 index 0000000..6d9e149 --- /dev/null +++ b/thoughts-frontend/app/actions/thoughts.ts @@ -0,0 +1,30 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { cookies } from "next/headers"; +import { + createThought as apiCreateThought, + deleteThought as apiDeleteThought, + CreateThoughtSchema, +} from "@/lib/api"; +import { z } from "zod"; + +async function getToken(): Promise { + const token = (await cookies()).get("auth_token")?.value; + if (!token) throw new Error("Not authenticated"); + return token; +} + +export async function createThought(data: z.infer) { + const token = await getToken(); + const thought = await apiCreateThought(data, token); + revalidateTag("feed"); + return thought; +} + +export async function deleteThought(thoughtId: string) { + const token = await getToken(); + await apiDeleteThought(thoughtId, token); + revalidateTag("feed"); + revalidateTag(`thought:${thoughtId}`); +} diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index f319c87..ebac652 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -5,11 +5,10 @@ import { getFriends, getMe, getTopFriends, - getUserProfile, Me, - User, } from "@/lib/api"; -import { PostThoughtForm } from "@/components/post-thought-form"; +import { ThoughtForm } from "@/components/thought-form"; +import { EmptyState } from "@/components/empty-state"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { PopularTags } from "@/components/popular-tags"; @@ -62,17 +61,6 @@ async function FeedPage({ const { items: allThoughts, totalPages } = feedData!; const thoughtThreads = buildThoughtThreads(allThoughts); - const authors = [...new Set(allThoughts.map((t) => t.author.username))]; - const userProfiles = await Promise.all( - authors.map((username) => getUserProfile(username, token).catch(() => null)) - ); - - const authorDetails = new Map( - userProfiles - .filter((u): u is User => !!u) - .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) - ); - const friends = (await getFriends(token)).users.map((user) => user.username); const topFriendsData = me ? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] })) @@ -93,7 +81,7 @@ async function FeedPage({

Your Feed

- +
@@ -111,14 +99,11 @@ async function FeedPage({ ))} {thoughtThreads.length === 0 && ( -

- Your feed is empty. Follow some users to see their thoughts! -

+ )}
null) : null, ]); - const authorDetails = new Map(); - if (results) { - results.users.forEach((user: User) => { - authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); - }); - } - return (
@@ -74,9 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
) : ( -

- No user found at {query} -

+ ) ) : results ? ( @@ -91,7 +83,6 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { @@ -100,9 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { ) : ( -

- No results found or an error occurred. -

+ )} diff --git a/thoughts-frontend/app/tags/[tagName]/page.tsx b/thoughts-frontend/app/tags/[tagName]/page.tsx index a244a89..953df10 100644 --- a/thoughts-frontend/app/tags/[tagName]/page.tsx +++ b/thoughts-frontend/app/tags/[tagName]/page.tsx @@ -1,7 +1,7 @@ // app/tags/[tagName]/page.tsx import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api"; +import { getThoughtsByTag, getMe, Me } from "@/lib/api"; export async function generateMetadata({ params, @@ -23,6 +23,7 @@ export async function generateMetadata({ }, }; } +import { EmptyState } from "@/components/empty-state"; import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { notFound } from "next/navigation"; @@ -49,16 +50,6 @@ export default async function TagPage({ params }: TagPageProps) { const thoughtThreads = buildThoughtThreads(allThoughts); const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; - const authors = [...new Set(allThoughts.map((t) => t.author.username))]; - const userProfiles = await Promise.all( - authors.map((username) => getUserProfile(username, token).catch(() => null)) - ); - const authorDetails = new Map( - userProfiles - .filter((u): u is User => !!u) - .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) - ); - return (
@@ -72,14 +63,11 @@ export default async function TagPage({ params }: TagPageProps) { ))} {thoughtThreads.length === 0 && ( -

- No thoughts found for this tag. -

+ )}
diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx index 1d87f14..d205053 100644 --- a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx +++ b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx @@ -3,10 +3,8 @@ import { cookies } from "next/headers"; import { getThoughtById, getThoughtThread, - getUserProfile, getMe, Me, - User, ThoughtThread as ThoughtThreadType, } from "@/lib/api"; import { ThoughtThread } from "@/components/thought-thread"; @@ -52,14 +50,6 @@ export async function generateMetadata({ }; } -function collectAuthors(thread: ThoughtThreadType): string[] { - const authors = new Set([thread.author.username]); - for (const reply of thread.replies) { - collectAuthors(reply).forEach((author) => authors.add(author)); - } - return Array.from(authors); -} - export default async function ThoughtPage({ params }: ThoughtPageProps) { const { thoughtId } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; @@ -76,20 +66,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) { const thread = threadResult.value; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; - // Fetch details for all authors in the thread efficiently - const authorUsernames = collectAuthors(thread); - const userProfiles = await Promise.all( - authorUsernames.map((username) => - getUserProfile(username, token).catch(() => null) - ) - ); - - const authorDetails = new Map( - userProfiles - .filter((u): u is User => !!u) - .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) - ); - return (
@@ -98,7 +74,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index f1f1563..bbb4ef1 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -44,6 +44,7 @@ export async function generateMetadata({ }, }; } +import { EmptyState } from "@/components/empty-state"; import { UserAvatar } from "@/components/user-avatar"; import { Calendar, Settings } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -126,9 +127,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const fediverseHandle = user.local && apiDomain ? `@${user.username}@${apiDomain}` : null; - const authorDetails = new Map(); - authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); - // Show who the profile owner follows (uses the already-fetched followingResult). const friends = followingResult.status === "fulfilled" @@ -277,19 +275,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) { ))} {thoughtThreads.length === 0 && ( - -

- This user hasn't posted any public thoughts yet. -

-
+ )} {isOwnProfile && ( diff --git a/thoughts-frontend/components/edit-profile-form.tsx b/thoughts-frontend/components/edit-profile-form.tsx index 5c17ca0..13c90cf 100644 --- a/thoughts-frontend/components/edit-profile-form.tsx +++ b/thoughts-frontend/components/edit-profile-form.tsx @@ -3,9 +3,8 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; -import { Me, UpdateProfileSchema, updateProfile } from "@/lib/api"; +import { Me, UpdateProfileSchema } from "@/lib/api"; +import { updateProfile } from "@/app/actions/profile"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; @@ -25,8 +24,6 @@ interface EditProfileFormProps { } export function EditProfileForm({ currentUser }: EditProfileFormProps) { - const router = useRouter(); - const { token } = useAuth(); const form = useForm>({ resolver: zodResolver(UpdateProfileSchema), @@ -40,13 +37,10 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) { }); async function onSubmit(values: z.infer) { - if (!token) return; toast.info("Updating your profile..."); try { - await updateProfile(values, token); + await updateProfile(currentUser.username, values); toast.success("Profile updated successfully!"); - router.push(`/users/${currentUser.username}`); - router.refresh(); } catch (err) { toast.error(`Failed to update profile. ${err}`); } diff --git a/thoughts-frontend/components/empty-state.tsx b/thoughts-frontend/components/empty-state.tsx new file mode 100644 index 0000000..5dcc01d --- /dev/null +++ b/thoughts-frontend/components/empty-state.tsx @@ -0,0 +1,12 @@ +interface EmptyStateProps { + message: string + className?: string +} + +export function EmptyState({ message, className }: EmptyStateProps) { + return ( +

+ {message} +

+ ) +} diff --git a/thoughts-frontend/components/follow-button.tsx b/thoughts-frontend/components/follow-button.tsx index 0462601..494292c 100644 --- a/thoughts-frontend/components/follow-button.tsx +++ b/thoughts-frontend/components/follow-button.tsx @@ -1,66 +1,41 @@ -"use client"; +"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"; +import { useOptimistic } from "react" +import { followUser, unfollowUser } from "@/app/actions/social" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { UserPlus, UserMinus } from "lucide-react" interface FollowButtonProps { - username: string; - isInitiallyFollowing: boolean; + 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; +export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) { + const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing) + async function handleClick() { + const next = !optimisticFollowing + setOptimisticFollowing(next) try { - // Optimistic update - setIsFollowing(!isFollowing); - await action(username, token); - router.refresh(); // Re-fetch server component data to get the latest follower count etc. + await (next ? followUser(username) : unfollowUser(username)) } catch { - // Revert on error - setIsFollowing(isFollowing); - toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`); - } finally { - setIsLoading(false); + setOptimisticFollowing(!next) // revert + toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`) } - }; + } return ( - ); + ) } diff --git a/thoughts-frontend/components/loading-skeleton.tsx b/thoughts-frontend/components/loading-skeleton.tsx new file mode 100644 index 0000000..8b7cbbe --- /dev/null +++ b/thoughts-frontend/components/loading-skeleton.tsx @@ -0,0 +1,34 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" + +export function ThoughtSkeleton() { + return ( + + + +
+ + +
+
+ + + + +
+ ) +} + +export function ProfileSkeleton() { + return ( + + + +
+ + +
+
+
+ ) +} diff --git a/thoughts-frontend/components/post-thought-form.tsx b/thoughts-frontend/components/post-thought-form.tsx deleted file mode 100644 index 124fa63..0000000 --- a/thoughts-frontend/components/post-thought-form.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Form, - FormField, - FormItem, - FormControl, - FormMessage, -} from "@/components/ui/form"; -import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { CreateThoughtSchema, createThought } from "@/lib/api"; -import { useAuth } from "@/hooks/use-auth"; -import { toast } from "sonner"; -import { Globe, Lock, Users } from "lucide-react"; -import { useState } from "react"; -import { Confetti } from "./confetti"; - -export function PostThoughtForm() { - const router = useRouter(); - const { token } = useAuth(); - const [showConfetti, setShowConfetti] = useState(false); - - const form = useForm>({ - resolver: zodResolver(CreateThoughtSchema), - defaultValues: { content: "", visibility: "public" }, - }); - - async function onSubmit(values: z.infer) { - if (!token) { - toast.error("You must be logged in to post."); - return; - } - - try { - await createThought(values, token); - toast.success("Your thought has been posted!"); - setShowConfetti(true); - form.reset(); - router.refresh(); // This is the key to updating the feed - } catch { - toast.error("Failed to post thought. Please try again."); - } - } - - return ( - <> - setShowConfetti(false)} /> - - -
- - ( - - -