1426 lines
44 KiB
Markdown
1426 lines
44 KiB
Markdown
# Frontend Overhaul Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Eliminate the author-profile fetch waterfall, replace `router.refresh()` with targeted Server Action cache invalidation, and fix composition throughout the frontend.
|
||
|
||
**Architecture:** Backend already embeds `author: UserResponse` in `ThoughtResponse` — no backend changes needed. Frontend pages ignore this and re-fetch profiles separately; we delete those calls. Mutations become Server Actions that call `revalidateTag` so only affected data refetches. `useOptimistic` (React 19) gives instant UI feedback. Large components split into focused sub-components. Near-duplicate form components unified.
|
||
|
||
**Tech Stack:** Next.js 15 App Router, React 19, TypeScript, Tailwind CSS v4, shadcn/ui, react-hook-form + Zod, `next/cache` (`revalidateTag`), `next/headers` (`cookies`)
|
||
|
||
**Working directory:** `thoughts-frontend/`
|
||
|
||
---
|
||
|
||
## Key facts before you start
|
||
|
||
- Auth token lives in a cookie named `auth_token`. In server components: `(await cookies()).get('auth_token')?.value`. In Server Actions: same.
|
||
- `apiFetch(endpoint, options, schema, token?)` is in `lib/api.ts`. `options` is `RequestInit`.
|
||
- `ThoughtResponse` (Zod: `ThoughtSchema`) already has `author: UserSchema` embedded — `thought.author.username`, `thought.author.avatarUrl`, `thought.author.displayName` all exist on every thought.
|
||
- `ThoughtThread` component (not the type) re-fetches the avatar via `authorDetails.get(username)` as an override — this is now unnecessary.
|
||
- Run the dev server with `npm run dev` (or `bun dev`) from `thoughts-frontend/`.
|
||
- Type-check with `npx tsc --noEmit` — run this after each task.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Status | Change |
|
||
|---|---|---|
|
||
| `lib/api.ts` | modify | Add `next?: NextFetchRequestConfig` to `apiFetch`; tag GET calls |
|
||
| `app/actions/thoughts.ts` | **create** | Server Actions: createThought, deleteThought |
|
||
| `app/actions/social.ts` | **create** | Server Actions: followUser, unfollowUser |
|
||
| `app/actions/profile.ts` | **create** | Server Action: updateProfile |
|
||
| `app/page.tsx` | modify | Remove getUserProfile waterfall; remove authorDetails |
|
||
| `app/users/[username]/page.tsx` | modify | Remove getUserProfile waterfall; remove authorDetails |
|
||
| `app/thoughts/[thoughtId]/page.tsx` | modify | Remove getUserProfile waterfall; remove authorDetails |
|
||
| `app/tags/[tagName]/page.tsx` | modify | Remove getUserProfile waterfall; remove authorDetails |
|
||
| `app/search/page.tsx` | modify | Remove getUserProfile waterfall; remove authorDetails |
|
||
| `components/thought-thread.tsx` | modify | Remove authorDetails prop; use thought.author directly |
|
||
| `components/thought-card.tsx` | modify | Remove author prop; use thought.author directly; call Server Action for delete |
|
||
| `components/follow-button.tsx` | modify | Replace router.refresh() with Server Action + useOptimistic |
|
||
| `components/post-thought-form.tsx` | **delete** | Replaced by ThoughtForm |
|
||
| `components/reply-form.tsx` | **delete** | Replaced by ThoughtForm |
|
||
| `components/thought-form.tsx` | **create** | Unified form accepting a Server Action |
|
||
| `components/empty-state.tsx` | **create** | Shared empty state primitive |
|
||
| `components/loading-skeleton.tsx` | **create** | Shared skeleton primitive |
|
||
| `components/edit-profile-form.tsx` | modify | Replace router.refresh() with Server Action |
|
||
| `components/remote-user-profile.tsx` | modify | Split into sub-components |
|
||
| `components/remote-user-profile/profile-card.tsx` | **create** | Avatar, bio, stats |
|
||
| `components/remote-user-profile/connections.tsx` | **create** | Followers/following lists |
|
||
|
||
---
|
||
|
||
## Task 1: Add `next` tag support to `apiFetch` and tag all GET fetches
|
||
|
||
**Files:**
|
||
- Modify: `lib/api.ts`
|
||
|
||
- [ ] **Step 1: Update `apiFetch` signature to accept Next.js cache options**
|
||
|
||
In `lib/api.ts`, change lines 154–188 (`apiFetch` function). Replace the function with:
|
||
|
||
```ts
|
||
type ApiFetchOptions = Omit<RequestInit, 'next'> & {
|
||
next?: { tags?: string[]; revalidate?: number | false }
|
||
}
|
||
|
||
async function apiFetch<T>(
|
||
endpoint: string,
|
||
options: ApiFetchOptions = {},
|
||
schema: z.ZodType<T>,
|
||
token?: string | null
|
||
): Promise<T> {
|
||
if (!API_BASE_URL) {
|
||
throw new Error("API_BASE_URL is not defined");
|
||
}
|
||
|
||
const { next, ...restOptions } = options;
|
||
|
||
const headers: Record<string, string> = {
|
||
"Content-Type": "application/json",
|
||
...(restOptions.headers as Record<string, string>),
|
||
};
|
||
|
||
if (token) {
|
||
headers["Authorization"] = `Bearer ${token}`;
|
||
}
|
||
|
||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||
...restOptions,
|
||
headers,
|
||
...(next ? { next } : {}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API request failed with status ${response.status}`);
|
||
}
|
||
|
||
if (response.status === 204) {
|
||
return null as T;
|
||
}
|
||
|
||
const data = await response.json();
|
||
return schema.parse(data);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tag the GET fetches that pages depend on**
|
||
|
||
Update these functions in `lib/api.ts` to pass `next.tags`:
|
||
|
||
```ts
|
||
export const getMe = (token: string) =>
|
||
apiFetch("/users/me", { next: { tags: ["me"] } }, MeSchema, token);
|
||
|
||
export const getUserProfile = (username: string, token: string | null) =>
|
||
apiFetch(`/users/${username}`, { next: { tags: [`profile:${username}`] } }, UserSchema, token);
|
||
|
||
export const getFollowersList = (username: string, token: string | null) =>
|
||
apiFetch(
|
||
`/users/${username}/followers`,
|
||
{ next: { tags: [`profile:${username}`] } },
|
||
z.object({ total: z.number(), items: z.array(UserSchema) }),
|
||
token
|
||
);
|
||
|
||
export const getFollowingList = (username: string, token: string | null) =>
|
||
apiFetch(
|
||
`/users/${username}/following`,
|
||
{ next: { tags: [`profile:${username}`] } },
|
||
z.object({ total: z.number(), items: z.array(UserSchema) }),
|
||
token
|
||
);
|
||
|
||
export const getTopFriends = (username: string, token: string | null) =>
|
||
apiFetch(
|
||
`/users/${username}/top-friends`,
|
||
{ next: { tags: [`profile:${username}`] } },
|
||
z.object({ topFriends: z.array(z.string()) }),
|
||
token
|
||
);
|
||
|
||
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
|
||
apiFetch(
|
||
`/feed?page=${page}&per_page=${pageSize}`,
|
||
{ next: { tags: ["feed"] } },
|
||
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() })
|
||
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) })),
|
||
token
|
||
);
|
||
|
||
export const getUserThoughts = (username: string, token: string | null) =>
|
||
apiFetch(
|
||
`/users/${username}/thoughts`,
|
||
{ next: { tags: [`profile:${username}`] } },
|
||
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
|
||
token
|
||
);
|
||
|
||
export const getThoughtById = (thoughtId: string, token: string | null) =>
|
||
apiFetch(
|
||
`/thoughts/${thoughtId}`,
|
||
{ next: { tags: [`thought:${thoughtId}`] } },
|
||
ThoughtSchema,
|
||
token
|
||
);
|
||
|
||
export const getThoughtsByTag = (tagName: string, token: string | null) =>
|
||
apiFetch(
|
||
`/tags/${tagName}`,
|
||
{ next: { tags: [`tag:${tagName}`, "feed"] } },
|
||
z.object({ tag: z.string(), items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
|
||
token
|
||
);
|
||
|
||
export const search = (query: string, token: string | null) =>
|
||
apiFetch(
|
||
`/search?q=${encodeURIComponent(query)}`,
|
||
{ next: { tags: ["search"] } },
|
||
SearchResultsSchema,
|
||
token
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 3: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add lib/api.ts
|
||
git commit -m "feat(frontend): add next.tags support to apiFetch; tag all GET fetches"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Server Actions — thought mutations
|
||
|
||
**Files:**
|
||
- Create: `app/actions/thoughts.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```ts
|
||
// app/actions/thoughts.ts
|
||
"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<string> {
|
||
const token = (await cookies()).get("auth_token")?.value;
|
||
if (!token) throw new Error("Not authenticated");
|
||
return token;
|
||
}
|
||
|
||
export async function createThought(data: z.infer<typeof CreateThoughtSchema>) {
|
||
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}`);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/actions/thoughts.ts
|
||
git commit -m "feat(frontend): Server Actions for thought mutations"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Server Actions — social mutations
|
||
|
||
**Files:**
|
||
- Create: `app/actions/social.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```ts
|
||
// app/actions/social.ts
|
||
"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<string> {
|
||
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");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/actions/social.ts
|
||
git commit -m "feat(frontend): Server Actions for social mutations"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Server Actions — profile mutation
|
||
|
||
**Files:**
|
||
- Create: `app/actions/profile.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```ts
|
||
// app/actions/profile.ts
|
||
"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<string> {
|
||
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<typeof UpdateProfileSchema>
|
||
) {
|
||
const token = await getToken();
|
||
const updated = await apiUpdateProfile(data, token);
|
||
revalidateTag(`profile:${username}`);
|
||
revalidateTag("me");
|
||
return updated;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/actions/profile.ts
|
||
git commit -m "feat(frontend): Server Action for profile mutation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Remove the author-profile waterfall
|
||
|
||
This is the biggest performance fix. Pages currently re-fetch `getUserProfile` for every unique author after the feed loads. Since `thought.author` already contains the data, we delete all of this.
|
||
|
||
**Files:**
|
||
- Modify: `components/thought-thread.tsx`
|
||
- Modify: `components/thought-card.tsx`
|
||
- Modify: `app/page.tsx`
|
||
- Modify: `app/users/[username]/page.tsx`
|
||
- Modify: `app/thoughts/[thoughtId]/page.tsx`
|
||
- Modify: `app/tags/[tagName]/page.tsx`
|
||
- Modify: `app/search/page.tsx`
|
||
|
||
### 5a: Update `ThoughtThread` — remove `authorDetails` prop
|
||
|
||
- [ ] **Step 1: Replace `components/thought-thread.tsx`**
|
||
|
||
```tsx
|
||
// components/thought-thread.tsx
|
||
import { Me, ThoughtThread as ThoughtThreadType } from "@/lib/api";
|
||
import { ThoughtCard } from "./thought-card";
|
||
|
||
interface ThoughtThreadProps {
|
||
thought: ThoughtThreadType;
|
||
currentUser: Me | null;
|
||
isReply?: boolean;
|
||
}
|
||
|
||
export function ThoughtThread({
|
||
thought,
|
||
currentUser,
|
||
isReply = false,
|
||
}: ThoughtThreadProps) {
|
||
return (
|
||
<div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0">
|
||
<ThoughtCard
|
||
thought={thought}
|
||
currentUser={currentUser}
|
||
isReply={isReply}
|
||
/>
|
||
|
||
{thought.replies.length > 0 && (
|
||
<div
|
||
id={`thought-thread-${thought.id}__replies`}
|
||
className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4"
|
||
>
|
||
{thought.replies.map((reply) => (
|
||
<ThoughtThread
|
||
key={reply.id}
|
||
thought={reply}
|
||
currentUser={currentUser}
|
||
isReply={true}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5b: Update `ThoughtCard` — use `thought.author` directly, call Server Action for delete
|
||
|
||
- [ ] **Step 2: Replace `components/thought-card.tsx`**
|
||
|
||
```tsx
|
||
// components/thought-card.tsx
|
||
"use client";
|
||
|
||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||
import { UserAvatar } from "./user-avatar";
|
||
import { Me, Thought } from "@/lib/api";
|
||
import { format, formatDistanceToNow } from "date-fns";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import { useState } from "react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import { CornerUpLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
|
||
import { ReplyForm } from "@/components/reply-form";
|
||
import Link from "next/link";
|
||
import { cn } from "@/lib/utils";
|
||
import { deleteThought } from "@/app/actions/thoughts";
|
||
|
||
interface ThoughtCardProps {
|
||
thought: Thought;
|
||
currentUser: Me | null;
|
||
isReply?: boolean;
|
||
}
|
||
|
||
export function ThoughtCard({
|
||
thought,
|
||
currentUser,
|
||
isReply = false,
|
||
}: ThoughtCardProps) {
|
||
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
||
const [isReplyOpen, setIsReplyOpen] = useState(false);
|
||
const { token } = useAuth();
|
||
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), { addSuffix: true });
|
||
const { author } = thought;
|
||
const isAuthor = currentUser?.username === author.username;
|
||
|
||
const handleDelete = async () => {
|
||
try {
|
||
await deleteThought(thought.id);
|
||
toast.success("Thought deleted successfully.");
|
||
} catch {
|
||
toast.error("Failed to delete thought.");
|
||
} finally {
|
||
setIsAlertOpen(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
id={thought.id}
|
||
className={cn(
|
||
"bg-transparent backdrop-blur-lg shadow-fa-md rounded-xl overflow-hidden glossy-effect bottom",
|
||
isReply ? "bg-white/80 glass-effect glossy-effect bottom shadow-fa-sm p-2" : ""
|
||
)}
|
||
>
|
||
{thought.replyToId && isReply && (
|
||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||
<CornerUpLeft className="h-4 w-4 text-primary/70" />
|
||
<span>
|
||
Replying to{" "}
|
||
<Link href={`#${thought.replyToId}`} className="hover:underline text-primary text-shadow-sm">
|
||
parent thought
|
||
</Link>
|
||
</span>
|
||
</div>
|
||
)}
|
||
{!thought.replyToId && thought.replyToUrl && (
|
||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||
<CornerUpLeft className="h-4 w-4 text-primary/70" />
|
||
<span>
|
||
Replying to{" "}
|
||
<a href={thought.replyToUrl} target="_blank" rel="noopener noreferrer" className="hover:underline text-primary text-shadow-sm">
|
||
original post ↗
|
||
</a>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Card className="mt-2">
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||
<Link href={`/users/${author.username}`} className="flex items-center gap-4 text-shadow-md">
|
||
<UserAvatar src={author.avatarUrl} alt={author.displayName || author.username} />
|
||
<div className="flex flex-col">
|
||
<span className="font-bold">{author.displayName || author.username}</span>
|
||
<time
|
||
dateTime={new Date(thought.createdAt).toISOString()}
|
||
title={format(new Date(thought.createdAt), "PPP p")}
|
||
className="text-sm text-muted-foreground text-shadow-sm"
|
||
>
|
||
{timeAgo}
|
||
</time>
|
||
</div>
|
||
</Link>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="p-2 rounded-full hover:bg-accent">
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent>
|
||
{isAuthor && (
|
||
<DropdownMenuItem className="text-destructive" onSelect={() => setIsAlertOpen(true)}>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
Delete
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem>
|
||
<Link href={`/thoughts/${thought.id}`} className="flex gap-2">
|
||
<MessageSquare className="mr-2 h-4 w-4" />
|
||
View
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{author.local ? (
|
||
<p className="whitespace-pre-wrap break-words text-shadow-sm">{thought.content}</p>
|
||
) : (
|
||
<div
|
||
className="text-sm break-words [&_a]:underline [&_a]:text-primary [&_p]:mb-2 [&_.media-notice]:text-muted-foreground [&_.media-notice]:italic"
|
||
dangerouslySetInnerHTML={{
|
||
__html: thought.content.trim() || '<p class="media-notice">📎 Media attachment — not supported</p>',
|
||
}}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
|
||
{token && (
|
||
<CardFooter className="border-t px-4 pt-2 pb-2 border-border/50">
|
||
<Button variant="ghost" size="sm" onClick={() => setIsReplyOpen(!isReplyOpen)}>
|
||
<MessageSquare className="mr-2 h-4 w-4" />
|
||
Reply
|
||
</Button>
|
||
</CardFooter>
|
||
)}
|
||
|
||
{isReplyOpen && (
|
||
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
|
||
<ReplyForm parentThoughtId={thought.id} onReplySuccess={() => setIsReplyOpen(false)} />
|
||
</div>
|
||
)}
|
||
</Card>
|
||
<AlertDialog open={isAlertOpen} onOpenChange={setIsAlertOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This action cannot be undone. This will permanently delete your thought.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5c: Remove waterfall from `app/page.tsx`
|
||
|
||
- [ ] **Step 3: Update `app/page.tsx` — delete lines 65–74 (the `getUserProfile` waterfall) and the `authorDetails` map**
|
||
|
||
Replace the `FeedPage` function body with:
|
||
|
||
```tsx
|
||
async function FeedPage({
|
||
token,
|
||
searchParams,
|
||
}: {
|
||
token: string;
|
||
searchParams: { page?: string };
|
||
}) {
|
||
const page = parseInt(searchParams.page ?? "1", 10);
|
||
|
||
const [feedData, me] = await Promise.all([
|
||
getFeed(token, page).catch(() => null),
|
||
getMe(token).catch(() => null) as Promise<Me | null>,
|
||
]);
|
||
|
||
if (!feedData || !me) {
|
||
redirect("/login");
|
||
}
|
||
|
||
const { items: allThoughts, totalPages } = feedData;
|
||
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||
|
||
const friends = (await getFriends(token)).users.map((user) => user.username);
|
||
const topFriendsData = me
|
||
? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] }))
|
||
: { topFriends: [] };
|
||
const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0;
|
||
|
||
return (
|
||
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||
<aside className="hidden lg:block lg:col-span-1">
|
||
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
|
||
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="col-span-1 lg:col-span-2 space-y-6">
|
||
<header className="mb-6">
|
||
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
||
</header>
|
||
<PostThoughtForm />
|
||
|
||
<div className="block lg:hidden space-y-6">
|
||
<PopularTags />
|
||
{shouldDisplayTopFriends && (
|
||
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
|
||
)}
|
||
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||
<TopFriends mode="friends" usernames={friends} />
|
||
)}
|
||
<UsersCount />
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
{thoughtThreads.map((thought) => (
|
||
<ThoughtThread
|
||
key={thought.id}
|
||
thought={thought}
|
||
currentUser={me}
|
||
/>
|
||
))}
|
||
{thoughtThreads.length === 0 && (
|
||
<p className="text-center text-muted-foreground pt-8">
|
||
Your feed is empty. Follow some users to see their thoughts!
|
||
</p>
|
||
)}
|
||
</div>
|
||
<PaginationNav
|
||
page={page}
|
||
totalPages={totalPages}
|
||
buildHref={(p) => `/?page=${p}`}
|
||
/>
|
||
</main>
|
||
|
||
<aside className="hidden lg:block lg:col-span-1">
|
||
<div className="sticky top-20 space-y-6">
|
||
<PopularTags />
|
||
{shouldDisplayTopFriends && (
|
||
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
|
||
)}
|
||
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||
<TopFriends mode="friends" usernames={friends} />
|
||
)}
|
||
<UsersCount />
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Also remove `getUserProfile` and `User` from the import at the top of `app/page.tsx`.
|
||
|
||
### 5d: Remove waterfall from remaining pages
|
||
|
||
- [ ] **Step 4: Update `app/users/[username]/page.tsx`**
|
||
|
||
Read the full file first. Find the block that:
|
||
1. Collects unique author usernames from `thoughts`
|
||
2. Calls `getUserProfile` for each
|
||
3. Builds an `authorDetails` Map
|
||
|
||
Delete that entire block. Remove `authorDetails` from all `ThoughtThread` usages in this file. Remove `getUserProfile` (for author fetching, not for the profile page header — keep the one that fetches the page subject's profile) and `User` from the imports if they are no longer needed.
|
||
|
||
The `ThoughtThread` components in this file should be called as:
|
||
```tsx
|
||
<ThoughtThread thought={thread} currentUser={me} />
|
||
```
|
||
(no `authorDetails` prop)
|
||
|
||
- [ ] **Step 5: Update `app/thoughts/[thoughtId]/page.tsx`**
|
||
|
||
Same pattern: find and delete the author waterfall, remove `authorDetails` from `ThoughtThread` calls.
|
||
|
||
- [ ] **Step 6: Update `app/tags/[tagName]/page.tsx`**
|
||
|
||
Same pattern.
|
||
|
||
- [ ] **Step 7: Update `app/search/page.tsx`**
|
||
|
||
Same pattern.
|
||
|
||
- [ ] **Step 8: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors. Fix any remaining `authorDetails` references until clean.
|
||
|
||
- [ ] **Step 9: Verify in browser**
|
||
|
||
Run `npm run dev`. Open the feed page. Check the Network tab — you should see **no** `getUserProfile` calls after the feed loads. Author avatars should still display correctly (from `thought.author.avatarUrl`).
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add components/thought-thread.tsx components/thought-card.tsx app/page.tsx \
|
||
app/users app/thoughts app/tags app/search
|
||
git commit -m "perf(frontend): eliminate author profile waterfall — use thought.author directly"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Shared primitives — EmptyState and LoadingSkeleton
|
||
|
||
**Files:**
|
||
- Create: `components/empty-state.tsx`
|
||
- Create: `components/loading-skeleton.tsx`
|
||
|
||
- [ ] **Step 1: Create `components/empty-state.tsx`**
|
||
|
||
```tsx
|
||
// components/empty-state.tsx
|
||
interface EmptyStateProps {
|
||
message: string
|
||
className?: string
|
||
}
|
||
|
||
export function EmptyState({ message, className }: EmptyStateProps) {
|
||
return (
|
||
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
|
||
{message}
|
||
</p>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `components/loading-skeleton.tsx`**
|
||
|
||
```tsx
|
||
// components/loading-skeleton.tsx
|
||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||
import { Skeleton } from "@/components/ui/skeleton"
|
||
|
||
export function ThoughtSkeleton() {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center gap-4">
|
||
<Skeleton className="h-10 w-10 rounded-full" />
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-32" />
|
||
<Skeleton className="h-3 w-20" />
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-4/5" />
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
export function ProfileSkeleton() {
|
||
return (
|
||
<Card>
|
||
<CardContent className="pt-6 flex items-center gap-4">
|
||
<Skeleton className="h-16 w-16 rounded-full" />
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-5 w-40" />
|
||
<Skeleton className="h-4 w-24" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Replace copy-pasted empty state blocks**
|
||
|
||
In `app/page.tsx`, replace:
|
||
```tsx
|
||
<p className="text-center text-muted-foreground pt-8">
|
||
Your feed is empty. Follow some users to see their thoughts!
|
||
</p>
|
||
```
|
||
with:
|
||
```tsx
|
||
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
|
||
```
|
||
|
||
Add `import { EmptyState } from "@/components/empty-state"` to the file.
|
||
|
||
Repeat for `app/users/[username]/page.tsx`, `app/tags/[tagName]/page.tsx`, `app/search/page.tsx` — find each hard-coded empty state paragraph and replace with `<EmptyState message="..." />`.
|
||
|
||
- [ ] **Step 4: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/empty-state.tsx components/loading-skeleton.tsx \
|
||
app/page.tsx app/users app/tags app/search
|
||
git commit -m "refactor(frontend): shared EmptyState and LoadingSkeleton primitives"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Unified ThoughtForm — replaces PostThoughtForm and ReplyForm
|
||
|
||
**Files:**
|
||
- Create: `components/thought-form.tsx`
|
||
- Delete: `components/post-thought-form.tsx`
|
||
- Delete: `components/reply-form.tsx`
|
||
- Modify: `app/page.tsx` (update import)
|
||
- Modify: `components/thought-card.tsx` (update import)
|
||
|
||
- [ ] **Step 1: Create `components/thought-form.tsx`**
|
||
|
||
```tsx
|
||
// components/thought-form.tsx
|
||
"use client"
|
||
|
||
import { useForm } from "react-hook-form"
|
||
import { zodResolver } from "@hookform/resolvers/zod"
|
||
import { z } from "zod"
|
||
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 } 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"
|
||
import { createThought } from "@/app/actions/thoughts"
|
||
|
||
interface ThoughtFormProps {
|
||
/** Set to the parent thought ID when composing a reply. */
|
||
replyToId?: string
|
||
/** Called after successful submit (e.g. close the reply panel). */
|
||
onSuccess?: () => void
|
||
/** Whether to wrap the form in a Card. Default: true for top-level, false for replies. */
|
||
card?: boolean
|
||
}
|
||
|
||
export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: ThoughtFormProps) {
|
||
const { token } = useAuth()
|
||
const [showConfetti, setShowConfetti] = useState(false)
|
||
|
||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||
resolver: zodResolver(CreateThoughtSchema),
|
||
defaultValues: {
|
||
content: "",
|
||
visibility: "public",
|
||
...(replyToId ? { inReplyToId: replyToId } : {}),
|
||
},
|
||
})
|
||
|
||
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||
if (!token) {
|
||
toast.error("You must be logged in.")
|
||
return
|
||
}
|
||
try {
|
||
await createThought(values)
|
||
toast.success(replyToId ? "Reply posted!" : "Thought posted!")
|
||
setShowConfetti(true)
|
||
form.reset()
|
||
onSuccess?.()
|
||
} catch {
|
||
toast.error(replyToId ? "Failed to post reply." : "Failed to post thought.")
|
||
}
|
||
}
|
||
|
||
const inner = (
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="content"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormControl>
|
||
<Textarea
|
||
placeholder={replyToId ? "Post your reply..." : "What's on your mind?"}
|
||
className={`resize-none ${replyToId ? "bg-white glass-effect glossy-effect bottom shadow-fa-sm" : ""}`}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
<div className={`flex ${replyToId ? "justify-end gap-2" : "justify-between items-center"}`}>
|
||
{!replyToId && (
|
||
<FormField
|
||
control={form.control}
|
||
name="visibility"
|
||
render={({ field }) => (
|
||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger className="w-[150px]">
|
||
<SelectValue placeholder="Visibility" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectItem value="public">
|
||
<div className="flex items-center gap-2"><Globe className="h-4 w-4" /> Public</div>
|
||
</SelectItem>
|
||
<SelectItem value="followers">
|
||
<div className="flex items-center gap-2"><Users className="h-4 w-4" /> Followers</div>
|
||
</SelectItem>
|
||
<SelectItem value="unlisted">
|
||
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Unlisted</div>
|
||
</SelectItem>
|
||
<SelectItem value="direct">
|
||
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Direct</div>
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
/>
|
||
)}
|
||
{replyToId && (
|
||
<Button type="button" variant="ghost" onClick={onSuccess}>
|
||
Cancel
|
||
</Button>
|
||
)}
|
||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||
{form.formState.isSubmitting
|
||
? replyToId ? "Replying..." : "Posting..."
|
||
: replyToId ? "Reply" : "Post Thought"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Form>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||
{card ? <Card><CardContent className="p-4">{inner}</CardContent></Card> : <div className="space-y-2 p-4">{inner}</div>}
|
||
</>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `app/page.tsx` — swap import**
|
||
|
||
Replace:
|
||
```tsx
|
||
import { PostThoughtForm } from "@/components/post-thought-form";
|
||
```
|
||
with:
|
||
```tsx
|
||
import { ThoughtForm } from "@/components/thought-form";
|
||
```
|
||
|
||
Replace `<PostThoughtForm />` with `<ThoughtForm />`.
|
||
|
||
- [ ] **Step 3: Update `components/thought-card.tsx` — swap ReplyForm import**
|
||
|
||
Replace:
|
||
```tsx
|
||
import { ReplyForm } from "@/components/reply-form";
|
||
```
|
||
with:
|
||
```tsx
|
||
import { ThoughtForm } from "@/components/thought-form";
|
||
```
|
||
|
||
Replace:
|
||
```tsx
|
||
<ReplyForm
|
||
parentThoughtId={thought.id}
|
||
onReplySuccess={() => setIsReplyOpen(false)}
|
||
/>
|
||
```
|
||
with:
|
||
```tsx
|
||
<ThoughtForm
|
||
replyToId={thought.id}
|
||
onSuccess={() => setIsReplyOpen(false)}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 4: Delete the old files**
|
||
|
||
```bash
|
||
rm components/post-thought-form.tsx components/reply-form.tsx
|
||
```
|
||
|
||
- [ ] **Step 5: Search for any remaining imports of the deleted files**
|
||
|
||
```bash
|
||
grep -r "post-thought-form\|reply-form" --include="*.tsx" --include="*.ts" .
|
||
```
|
||
|
||
Expected: no results. If any remain, update those imports to use `ThoughtForm`.
|
||
|
||
- [ ] **Step 6: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 7: Verify in browser**
|
||
|
||
Open the feed page. Post a thought — it should appear without a page reload (Server Action + `revalidateTag('feed')`). Open a thought thread. Reply — the form should close and the reply count update.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add components/thought-form.tsx components/thought-card.tsx app/page.tsx
|
||
git commit -m "refactor(frontend): unified ThoughtForm replaces PostThoughtForm and ReplyForm"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: FollowButton — useOptimistic + Server Action
|
||
|
||
Replace the `useState` + `router.refresh()` pattern with `useOptimistic` (React 19) and the Server Action from Task 3.
|
||
|
||
**Files:**
|
||
- Modify: `components/follow-button.tsx`
|
||
|
||
- [ ] **Step 1: Replace `components/follow-button.tsx`**
|
||
|
||
```tsx
|
||
// components/follow-button.tsx
|
||
"use client"
|
||
|
||
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
|
||
}
|
||
|
||
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
||
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
||
|
||
async function handleClick() {
|
||
const next = !optimisticFollowing
|
||
setOptimisticFollowing(next)
|
||
try {
|
||
await (next ? followUser(username) : unfollowUser(username))
|
||
} catch {
|
||
setOptimisticFollowing(!next) // revert
|
||
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
onClick={handleClick}
|
||
variant={optimisticFollowing ? "secondary" : "default"}
|
||
data-following={optimisticFollowing}
|
||
>
|
||
{optimisticFollowing ? (
|
||
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
|
||
) : (
|
||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
||
)}
|
||
</Button>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Verify in browser**
|
||
|
||
Go to a user profile page. Click Follow — button should toggle instantly. The profile follower count should update after the Server Action completes (because `revalidateTag` fires).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add components/follow-button.tsx
|
||
git commit -m "refactor(frontend): FollowButton — useOptimistic + Server Action, remove router.refresh()"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Update edit-profile-form to use Server Action
|
||
|
||
**Files:**
|
||
- Modify: `components/edit-profile-form.tsx`
|
||
|
||
- [ ] **Step 1: Read the current file**
|
||
|
||
```bash
|
||
cat components/edit-profile-form.tsx
|
||
```
|
||
|
||
- [ ] **Step 2: Replace the mutation call**
|
||
|
||
Find the submit handler. It currently calls `updateProfile(data, token)` from `@/lib/api` then `router.refresh()`.
|
||
|
||
Change the import: remove `updateProfile` from `@/lib/api`. Add:
|
||
```tsx
|
||
import { updateProfile } from "@/app/actions/profile"
|
||
```
|
||
|
||
Remove `useRouter` and `const router = useRouter()`.
|
||
|
||
In the submit handler, find `await updateProfile(data, token)` and replace with:
|
||
```tsx
|
||
const me = await getMe(token!) // still needed to get username for revalidation
|
||
await updateProfile(me.username, data)
|
||
```
|
||
|
||
Or if `username` is already available as a prop, pass it to the Server Action directly.
|
||
|
||
Remove the `router.refresh()` call — the Server Action handles invalidation via `revalidateTag('me')` and `revalidateTag('profile:${username}')`.
|
||
|
||
- [ ] **Step 3: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add components/edit-profile-form.tsx
|
||
git commit -m "refactor(frontend): edit-profile-form — use Server Action, remove router.refresh()"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Split RemoteUserProfile
|
||
|
||
The 296-line `components/remote-user-profile.tsx` mixes profile display, tab switching, and follower/following list fetching. Split into focused sub-components.
|
||
|
||
**Files:**
|
||
- Create: `components/remote-user-profile/profile-card.tsx`
|
||
- Create: `components/remote-user-profile/connections.tsx`
|
||
- Create: `components/remote-user-profile/index.tsx`
|
||
- Delete: `components/remote-user-profile.tsx`
|
||
|
||
- [ ] **Step 1: Read the current file in full**
|
||
|
||
```bash
|
||
cat components/remote-user-profile.tsx
|
||
```
|
||
|
||
- [ ] **Step 2: Create `components/remote-user-profile/profile-card.tsx`**
|
||
|
||
Extract the avatar, display name, handle, bio, follower/following count display, and external link into this component. It receives the `RemoteActor` data as a prop and renders it — no state, no API calls.
|
||
|
||
```tsx
|
||
// components/remote-user-profile/profile-card.tsx
|
||
import { RemoteActor } from "@/lib/api"
|
||
import { UserAvatar } from "@/components/user-avatar"
|
||
import { ExternalLink } from "lucide-react"
|
||
|
||
interface ProfileCardProps {
|
||
actor: RemoteActor
|
||
}
|
||
|
||
export function ProfileCard({ actor }: ProfileCardProps) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-start gap-4">
|
||
<UserAvatar src={actor.avatarUrl} alt={actor.displayName || actor.handle} className="h-16 w-16" />
|
||
<div className="flex-1 min-w-0">
|
||
<h2 className="font-bold text-xl truncate">{actor.displayName || actor.handle}</h2>
|
||
<p className="text-sm text-muted-foreground truncate">@{actor.handle}</p>
|
||
{actor.url && (
|
||
<a href={actor.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-primary hover:underline mt-1">
|
||
<ExternalLink className="h-3 w-3" /> View profile
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{actor.bio && <p className="text-sm text-muted-foreground">{actor.bio}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `components/remote-user-profile/connections.tsx`**
|
||
|
||
Extract the followers/following list fetching and rendering into this component. It receives the `handle` and `token`, manages its own pagination state.
|
||
|
||
Move the follower list pagination logic (useState for page, the fetch calls for `getActorFollowers`/`getActorFollowing`, the list rendering) from the monolith into this component:
|
||
|
||
```tsx
|
||
// components/remote-user-profile/connections.tsx
|
||
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { ActorConnection, getActorFollowers, getActorFollowing } from "@/lib/api"
|
||
import { Button } from "@/components/ui/button"
|
||
import { UserAvatar } from "@/components/user-avatar"
|
||
|
||
interface ConnectionsProps {
|
||
handle: string
|
||
token: string | null
|
||
type: "followers" | "following"
|
||
}
|
||
|
||
export function Connections({ handle, token, type }: ConnectionsProps) {
|
||
const [items, setItems] = useState<ActorConnection[]>([])
|
||
const [page, setPage] = useState(1)
|
||
const [hasMore, setHasMore] = useState(false)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
const fetch = type === "followers" ? getActorFollowers : getActorFollowing
|
||
fetch(handle, page, token)
|
||
.then((data) => {
|
||
setItems((prev) => (page === 1 ? data.items : [...prev, ...data.items]))
|
||
setHasMore(data.hasMore)
|
||
})
|
||
.catch(console.error)
|
||
.finally(() => setLoading(false))
|
||
}, [handle, token, type, page])
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{items.map((item) => (
|
||
<a key={item.handle} href={item.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:opacity-80">
|
||
<UserAvatar src={item.avatarUrl} alt={item.displayName || item.handle} className="h-8 w-8" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium truncate">{item.displayName || item.handle}</p>
|
||
<p className="text-xs text-muted-foreground truncate">@{item.handle}</p>
|
||
</div>
|
||
</a>
|
||
))}
|
||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||
{hasMore && !loading && (
|
||
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)}>
|
||
Load more
|
||
</Button>
|
||
)}
|
||
{!loading && items.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">No {type} yet.</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `components/remote-user-profile/index.tsx`**
|
||
|
||
This component owns only tab state. It delegates all display to `ProfileCard` and `Connections`.
|
||
|
||
Look at the current `remote-user-profile.tsx` for the Tab structure and adapt it using the new sub-components:
|
||
|
||
```tsx
|
||
// components/remote-user-profile/index.tsx
|
||
"use client"
|
||
|
||
import { useState } from "react"
|
||
import { RemoteActor } from "@/lib/api"
|
||
import { ProfileCard } from "./profile-card"
|
||
import { Connections } from "./connections"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
|
||
interface RemoteUserProfileProps {
|
||
actor: RemoteActor
|
||
token: string | null
|
||
}
|
||
|
||
export function RemoteUserProfile({ actor, token }: RemoteUserProfileProps) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<ProfileCard actor={actor} />
|
||
<Tabs defaultValue="followers">
|
||
<TabsList>
|
||
<TabsTrigger value="followers">Followers</TabsTrigger>
|
||
<TabsTrigger value="following">Following</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="followers">
|
||
<Connections handle={actor.handle} token={token} type="followers" />
|
||
</TabsContent>
|
||
<TabsContent value="following">
|
||
<Connections handle={actor.handle} token={token} type="following" />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Update import in `app/remote-actor/page.tsx`**
|
||
|
||
Find: `import { RemoteUserProfile } from "@/components/remote-user-profile"`
|
||
Replace: `import { RemoteUserProfile } from "@/components/remote-user-profile/index"`
|
||
|
||
Or just: the import path stays the same if the directory `index.tsx` resolves automatically.
|
||
|
||
- [ ] **Step 6: Delete the old file**
|
||
|
||
```bash
|
||
rm components/remote-user-profile.tsx
|
||
```
|
||
|
||
- [ ] **Step 7: Type-check**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 8: Verify in browser**
|
||
|
||
Go to `/remote-actor?handle=@someone@instance.social`. Profile should display. Followers/following tabs should load.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add components/remote-user-profile/ app/remote-actor
|
||
git commit -m "refactor(frontend): split RemoteUserProfile into ProfileCard + Connections"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Checklist (run after all tasks)
|
||
|
||
- [ ] `npx tsc --noEmit` passes with 0 errors
|
||
- [ ] `grep -r "router\.refresh" --include="*.tsx" --include="*.ts" .` returns no results
|
||
- [ ] `grep -r "authorDetails" --include="*.tsx" --include="*.ts" .` returns no results
|
||
- [ ] `grep -r "getUserProfile" --include="*.tsx" app/` returns only legitimate uses (profile page metadata generation), not author waterfall fetches
|
||
- [ ] Feed page Network tab shows no `getUserProfile` calls after initial load
|
||
- [ ] Posting a thought refreshes the feed without `router.refresh()`
|
||
- [ ] Following a user updates the button state immediately (optimistic) and the profile refreshes
|
||
|
||
---
|
||
|
||
## Final commit message (after all tasks)
|
||
|
||
```
|
||
feat(frontend): performance + DRY + composition overhaul
|
||
|
||
- Eliminate author profile waterfall (thought.author used directly)
|
||
- Server Actions + revalidateTag replace scattered router.refresh()
|
||
- useOptimistic on FollowButton for instant feedback
|
||
- ThoughtForm unifies PostThoughtForm and ReplyForm
|
||
- EmptyState and LoadingSkeleton shared primitives
|
||
- RemoteUserProfile split into ProfileCard + Connections
|
||
```
|