diff --git a/docs/superpowers/plans/2026-05-15-frontend-overhaul.md b/docs/superpowers/plans/2026-05-15-frontend-overhaul.md new file mode 100644 index 0000000..e37b52a --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-frontend-overhaul.md @@ -0,0 +1,1425 @@ +# 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 & { + next?: { tags?: string[]; revalidate?: number | false } +} + +async function apiFetch( + endpoint: string, + options: ApiFetchOptions = {}, + schema: z.ZodType, + token?: string | null +): Promise { + if (!API_BASE_URL) { + throw new Error("API_BASE_URL is not defined"); + } + + const { next, ...restOptions } = options; + + const headers: Record = { + "Content-Type": "application/json", + ...(restOptions.headers as Record), + }; + + 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 { + 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}`); +} +``` + +- [ ] **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 { + 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 { + 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; +} +``` + +- [ ] **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 ( +
+ + + {thought.replies.length > 0 && ( +
+ {thought.replies.map((reply) => ( + + ))} +
+ )} +
+ ); +} +``` + +### 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 ( + <> +
+ {thought.replyToId && isReply && ( +
+ + + Replying to{" "} + + parent thought + + +
+ )} + {!thought.replyToId && thought.replyToUrl && ( +
+ + + Replying to{" "} + + original post ↗ + + +
+ )} +
+ + + + +
+ {author.displayName || author.username} + +
+ + + + + + + {isAuthor && ( + setIsAlertOpen(true)}> + + Delete + + )} + + + + View + + + + +
+ + {author.local ? ( +

{thought.content}

+ ) : ( +
📎 Media attachment — not supported

', + }} + /> + )} + + + {token && ( + + + + )} + + {isReplyOpen && ( +
+ setIsReplyOpen(false)} /> +
+ )} + + + + + Are you sure? + + This action cannot be undone. This will permanently delete your thought. + + + + Cancel + Delete + + + + + ); +} +``` + +### 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, + ]); + + 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 ( +
+
+ + +
+
+

Your Feed

+
+ + +
+ + {shouldDisplayTopFriends && ( + + )} + {!shouldDisplayTopFriends && token && friends.length > 0 && ( + + )} + +
+ +
+ {thoughtThreads.map((thought) => ( + + ))} + {thoughtThreads.length === 0 && ( +

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

+ )} +
+ `/?page=${p}`} + /> +
+ + +
+
+ ); +} +``` + +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 + +``` +(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 ( +

+ {message} +

+ ) +} +``` + +- [ ] **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 ( + + + +
+ + +
+
+ + + + +
+ ) +} + +export function ProfileSkeleton() { + return ( + + + +
+ + +
+
+
+ ) +} +``` + +- [ ] **Step 3: Replace copy-pasted empty state blocks** + +In `app/page.tsx`, replace: +```tsx +

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

+``` +with: +```tsx + +``` + +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 ``. + +- [ ] **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>({ + resolver: zodResolver(CreateThoughtSchema), + defaultValues: { + content: "", + visibility: "public", + ...(replyToId ? { inReplyToId: replyToId } : {}), + }, + }) + + async function onSubmit(values: z.infer) { + 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 = ( +
+ + ( + + +