# Public Profile Shell

Actor profile layout that pairs the profile header with a two-column content slot for signed-out visitors.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/public-profile-shell.json
```

[Registry JSON](https://ui.uptoolkit.com/r/public-profile-shell.json)

## Preview

```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

import { sampleAuthor, sampleCounts } from "@/lib/social-sample-data";
import { PublicProfileShell } from "@/components/public-profile-shell";

export function Preview() {
  return (
    <PublicProfileShell
      actor={sampleAuthor}
      counts={sampleCounts.author}
      aside={
        <Card className="gap-3 py-4">
          <CardHeader>
            <CardTitle className="text-base">Mutual contacts</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="m-0 text-sm text-muted-foreground">Rail content goes in `aside`.</p>
          </CardContent>
        </Card>
      }
    >
      <Card>
        <CardHeader>
          <CardTitle className="text-base">Timeline</CardTitle>
        </CardHeader>
        <CardContent>
          <p className="m-0 text-sm text-muted-foreground">
            The timeline or wall feed renders in the shell&apos;s content slot.
          </p>
        </CardContent>
      </Card>
    </PublicProfileShell>
  );
}
```


## Source

### components/public-profile-shell.tsx

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

import { ProfileHeader, type ProfileHeaderProps } from "@/components/profile-header";
import type { ActivityPubActor } from "@/lib/activitypub";
import { sampleAuthor, sampleCounts } from "@/lib/social-sample-data";

type PublicProfileShellProps = {
  actor?: ActivityPubActor;
  brand?: { name: string; href?: string };
  signInHref?: string;
  /** Rail content, rendered alongside `children` in a two-column layout at `lg` and up. */
  aside?: React.ReactNode;
  /** Places `aside` before `children` on wide screens. */
  asideFirst?: boolean;
  children?: React.ReactNode;
  className?: string;
} & Pick<
  ProfileHeaderProps,
  | "counts"
  | "tabs"
  | "defaultTab"
  | "onFollow"
  | "onUnfollow"
  | "onMessage"
  | "includeJsonLd"
  | "locale"
>;

/**
 * Layout for a signed-out visitor viewing an actor's public profile: a
 * minimal top bar, the `ProfileHeader` block, and a two-column area for the
 * timeline plus an optional rail.
 *
 * `viewer` is intentionally left out of `ProfileHeader` here since the
 * visitor isn't signed in — pass `onFollow`/`onUnfollow` that redirect to
 * `signInHref` if you want the follow control to gate on sign-in.
 */
function PublicProfileShell({
  actor = sampleAuthor,
  brand = { name: "Uptoolkit", href: "#" },
  signInHref = "#",
  counts = sampleCounts.author,
  tabs,
  defaultTab,
  onFollow,
  onUnfollow,
  onMessage,
  includeJsonLd = true,
  locale,
  aside,
  asideFirst = false,
  children,
  className,
}: PublicProfileShellProps) {
  return (
    <div className={cn("min-h-svh bg-muted/40", className)}>
      <header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
        <div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
          <a
            href={brand.href ?? "#"}
            className="flex shrink-0 items-center gap-2 font-semibold tracking-tight"
          >
            {brand.name}
          </a>
          <Button
            size="sm"
            nativeButton={false}
            render={<a href={signInHref} />}
            className="ml-auto"
          >
            Sign in
          </Button>
        </div>
      </header>

      <div className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-6">
        <ProfileHeader
          actor={actor}
          counts={counts}
          tabs={tabs}
          defaultTab={defaultTab}
          onFollow={onFollow}
          onUnfollow={onUnfollow}
          onMessage={onMessage}
          includeJsonLd={includeJsonLd}
          locale={locale}
        />

        <div className="flex flex-col gap-6 lg:flex-row">
          <main className="min-w-0 flex-1">{children}</main>

          {aside ? (
            <aside
              className={cn(
                "flex w-full shrink-0 flex-col gap-4 lg:w-80",
                asideFirst && "lg:order-first",
              )}
            >
              {aside}
            </aside>
          ) : null}
        </div>
      </div>
    </div>
  );
}

export { PublicProfileShell, type PublicProfileShellProps };
```



## Usage

A minimal top bar, the [`ProfileHeader`](/blocks/profile-header) block, and a two-column area for the
timeline plus an optional rail — the layout `profile-page` and `wall-page` compose inline, pulled out
so a public profile route can reuse it directly.

```tsx
import { PublicProfileShell } from "@/components/public-profile-shell";

<PublicProfileShell actor={actor} counts={counts} signInHref="/login" aside={<MutualsCard />}>
  <SocialFeed viewer={undefined} collection={timeline} />
</PublicProfileShell>;
```

### Signed-out follow

`viewer` isn't a prop here — the shell always renders `ProfileHeader` without one, since the visitor
isn't signed in. `onFollow` and `onUnfollow` still fire after the optimistic UI update, so redirect to
`signInHref` from one of them if you want the follow button to gate on sign-in instead of failing
silently.

### Layout

`aside` renders in a `lg:w-80` rail next to `children`; pass `asideFirst` to put it before the main
column, matching a wall-style layout instead of a feed-style one. Omit `aside` for a single-column
page.

