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

44 KiB
Raw Blame History

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:

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:

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
npx tsc --noEmit

Expected: 0 errors.

  • Step 4: Commit
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

// 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
npx tsc --noEmit

Expected: 0 errors.

  • Step 3: Commit
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

// 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
npx tsc --noEmit

Expected: 0 errors.

  • Step 3: Commit
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

// 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
npx tsc --noEmit

Expected: 0 errors.

  • Step 3: Commit
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
// 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
// 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:

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:

<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
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
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

// 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
// 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:

<p className="text-center text-muted-foreground pt-8">
  Your feed is empty. Follow some users to see their thoughts!
</p>

with:

<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
npx tsc --noEmit

Expected: 0 errors.

  • Step 5: Commit
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

// 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:

import { PostThoughtForm } from "@/components/post-thought-form";

with:

import { ThoughtForm } from "@/components/thought-form";

Replace <PostThoughtForm /> with <ThoughtForm />.

  • Step 3: Update components/thought-card.tsx — swap ReplyForm import

Replace:

import { ReplyForm } from "@/components/reply-form";

with:

import { ThoughtForm } from "@/components/thought-form";

Replace:

<ReplyForm
  parentThoughtId={thought.id}
  onReplySuccess={() => setIsReplyOpen(false)}
/>

with:

<ThoughtForm
  replyToId={thought.id}
  onSuccess={() => setIsReplyOpen(false)}
/>
  • Step 4: Delete the old files
rm components/post-thought-form.tsx components/reply-form.tsx
  • Step 5: Search for any remaining imports of the deleted files
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
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
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

// 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
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
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

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:

import { updateProfile } from "@/app/actions/profile"

Remove useRouter and const router = useRouter().

In the submit handler, find await updateProfile(data, token) and replace with:

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
npx tsc --noEmit

Expected: 0 errors.

  • Step 4: Commit
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

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.

// 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:

// 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:

// 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
rm components/remote-user-profile.tsx
  • Step 7: Type-check
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
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