Files
thoughts/docs/superpowers/plans/2026-05-15-frontend-overhaul.md

1426 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 154188 (`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 6574 (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 &amp; 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
```