# The Long Table: podcast

> A warm five-page podcast site built on a new audio player: a home with a latest-episode hero and a mini player, an episodes archive, an episode page with chapters and a transcript that follows playback, hosts and subscribe.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/template-podcast).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/template-podcast
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/template-podcast-light-c7167e1188838f01.png
- Registry item: https://ui.ballmac.com/r/template-podcast.json
- Categories: templates
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/template-podcast.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/audio-player.json`, `https://ui.ballmac.com/r/copy-button.json`, `https://ui.ballmac.com/r/media.json`

## Notes from the registry

Pages are at /podcast, /podcast/episodes, /podcast/episodes/[slug], /podcast/hosts and /podcast/subscribe. Episodes and the transcript live in podcast-data.ts; pass a real src to AudioPlayer in podcast-episode.tsx to play audio (without one it runs a silent demo).

## Files

### `registry/ballmac/components/templates/podcast/podcast-fonts.ts`

```ts
// Ballmac UI: Podcast template fonts. https://ui.ballmac.com/templates/template-podcast
import { Nunito_Sans, Young_Serif } from "next/font/google"

export const podcastDisplay = Young_Serif({ variable: "--podcast-display", subsets: ["latin"], weight: "400", display: "swap", preload: false })
export const podcastSans = Nunito_Sans({ variable: "--podcast-sans", subsets: ["latin"], display: "swap", preload: false })
```

### `registry/ballmac/components/templates/podcast/podcast-data.ts`

```ts
import type { MediaSource } from "@/components/ballmac/media"

// Ballmac UI: Podcast sample content. https://ui.ballmac.com/templates/template-podcast

export type TranscriptLine = { t: number; who: string; text: string }
export type Episode = {
  n: number
  slug: string
  title: string
  guest: string
  guestRole: string
  date: string
  /** Length in seconds. */
  duration: number
  season: 1 | 2 | 3
  summary: string
  art: number
  /** Your own image instead of the painted art: an image URL (give it imageAlt), an object with alt text and a dark-mode file, or your own element. */
  image?: MediaSource
  /** Describes `image` when it is a plain URL. */
  imageAlt?: string
  chapters: { start: number; title: string }[]
}

export const episodes: Episode[] = [
  { n: 42, slug: "the-optimised-life", title: "The Optimised Life", guest: "Amara Okafor", guestRole: "Writer and technologist", date: "2026-09-29", duration: 2460, season: 3, summary: "A year without metrics, and what was left when she stopped counting. Plus: why the best dinner party has no agenda.", art: 0, chapters: [{ start: 0, title: "Cold open" }, { start: 95, title: "Why we stopped measuring" }, { start: 612, title: "The spreadsheet that ate a year" }, { start: 1480, title: "What replaced it" }, { start: 2210, title: "Listener questions" }] },
  { n: 41, slug: "bread-and-patience", title: "Bread and Patience", guest: "Tomás Reyes", guestRole: "Baker", date: "2026-09-15", duration: 2920, season: 3, summary: "A third-generation baker on sourdough, sleep and why nothing good happens quickly.", art: 1, chapters: [{ start: 0, title: "Welcome" }, { start: 300, title: "The starter" }, { start: 1500, title: "Night shifts" }] },
  { n: 40, slug: "the-unfinished-city", title: "The Unfinished City", guest: "Inês Marques", guestRole: "Editor", date: "2026-09-01", duration: 2210, season: 3, summary: "Scaffolding, cranes and the case for cities that are never done.", art: 2, chapters: [{ start: 0, title: "Welcome" }, { start: 410, title: "Rua da Madalena" }] },
  { n: 39, slug: "what-the-radio-knew", title: "What the Radio Knew", guest: "Elena Rossi", guestRole: "Culture editor", date: "2026-08-18", duration: 1980, season: 3, summary: "Why the old shipping forecast still feels like poetry.", art: 3, chapters: [{ start: 0, title: "Welcome" }, { start: 600, title: "The forecast" }] },
  { n: 38, slug: "a-year-offline", title: "A Year Offline", guest: "Dev Patel", guestRole: "Engineer", date: "2026-08-04", duration: 3120, season: 3, summary: "He deleted every feed and kept the group chats. Here is what changed.", art: 4, chapters: [{ start: 0, title: "Welcome" }, { start: 800, title: "Week three" }] },
  { n: 37, slug: "small-kitchens", title: "Small Kitchens", guest: "Mei Tanaka", guestRole: "Chef", date: "2026-07-21", duration: 2640, season: 2, summary: "Cooking for twelve on two burners, and why constraints taste good.", art: 5, chapters: [{ start: 0, title: "Welcome" }, { start: 520, title: "Two burners" }] },
  { n: 36, slug: "the-long-way-home", title: "The Long Way Home", guest: "Sasha Petrova", guestRole: "Travel writer", date: "2026-07-07", duration: 2750, season: 2, summary: "Trains, ferries and the pleasure of arriving slowly.", art: 0, chapters: [{ start: 0, title: "Welcome" }] },
  { n: 35, slug: "on-listening", title: "On Listening", guest: "Marcus Lindqvist", guestRole: "Therapist", date: "2026-06-23", duration: 2300, season: 2, summary: "What a good listener actually does, and how to become one.", art: 2, chapters: [{ start: 0, title: "Welcome" }] },
]

export const transcript: TranscriptLine[] = [
  { t: 0, who: "Nora", text: "Welcome to The Long Table, the show where we sit down for a long dinner and talk about whatever comes up." },
  { t: 14, who: "Sam", text: "Tonight it's Amara Okafor, who spent a year without measuring anything. No steps, no calories, no inbox zero." },
  { t: 31, who: "Nora", text: "Which sounds either wonderful or like a recipe for disaster." },
  { t: 40, who: "Amara", text: "Both, honestly. The first month I felt like I'd lost my map. I kept reaching for numbers the way you reach for a phone you've left in another room." },
  { t: 63, who: "Sam", text: "What were you actually measuring before? Walk us through a normal day." },
  { t: 76, who: "Amara", text: "Everything. Sleep, steps, tasks closed, words written, minutes meditated. I had a dashboard for my own life and it had a streak counter." },
  { t: 99, who: "Nora", text: "A streak counter for meditating. That's the part that gets me." },
  { t: 108, who: "Amara", text: "I know. I once meditated in a taxi to keep a streak alive. I wasn't calm. I was anxious about the streak." },
  { t: 126, who: "Sam", text: "So what made you stop?" },
  { t: 132, who: "Amara", text: "A very ordinary Tuesday. I realised I couldn't remember anything about the week except the numbers." },
]

export const getEpisode = (slug: string) => episodes.find((e) => e.slug === slug) ?? episodes[0]
const fmt = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
export const formatDate = (iso: string) => fmt.format(new Date(iso))
export const minutes = (s: number) => `${Math.round(s / 60)} min`
```

### `registry/ballmac/components/templates/podcast/podcast-theme.tsx`

```tsx
// Ballmac UI: Podcast template shell. https://ui.ballmac.com/templates/template-podcast
"use client"

import * as React from "react"
import { Menu, Rss, X } from "lucide-react"

import { AudioPlayer } from "@/components/ballmac/audio-player"
import { type Episode } from "@/components/ballmac/templates/podcast/podcast-data"
import { podcastDisplay, podcastSans } from "@/components/ballmac/templates/podcast/podcast-fonts"
import { cn } from "@/lib/utils"
import { Media, type MediaSource } from "@/components/ballmac/media"

type PodcastPage = "home" | "episodes" | "episode" | "hosts" | "subscribe"
type PodcastHrefs = Record<PodcastPage, string>

const defaultHrefs: PodcastHrefs = { home: "/podcast", episodes: "/podcast/episodes", episode: "/podcast/episodes/the-optimised-life", hosts: "/podcast/hosts", subscribe: "/podcast/subscribe" }

/** The Long Table's palette: warm cream and aubergine ink with amber candlelight. Dark mode is the same table after dark. */
const podcastCss = `
.podcast-theme,body:not(:has([data-gallery])):has(.podcast-theme){--background:oklch(0.972 0.02 70);--foreground:oklch(0.24 0.06 340);--card:oklch(0.99 0.012 70);--card-foreground:oklch(0.24 0.06 340);--popover:oklch(0.99 0.012 70);--popover-foreground:oklch(0.24 0.06 340);--primary:oklch(0.3 0.08 340);--primary-foreground:oklch(0.975 0.02 70);--secondary:oklch(0.945 0.03 68);--secondary-foreground:oklch(0.24 0.06 340);--muted:oklch(0.945 0.03 68);--muted-foreground:oklch(0.45 0.05 340);--accent:oklch(0.93 0.04 66);--accent-foreground:oklch(0.24 0.06 340);--border:oklch(0.24 0.06 340 / 14%);--input:oklch(0.24 0.06 340 / 22%);--ring:oklch(0.45 0.16 345);--surface:oklch(0.955 0.025 68);--destructive:oklch(0.52 0.21 27);--chart-1:oklch(0.78 0.16 70);--chart-2:oklch(0.45 0.16 345);--chart-3:oklch(0.55 0.1 160);--chart-4:oklch(0.68 0.15 35);--chart-5:oklch(0.42 0.1 280);--podcast-on-amber:oklch(0.24 0.06 340);--radius:1rem}
.dark .podcast-theme,.dark body:not(:has([data-gallery])):has(.podcast-theme){--background:oklch(0.18 0.04 335);--foreground:oklch(0.95 0.025 70);--card:oklch(0.22 0.045 335);--card-foreground:oklch(0.95 0.025 70);--popover:oklch(0.24 0.047 335);--popover-foreground:oklch(0.95 0.025 70);--primary:oklch(0.95 0.025 70);--primary-foreground:oklch(0.22 0.05 340);--secondary:oklch(0.27 0.05 335);--secondary-foreground:oklch(0.95 0.025 70);--muted:oklch(0.26 0.05 335);--muted-foreground:oklch(0.74 0.04 60);--accent:oklch(0.3 0.055 335);--accent-foreground:oklch(0.95 0.025 70);--border:oklch(1 0 0 / 11%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.78 0.16 70);--surface:oklch(0.2 0.042 335);--destructive:oklch(0.7 0.19 27);--chart-1:oklch(0.8 0.16 72);--chart-2:oklch(0.76 0.13 350);--chart-3:oklch(0.78 0.11 160);--chart-4:oklch(0.74 0.14 38);--chart-5:oklch(0.74 0.1 285)}
body:not(:has([data-gallery])):has(.podcast-theme){font-family:var(--podcast-sans),ui-sans-serif,system-ui,sans-serif}
`

const display = "[font-family:var(--podcast-display),ui-serif,Georgia,serif] font-normal tracking-[-0.01em]"

/** Cover art: a table seen from above, in the show's colours. */
function ShowArt({ variant, className, image, imageAlt }: { variant: number; className?: string; image?: MediaSource; imageAlt?: string }) {
  const v = ((variant % 6) + 6) % 6
  if (image) return <Media media={image} alt={imageAlt} fill className={cn("aspect-square w-full", className)} />
  const bg = ["bg-chart-2", "bg-chart-1", "bg-chart-4", "bg-chart-3", "bg-chart-5", "bg-primary"][v]
  return (
    <div aria-hidden="true" className={cn("@container relative isolate aspect-square w-full overflow-hidden", bg, className)}>
      <div className="bg-card/90 absolute top-1/2 left-1/2 size-[62%] -translate-x-1/2 -translate-y-1/2 rounded-full" />
      <div className="border-foreground/30 absolute top-1/2 left-1/2 size-[44%] -translate-x-1/2 -translate-y-1/2 rounded-full border-2" />
      <div className="bg-chart-1 absolute top-[10%] start-[12%] size-[14%] rounded-full" />
      <div className="bg-foreground/80 absolute end-[10%] bottom-[12%] h-[7%] w-[34%] rounded-full" style={{ transform: `rotate(${-20 + v * 12}deg)` }} />
      <div className="bg-foreground/80 absolute bottom-[12%] start-[10%] h-[30%] w-[3%] rounded-full" />
      <span className={cn("text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[30cqw] leading-none", display)}>{["42", "41", "40", "39", "38", "37"][v]}</span>
    </div>
  )
}

type Ctx = { now: Episode | null; play: (e: Episode) => void }
const PodcastContext = React.createContext<Ctx>({ now: null, play: () => {} })
/** The episode in the mini player and a way to start one. Use it inside a PodcastShell. */
function usePodcast() {
  return React.useContext(PodcastContext)
}

const links: { key: PodcastPage; label: string }[] = [
  { key: "episodes", label: "Episodes" },
  { key: "hosts", label: "Hosts" },
  { key: "subscribe", label: "Subscribe" },
]

type PodcastShellProps = React.ComponentProps<"div"> & {
  /** The page being shown, so its nav link is marked current. */
  page: PodcastPage
  /** Override where pages live (used by previews). */
  hrefs?: Partial<PodcastHrefs>
}

/** The Long Table's frame: a warm header, a footer with platforms and a mini player that appears when you press play on a list. */
function PodcastShell({ page, hrefs: overrides, className, style, children, ...props }: PodcastShellProps) {
  const hrefs = { ...defaultHrefs, ...overrides }
  const [open, setOpen] = React.useState(false)
  const [now, setNow] = React.useState<Episode | null>(null)
  React.useEffect(() => {
    const classes = [podcastDisplay.variable, podcastSans.variable].filter(Boolean)
    document.body.classList.add(...classes)
    return () => document.body.classList.remove(...classes)
  }, [])
  const active = page === "episode" ? "episodes" : page
  const ctx = React.useMemo<Ctx>(() => ({ now, play: setNow }), [now])
  return (
    <PodcastContext.Provider value={ctx}>
      <div
        data-slot="podcast"
        className={cn("podcast-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", now && "pb-32", podcastDisplay.variable, podcastSans.variable, className)}
        style={{ fontFamily: "var(--podcast-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
        {...props}
      >
        <style>{podcastCss}</style>
        <header className="bg-background/85 sticky top-0 z-40 border-b backdrop-blur-xl">
          <div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
            <a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-3 rounded-lg outline-none focus-visible:ring-[3px]">
              <span className="bg-chart-1 flex size-10 items-center justify-center rounded-full" aria-hidden="true"><span className="border-[var(--podcast-on-amber)] size-5 rounded-full border-[3px]" /></span>
              <span className={cn("text-xl", display)}>The Long Table</span>
            </a>
            <nav aria-label="Main" className="hidden items-center gap-1 md:flex">
              {links.map((l) => <a key={l.key} href={hrefs[l.key]} aria-current={active === l.key ? "page" : undefined} className="hover:bg-accent aria-[current=page]:bg-accent focus-visible:ring-ring/50 rounded-full px-4 py-2 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]">{l.label}</a>)}
              <a href={hrefs.subscribe} className="bg-chart-1 ms-2 inline-flex h-10 items-center gap-2 rounded-full px-5 text-sm font-bold text-[var(--podcast-on-amber)] outline-none transition-transform hover:translate-y-[var(--bm-lift,-2px)] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none"><Rss className="size-4" aria-hidden="true" />Follow</a>
            </nav>
            <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="podcast-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] md:hidden">{open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}</button>
          </div>
          {open && <nav id="podcast-mobile-menu" aria-label="Mobile" className="px-4 pb-4 md:hidden">{links.map((l) => <a key={l.key} href={hrefs[l.key]} className="hover:bg-accent block rounded-2xl px-4 py-3 text-lg font-semibold">{l.label}</a>)}</nav>}
        </header>
        {children}
        <footer className="bg-primary text-primary-foreground mt-24">
          <div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
            <div><p className={cn("text-3xl", display)}>The Long Table</p><p className="mt-3 max-w-xs opacity-80 text-pretty">A long dinner and a good conversation, every other Tuesday. Hosted by Nora Vale and Sam Okoye.</p></div>
            <div><h2 className="text-xs font-bold tracking-widest uppercase opacity-70">Listen</h2><ul className="mt-4 space-y-2.5">{["Apple Podcasts", "Spotify", "YouTube", "RSS feed"].map((p) => <li key={p}><a href={hrefs.subscribe} className="opacity-90 hover:underline">{p}</a></li>)}</ul></div>
            <div><h2 className="text-xs font-bold tracking-widest uppercase opacity-70">The show</h2><ul className="mt-4 space-y-2.5">{[["Episodes", hrefs.episodes], ["Hosts", hrefs.hosts], ["Sponsor", hrefs.subscribe], ["Contact", hrefs.hosts]].map(([l, h]) => <li key={l}><a href={h} className="opacity-90 hover:underline">{l}</a></li>)}</ul></div>
          </div>
          <p className="mx-auto max-w-6xl border-t border-current/20 px-4 py-5 text-xs opacity-70 sm:px-6">© 2026 The Long Table. Recorded in a very small kitchen.</p>
        </footer>
        {now && (
          <div role="region" aria-label="Now playing" className="fixed inset-x-3 bottom-3 z-50 mx-auto max-w-3xl">
            <AudioPlayer key={now.slug} variant="compact" title={`${now.n}. ${now.title}`} duration={now.duration} className="shadow-[0_20px_50px_-12px_rgb(0_0_0/0.4)]" />
            <button type="button" onClick={() => setNow(null)} className="bg-card hover:bg-accent focus-visible:ring-ring/50 absolute -top-3 -end-1 inline-flex size-8 items-center justify-center rounded-full border shadow-sm outline-none focus-visible:ring-[3px]" aria-label="Close the player"><X className="size-4" aria-hidden="true" /></button>
          </div>
        )}
      </div>
    </PodcastContext.Provider>
  )
}

export { PodcastShell, ShowArt, defaultHrefs as podcastDefaultHrefs, display as podcastDisplayClass, usePodcast, type PodcastHrefs, type PodcastPage, type PodcastShellProps }
```

### `registry/ballmac/components/templates/podcast/podcast-home.tsx`

```tsx
// Ballmac UI: Podcast home page. https://ui.ballmac.com/templates/template-podcast
"use client"

import * as React from "react"
import { ArrowRight, Check, Play } from "lucide-react"

import { episodes, formatDate, minutes } from "@/components/ballmac/templates/podcast/podcast-data"
import { PodcastShell, ShowArt, podcastDisplayClass, usePodcast, type PodcastHrefs } from "@/components/ballmac/templates/podcast/podcast-theme"
import { cn } from "@/lib/utils"

const eyebrow = "text-chart-2 text-xs font-extrabold tracking-[0.14em] uppercase"

function PlayButton({ ep, big }: { ep: (typeof episodes)[number]; big?: boolean }) {
  const { play, now } = usePodcast()
  const on = now?.slug === ep.slug
  return (
    <button type="button" onClick={() => play(ep)} aria-label={`Play episode ${ep.n}: ${ep.title}`} aria-pressed={on} className={cn("bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center justify-center gap-2 rounded-full font-bold outline-none transition-transform hover:scale-105 focus-visible:ring-[3px] motion-reduce:transition-none", big ? "h-14 px-7 text-base" : "size-11")}>
      <Play className={cn("fill-current", big ? "size-5" : "size-4 translate-x-px")} aria-hidden="true" />{big && (on ? "Playing" : "Play latest")}
    </button>
  )
}

function Subscribe() {
  const [email, setEmail] = React.useState("")
  const [error, setError] = React.useState("")
  const [done, setDone] = React.useState(false)
  return done ? (
    <p role="status" className="inline-flex items-center gap-2 font-semibold"><Check className="text-chart-3 size-5" aria-hidden="true" />Saved a seat for you. See you Tuesday.</p>
  ) : (
    <div className="w-full max-w-md"><form noValidate onSubmit={(e) => { e.preventDefault(); if (!/^\S+@\S+\.\S+$/.test(email.trim())) return setError("Enter an email like you@example.com."); setError(""); setDone(true) }} className="flex w-full flex-col gap-2 sm:flex-row">
      <label htmlFor="pc-email" className="sr-only">Email address</label>
      <input id="pc-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!error} aria-describedby={error ? "pc-err" : undefined} placeholder="you@example.com" className="bg-background text-foreground focus-visible:ring-ring/50 placeholder:text-muted-foreground aria-[invalid=true]:border-destructive h-12 flex-1 rounded-full border px-5 outline-none focus-visible:ring-[3px]" />
      <button type="submit" className="bg-chart-1 h-12 rounded-full px-6 font-bold text-[var(--podcast-on-amber)] outline-none transition-transform hover:translate-y-[var(--bm-lift,-2px)] focus-visible:ring-[3px] focus-visible:ring-ring motion-reduce:transition-none">Send me show notes</button>
    </form>
    {error && <p id="pc-err" role="alert" className="bg-background text-destructive mt-3 inline-block rounded-full px-4 py-1.5 text-sm font-semibold">{error}</p>}
    </div>
  )
}

type PodcastHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PodcastHrefs> }

/** The Podcast home page: the latest episode with a play button, the recent list, the hosts and a show-notes sign-up. The mini player appears when you play. */
function PodcastHome({ hrefs, ...props }: PodcastHomeProps) {
  const [latest, ...rest] = episodes
  const ep = hrefs?.episode ?? "/podcast/episodes/the-optimised-life"
  return (
    <PodcastShell page="home" hrefs={hrefs} {...props}>
      <main>
        <section className="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-12 sm:px-6 sm:pt-20 md:grid-cols-[1.15fr_1fr]">
          <div>
            <p className={eyebrow}>New episode · {formatDate(latest.date)}</p>
            <h1 className={cn("mt-4 text-[clamp(2.8rem,7vw,5.5rem)] leading-[1.02] text-balance", podcastDisplayClass)}>Long dinners. Good questions. No agenda.</h1>
            <p className="text-muted-foreground mt-6 max-w-lg text-xl leading-relaxed text-pretty">The Long Table is a podcast recorded at an actual table, every other Tuesday, with someone worth listening to for an hour.</p>
            <div className="mt-8 flex flex-wrap items-center gap-3">
              <PlayButton ep={latest} big />
              <a href={ep} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-14 items-center gap-2 rounded-full border px-6 font-bold outline-none transition-colors focus-visible:ring-[3px]">Show notes <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a>
            </div>
            <ul aria-label="Listen on" className="mt-8 flex flex-wrap gap-2">{["Apple Podcasts", "Spotify", "YouTube", "RSS"].map((p) => <li key={p}><a href={hrefs?.subscribe ?? "/podcast/subscribe"} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center rounded-full border px-4 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]">{p}</a></li>)}</ul>
          </div>
          <a href={ep} className="group focus-visible:ring-ring/50 block rotate-2 rounded-[2rem] outline-none transition-transform duration-500 hover:rotate-0 focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background motion-reduce:transition-none motion-reduce:hover:rotate-2">
            <ShowArt variant={latest.art} image={latest.image} imageAlt={latest.imageAlt} className="rounded-[2rem] shadow-[0_30px_70px_-30px_oklch(0.3_0.1_340/0.6)]" />
            <span className="sr-only">Episode {latest.n}: {latest.title}</span>
          </a>
        </section>

        <section aria-labelledby="pc-recent" className="mx-auto mt-24 max-w-6xl px-4 sm:px-6">
          <div className="flex items-end justify-between gap-4"><h2 id="pc-recent" className={cn("text-4xl sm:text-5xl", podcastDisplayClass)}>Recent episodes</h2><a href={hrefs?.episodes ?? "/podcast/episodes"} className="text-chart-2 inline-flex items-center gap-1.5 font-bold underline-offset-4 hover:underline">All {episodes.length + 34} episodes <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a></div>
          <ul className="mt-8 divide-y border-y">
            {rest.slice(0, 5).map((e) => (
              <li key={e.slug} className="grid grid-cols-[3.5rem_1fr_auto] items-center gap-4 py-5 sm:grid-cols-[5rem_1fr_auto] sm:gap-6">
                <ShowArt variant={e.art} image={e.image} imageAlt={e.imageAlt} className="rounded-xl" />
                <a href={ep} className="group focus-visible:ring-ring/50 min-w-0 rounded outline-none focus-visible:ring-[3px]">
                  <p className="text-muted-foreground text-xs font-semibold">Ep. {e.n} · {formatDate(e.date)} · {minutes(e.duration)}</p>
                  <h3 className={cn("mt-1 text-xl leading-tight text-balance group-hover:underline underline-offset-4 sm:text-2xl", podcastDisplayClass)}>{e.title}</h3>
                  <p className="text-muted-foreground mt-1 hidden text-pretty sm:block">{e.summary}</p>
                </a>
                <PlayButton ep={e} />
              </li>
            ))}
          </ul>
        </section>

        <section aria-labelledby="pc-hosts" className="mx-auto mt-24 grid max-w-6xl items-center gap-10 px-4 sm:px-6 md:grid-cols-2">
          <div>
            <h2 id="pc-hosts" className={cn("text-4xl sm:text-5xl", podcastDisplayClass)}>Two friends, one table.</h2>
            <p className="text-muted-foreground mt-5 max-w-md text-lg leading-relaxed text-pretty">Nora Vale is a food writer. Sam Okoye is a former radio producer. They started the show to have the long conversations they kept promising each other.</p>
            <a href={hrefs?.hosts ?? "/podcast/hosts"} className="text-chart-2 mt-5 inline-flex items-center gap-1.5 font-bold underline-offset-4 hover:underline">Meet the hosts <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a>
          </div>
          <div aria-hidden="true" className="flex items-center justify-center gap-4">{[["NV", "bg-chart-4"], ["SO", "bg-chart-3"]].map(([i, c], idx) => <div key={i} className={cn("text-[var(--podcast-on-amber)] flex size-40 items-center justify-center rounded-full text-5xl sm:size-52", c, podcastDisplayClass, idx === 1 && "translate-y-8")}>{i}</div>)}</div>
        </section>

        <section className="mx-auto mt-28 max-w-6xl px-4 sm:px-6">
          <div className="bg-primary text-primary-foreground relative rounded-[2rem] px-6 py-14 text-center sm:px-14">
            <h2 className={cn("mx-auto max-w-xl text-4xl text-balance sm:text-5xl", podcastDisplayClass)}>Show notes, in your inbox, before the episode.</h2>
            <p className="mx-auto mt-4 max-w-md opacity-85 text-pretty">One email every other Tuesday: the guest, the three best ideas and the recipe we made.</p>
            <div className="mt-8 flex justify-center"><Subscribe /></div>
          </div>
        </section>
      </main>
    </PodcastShell>
  )
}

export { PodcastHome, PlayButton, type PodcastHomeProps }
```

### `registry/ballmac/components/templates/podcast/podcast-episodes.tsx`

```tsx
// Ballmac UI: Podcast episodes page. https://ui.ballmac.com/templates/template-podcast
"use client"

import * as React from "react"
import { Search } from "lucide-react"

import { episodes, formatDate, minutes } from "@/components/ballmac/templates/podcast/podcast-data"
import { PlayButton } from "@/components/ballmac/templates/podcast/podcast-home"
import { PodcastShell, ShowArt, podcastDisplayClass, type PodcastHrefs } from "@/components/ballmac/templates/podcast/podcast-theme"
import { cn } from "@/lib/utils"

type Season = "All" | "3" | "2"

type PodcastEpisodesProps = React.ComponentProps<"div"> & { hrefs?: Partial<PodcastHrefs> }

/** The Podcast episodes page: search, a season filter and a sort, with a play button on every row. */
function PodcastEpisodes({ hrefs, ...props }: PodcastEpisodesProps) {
  const [query, setQuery] = React.useState("")
  const [season, setSeason] = React.useState<Season>("All")
  const [sort, setSort] = React.useState<"new" | "long">("new")
  const q = query.trim().toLowerCase()
  const list = episodes
    .filter((e) => (season === "All" || String(e.season) === season) && (!q || [e.title, e.guest, e.summary].join(" ").toLowerCase().includes(q)))
    .sort((a, b) => (sort === "new" ? b.n - a.n : b.duration - a.duration))
  const ep = hrefs?.episode ?? "/podcast/episodes/the-optimised-life"
  return (
    <PodcastShell page="episodes" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-4xl px-4 pt-12 sm:px-6 sm:pt-16">
        <h1 className={cn("text-[clamp(3rem,8vw,5.5rem)] leading-none", podcastDisplayClass)}>Episodes</h1>
        <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Every conversation, newest first. Search a guest or an idea.</p>
        <div className="mt-8 flex flex-wrap items-center gap-3">
          <div className="relative w-full sm:w-72">
            <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-4 -translate-y-1/2" aria-hidden="true" />
            <input type="search" aria-label="Search episodes" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search episodes" className="bg-card focus-visible:ring-ring/50 placeholder:text-muted-foreground h-11 w-full rounded-full border pe-4 ps-11 outline-none focus-visible:ring-[3px]" />
          </div>
          <div role="group" aria-label="Season" className="flex gap-1.5">
            {(["All", "3", "2"] as const).map((s) => <button key={s} type="button" aria-pressed={season === s} onClick={() => setSeason(s)} className={cn("focus-visible:ring-ring/50 h-11 rounded-full border px-5 text-sm font-bold outline-none transition-colors focus-visible:ring-[3px]", season === s ? "bg-primary text-primary-foreground border-transparent" : "hover:bg-accent")}>{s === "All" ? "All seasons" : `Season ${s}`}</button>)}
          </div>
          <label className="text-muted-foreground ms-auto flex items-center gap-2 text-sm font-semibold">Sort <select value={sort} onChange={(e) => setSort(e.target.value as "new" | "long")} className="bg-card text-foreground focus-visible:ring-ring/50 h-11 rounded-full border px-4 outline-none focus-visible:ring-[3px]"><option value="new">Newest</option><option value="long">Longest</option></select></label>
        </div>
        <p className="sr-only" role="status">{list.length} episodes</p>
        {list.length === 0 ? (
          <div className="mt-10 rounded-[2rem] border border-dashed p-14 text-center"><p className={cn("text-3xl", podcastDisplayClass)}>No episodes found</p><p className="text-muted-foreground mt-2">Try a different guest, or clear the filters.</p></div>
        ) : (
          <ul className="mt-8 divide-y border-y">
            {list.map((e) => (
              <li key={e.slug} className="grid grid-cols-[4rem_1fr_auto] items-center gap-4 py-6 sm:grid-cols-[6rem_1fr_auto] sm:gap-6">
                <ShowArt variant={e.art} image={e.image} imageAlt={e.imageAlt} className="rounded-2xl" />
                <a href={ep} className="group focus-visible:ring-ring/50 min-w-0 rounded outline-none focus-visible:ring-[3px]">
                  <p className="text-chart-2 text-xs font-extrabold tracking-[0.12em] uppercase">Season {e.season} · Ep. {e.n}</p>
                  <h2 className={cn("mt-1 text-2xl leading-tight text-balance group-hover:underline underline-offset-4 sm:text-3xl", podcastDisplayClass)}>{e.title}</h2>
                  <p className="text-muted-foreground mt-1.5 text-sm">with {e.guest}, {e.guestRole.toLowerCase()} · {formatDate(e.date)} · {minutes(e.duration)}</p>
                  <p className="text-muted-foreground mt-2 hidden text-pretty sm:block">{e.summary}</p>
                </a>
                <PlayButton ep={e} />
              </li>
            ))}
          </ul>
        )}
      </main>
    </PodcastShell>
  )
}

export { PodcastEpisodes, type PodcastEpisodesProps }
```

### `registry/ballmac/components/templates/podcast/podcast-episode.tsx`

```tsx
// Ballmac UI: Podcast episode page. https://ui.ballmac.com/templates/template-podcast
"use client"

import * as React from "react"
import { Search } from "lucide-react"

import { AudioPlayer, formatTime } from "@/components/ballmac/audio-player"
import { episodes, formatDate, getEpisode, minutes, transcript } from "@/components/ballmac/templates/podcast/podcast-data"
import { PodcastShell, ShowArt, podcastDisplayClass, type PodcastHrefs } from "@/components/ballmac/templates/podcast/podcast-theme"
import { cn } from "@/lib/utils"

const eyebrow = "text-chart-2 text-xs font-extrabold tracking-[0.14em] uppercase"

type PodcastEpisodeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PodcastHrefs>; slug?: string }

/** The Podcast episode page: a player whose chapters and transcript follow along, a searchable transcript you can click to seek, show notes and more episodes. */
function PodcastEpisode({ hrefs, slug = "the-optimised-life", ...props }: PodcastEpisodeProps) {
  const ep = getEpisode(slug)
  const [time, setTime] = React.useState(0)
  const [follow, setFollow] = React.useState(true)
  const [query, setQuery] = React.useState("")
  const box = React.useRef<HTMLOListElement>(null)
  const q = query.trim().toLowerCase()
  const current = [...transcript].reverse().find((l) => time >= l.t)
  const matches = q ? transcript.filter((l) => l.text.toLowerCase().includes(q)).length : 0

  // Keep the spoken line in view when following along.
  React.useEffect(() => {
    if (!follow || !box.current) return
    const el = box.current.querySelector<HTMLElement>("[data-current='true']")
    const parent = box.current.parentElement
    if (el && parent && typeof parent.scrollTo === "function") parent.scrollTo({ top: Math.max(0, el.offsetTop - 96), behavior: "smooth" })
  }, [current?.t, follow])

  const marks = (text: string) => {
    if (!q) return text
    const i = text.toLowerCase().indexOf(q)
    return i < 0 ? text : <>{text.slice(0, i)}<mark className="bg-chart-1 rounded-sm px-0.5 text-[var(--podcast-on-amber)]">{text.slice(i, i + q.length)}</mark>{text.slice(i + q.length)}</>
  }

  return (
    <PodcastShell page="episode" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-5xl px-4 pt-10 sm:px-6 sm:pt-14">
        <p className={eyebrow}>Season {ep.season} · Episode {ep.n}</p>
        <h1 className={cn("mt-3 text-[clamp(2.6rem,6.4vw,4.8rem)] leading-[1.02] text-balance", podcastDisplayClass)}>{ep.title}</h1>
        <p className="text-muted-foreground mt-4 max-w-2xl text-xl leading-relaxed text-pretty">{ep.summary}</p>
        <p className="text-muted-foreground mt-4 text-sm">{formatDate(ep.date)} · {minutes(ep.duration)} · with <span className="text-foreground font-semibold">{ep.guest}</span></p>

        <div className="mt-8">
          <AudioPlayer title={`${ep.n}. ${ep.title}`} subtitle={`The Long Table · ${minutes(ep.duration)}`} duration={ep.duration} chapters={ep.chapters} time={time} onTimeChange={setTime} artwork={<ShowArt variant={ep.art} image={ep.image} imageAlt={ep.imageAlt} />} />
        </div>

        <div className="mt-14 grid gap-12 lg:grid-cols-[minmax(0,1fr)_18rem]">
          <section aria-labelledby="pe-transcript">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <h2 id="pe-transcript" className={cn("text-3xl", podcastDisplayClass)}>Transcript</h2>
              <label className="flex cursor-pointer items-center gap-2 text-sm font-semibold"><input type="checkbox" checked={follow} onChange={(e) => setFollow(e.target.checked)} className="accent-primary size-4" />Follow along</label>
            </div>
            <div className="relative mt-4">
              <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-4 -translate-y-1/2" aria-hidden="true" />
              <input type="search" aria-label="Search the transcript" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the transcript" className="bg-card focus-visible:ring-ring/50 placeholder:text-muted-foreground h-11 w-full rounded-full border pe-4 ps-11 outline-none focus-visible:ring-[3px]" />
            </div>
            <p className="text-muted-foreground mt-2 text-sm" role="status">{q ? `${matches} ${matches === 1 ? "match" : "matches"}` : "Click any line to jump to it."}</p>
            <div tabIndex={0} role="region" aria-label="Transcript" className="focus-visible:ring-ring/50 bg-card mt-3 max-h-[28rem] overflow-y-auto rounded-3xl border p-3 outline-none focus-visible:ring-[3px]">
              <ol ref={box} className="space-y-1">
                {transcript.map((l) => {
                  const on = current?.t === l.t
                  return (
                    <li key={l.t} data-current={on}>
                      <button type="button" onClick={() => setTime(l.t)} aria-current={on ? "true" : undefined} className={cn("focus-visible:ring-ring/50 grid w-full grid-cols-[3.2rem_4rem_1fr] gap-2 rounded-2xl px-3 py-3 text-start outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", on ? "bg-chart-1 text-[var(--podcast-on-amber)]" : "hover:bg-accent")}>
                        <span className={cn("text-xs font-bold tabular-nums", !on && "text-chart-2")}>{formatTime(l.t)}</span>
                        <span className="text-sm font-extrabold">{l.who}</span>
                        <span className="text-pretty">{marks(l.text)}</span>
                      </button>
                    </li>
                  )
                })}
              </ol>
            </div>
          </section>

          <aside className="space-y-8">
            <section aria-labelledby="pe-chapters">
              <h2 id="pe-chapters" className={cn("text-2xl", podcastDisplayClass)}>Chapters</h2>
              <ol className="mt-3 divide-y border-y">{ep.chapters.map((c) => <li key={c.start}><button type="button" onClick={() => setTime(c.start)} className="hover:bg-accent focus-visible:ring-ring/50 flex w-full items-baseline justify-between gap-3 rounded px-1 py-3 text-start outline-none focus-visible:ring-[3px]"><span className="font-semibold text-pretty">{c.title}</span><span className="text-muted-foreground text-sm tabular-nums">{formatTime(c.start)}</span></button></li>)}</ol>
            </section>
            <section aria-labelledby="pe-guest" className="bg-secondary rounded-3xl p-5">
              <h2 id="pe-guest" className="text-xs font-extrabold tracking-[0.14em] uppercase">Today’s guest</h2>
              <p className={cn("mt-3 text-2xl", podcastDisplayClass)}>{ep.guest}</p>
              <p className="text-muted-foreground text-sm">{ep.guestRole}</p>
              <ul className="mt-4 space-y-2 text-sm font-semibold"><li><a href="#" className="text-chart-2 underline underline-offset-4">Her website</a></li><li><a href="#" className="text-chart-2 underline underline-offset-4">The essay we discuss</a></li></ul>
            </section>
          </aside>
        </div>

        <section aria-labelledby="pe-more" className="mt-20">
          <h2 id="pe-more" className={cn("text-3xl", podcastDisplayClass)}>More conversations</h2>
          <ul className="mt-6 grid gap-6 sm:grid-cols-3">{episodes.filter((e) => e.slug !== ep.slug).slice(0, 3).map((e) => <li key={e.slug}><a href={hrefs?.episode ?? "#"} className="group focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px]"><ShowArt variant={e.art} image={e.image} imageAlt={e.imageAlt} className="rounded-3xl" /><p className="text-muted-foreground mt-3 text-xs font-semibold">Ep. {e.n} · {minutes(e.duration)}</p><h3 className={cn("mt-1 text-xl leading-tight text-balance group-hover:underline underline-offset-4", podcastDisplayClass)}>{e.title}</h3></a></li>)}</ul>
        </section>
      </main>
    </PodcastShell>
  )
}

export { PodcastEpisode, type PodcastEpisodeProps }
```

### `registry/ballmac/components/templates/podcast/podcast-hosts.tsx`

```tsx
// Ballmac UI: Podcast hosts page. https://ui.ballmac.com/templates/template-podcast
"use client"

import * as React from "react"
import { Mail } from "lucide-react"

import { PodcastShell, podcastDisplayClass, type PodcastHrefs } from "@/components/ballmac/templates/podcast/podcast-theme"
import { cn } from "@/lib/utils"

const hosts = [
  { name: "Nora Vale", role: "Host and food writer", bio: "Nora has written about food for fifteen years and cooks every dinner we record. She believes most arguments end better with a second helping.", tone: "bg-chart-4", initials: "NV", fact: "Cannot eat a meal without asking where the recipe came from." },
  { name: "Sam Okoye", role: "Host and producer", bio: "Sam spent a decade making radio before the long table. He asks the second question, the one after the polite answer.", tone: "bg-chart-3", initials: "SO", fact: "Still keeps a drawer of cassette tapes he will never digitise." },
]
const guests = ["Amara Okafor", "Tomás Reyes", "Inês Marques", "Elena Rossi", "Dev Patel", "Mei Tanaka", "Sasha Petrova", "Marcus Lindqvist", "Priya Raman", "Colm Brennan", "Jonas Eklund", "Yuki Tanaka"]

type PodcastHostsProps = React.ComponentProps<"div"> & { hrefs?: Partial<PodcastHrefs> }

/** The Podcast hosts page: two hosts with bios, a wall of past guests and a way to write in. */
function PodcastHosts({ hrefs, ...props }: PodcastHostsProps) {
  return (
    <PodcastShell page="hosts" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-5xl px-4 pt-12 sm:px-6 sm:pt-16">
        <h1 className={cn("text-[clamp(3rem,8vw,5.5rem)] leading-none", podcastDisplayClass)}>Meet the hosts</h1>
        <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Two friends, one table, and a recording light that is mostly a candle.</p>
        <ul className="mt-12 grid gap-8 md:grid-cols-2">
          {hosts.map((h) => (
            <li key={h.name} className="bg-card rounded-[2rem] border p-8">
              <div aria-hidden="true" className={cn("text-[var(--podcast-on-amber)] flex size-32 items-center justify-center rounded-full text-5xl", h.tone, podcastDisplayClass)}>{h.initials}</div>
              <h2 className={cn("mt-6 text-3xl", podcastDisplayClass)}>{h.name}</h2>
              <p className="text-chart-2 text-sm font-bold">{h.role}</p>
              <p className="text-muted-foreground mt-4 text-lg leading-relaxed text-pretty">{h.bio}</p>
              <p className="bg-secondary mt-5 rounded-2xl p-4 text-sm text-pretty"><span className="font-bold">Fun fact: </span>{h.fact}</p>
            </li>
          ))}
        </ul>
        <section aria-labelledby="ph-guests" className="mt-20">
          <h2 id="ph-guests" className={cn("text-4xl", podcastDisplayClass)}>Everyone who sat down</h2>
          <ul className="mt-8 flex flex-wrap gap-2">{guests.map((g) => <li key={g} className="bg-secondary rounded-full px-4 py-2 text-sm font-semibold">{g}</li>)}</ul>
        </section>
        <section aria-labelledby="ph-write" className="bg-chart-1 mt-20 rounded-[2rem] p-8 text-[var(--podcast-on-amber)] sm:p-12">
          <h2 id="ph-write" className={cn("text-4xl", podcastDisplayClass)}>Write to the table</h2>
          <p className="mt-3 max-w-lg text-lg text-pretty">Suggest a guest, argue with an episode or send us a recipe. We read every message and answer the good questions on air.</p>
          <a href="mailto:hello@longtable.example" className="bg-primary text-primary-foreground focus-visible:ring-ring mt-6 inline-flex h-12 items-center gap-2 rounded-full px-6 font-bold outline-none transition-transform hover:translate-y-[var(--bm-lift,-2px)] focus-visible:ring-[3px] motion-reduce:transition-none"><Mail className="size-4" aria-hidden="true" />hello@longtable.example</a>
        </section>
      </main>
    </PodcastShell>
  )
}

export { PodcastHosts, type PodcastHostsProps }
```

### `registry/ballmac/components/templates/podcast/podcast-subscribe.tsx`

```tsx
// Ballmac UI: Podcast subscribe page. https://ui.ballmac.com/templates/template-podcast
"use client"

import * as React from "react"
import { Check, Heart } from "lucide-react"

import { CopyButton } from "@/components/ballmac/copy-button"
import { PodcastShell, podcastDisplayClass, type PodcastHrefs } from "@/components/ballmac/templates/podcast/podcast-theme"
import { cn } from "@/lib/utils"

const platforms = [["Apple Podcasts", "Open in Podcasts"], ["Spotify", "Follow on Spotify"], ["YouTube", "Watch the full dinners"], ["Overcast", "Subscribe"], ["Pocket Casts", "Subscribe"], ["Amazon Music", "Listen"]]
const tiers = [
  { id: "guest", name: "Guest", price: 5, perks: ["Ad-free episodes", "Bonus recipe card each episode"] },
  { id: "regular", name: "Regular", price: 10, perks: ["Everything in Guest", "The unedited hour after each recording", "Name in the credits"], featured: true },
  { id: "host", name: "Host the dinner", price: 25, perks: ["Everything in Regular", "Vote on the next guest", "A yearly letter from Nora and Sam"] },
]

type PodcastSubscribeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PodcastHrefs> }

/** The Podcast subscribe page: every platform, the RSS feed to copy, and three ways to support the show. */
function PodcastSubscribe({ hrefs, ...props }: PodcastSubscribeProps) {
  const [tier, setTier] = React.useState("regular")
  return (
    <PodcastShell page="subscribe" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-5xl px-4 pt-12 sm:px-6 sm:pt-16">
        <h1 className={cn("text-[clamp(3rem,8vw,5.5rem)] leading-none", podcastDisplayClass)}>Pull up a chair</h1>
        <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Follow The Long Table wherever you listen. New episodes land every other Tuesday at 6am.</p>
        <ul className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
          {platforms.map(([p, l]) => <li key={p}><a href="#" className="bg-card hover:bg-accent focus-visible:ring-ring/50 group flex items-center gap-4 rounded-3xl border p-5 outline-none transition-colors focus-visible:ring-[3px]"><span className="bg-chart-1 flex size-12 shrink-0 items-center justify-center rounded-2xl text-lg font-extrabold text-[var(--podcast-on-amber)]" aria-hidden="true">{p[0]}</span><span><span className="block font-bold">{p}</span><span className="text-muted-foreground text-sm">{l}</span></span></a></li>)}
        </ul>
        <section aria-labelledby="ps-rss" className="bg-secondary mt-8 flex flex-wrap items-center gap-4 rounded-3xl p-5">
          <div className="min-w-0 flex-1"><h2 id="ps-rss" className="font-bold">RSS feed</h2><p className="text-muted-foreground truncate font-mono text-sm">https://feeds.longtable.example/the-long-table.xml</p></div>
          <CopyButton value="https://feeds.longtable.example/the-long-table.xml" label="Copy feed" variant="outline" />
        </section>

        <section aria-labelledby="ps-support" className="mt-24">
          <h2 id="ps-support" className={cn("text-4xl sm:text-5xl", podcastDisplayClass)}>Keep the candle lit</h2>
          <p className="text-muted-foreground mt-3 max-w-xl text-lg text-pretty">The show is made by three people and paid for by listeners. No sponsors in the middle of the conversation, ever.</p>
          <div role="radiogroup" aria-label="Choose a way to support the show" className="mt-8 grid gap-4 md:grid-cols-3">
            {tiers.map((t) => {
              const on = tier === t.id
              return (
                <label key={t.id} className={cn("has-[:focus-visible]:ring-ring/50 relative flex cursor-pointer flex-col rounded-3xl border-2 p-6 transition-colors has-[:focus-visible]:ring-[3px]", on ? "border-primary bg-card" : "hover:bg-accent/50 border-transparent bg-secondary")}>
                  <input type="radio" name="tier" value={t.id} checked={on} onChange={() => setTier(t.id)} className="sr-only" />
                  {"featured" in t && t.featured && <span className="bg-chart-1 absolute -top-3 start-6 rounded-full px-3 py-0.5 text-xs font-extrabold text-[var(--podcast-on-amber)]">Most chosen</span>}
                  <span className={cn("text-2xl", podcastDisplayClass)}>{t.name}</span>
                  <span className="mt-3 flex items-baseline gap-1"><span className="text-5xl font-extrabold tabular-nums">${t.price}</span><span className="text-muted-foreground text-sm">a month</span></span>
                  <ul className="mt-5 space-y-2.5">{t.perks.map((p) => <li key={p} className="flex items-start gap-2.5 text-sm text-pretty"><Check className="text-chart-3 mt-0.5 size-4 shrink-0" aria-hidden="true" />{p}</li>)}</ul>
                  <span className={cn("mt-6 inline-flex items-center gap-1.5 text-sm font-bold", on ? "text-chart-2" : "text-muted-foreground")} aria-hidden="true">{on ? <><Check className="size-4" />Selected</> : "Choose"}</span>
                </label>
              )
            })}
          </div>
          <a href="#" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-8 inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:translate-y-[var(--bm-lift,-2px)] focus-visible:ring-[3px] motion-reduce:transition-none"><Heart className="size-5" aria-hidden="true" />Support at ${tiers.find((t) => t.id === tier)?.price} a month</a>
        </section>
      </main>
    </PodcastShell>
  )
}

export { PodcastSubscribe, type PodcastSubscribeProps }
```

### `registry/ballmac/app/podcast/page.tsx`

```tsx
// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast
import type { Metadata } from "next"

import { PodcastHome } from "@/components/ballmac/templates/podcast/podcast-home"

export const metadata: Metadata = {
  title: "The Long Table: a podcast recorded at a real table",
  description: "Long dinners, good questions, no agenda. New episodes every other Tuesday.",
}

export default function Page() {
  return <PodcastHome />
}
```

### `registry/ballmac/app/podcast/episodes/page.tsx`

```tsx
// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast
import type { Metadata } from "next"

import { PodcastEpisodes } from "@/components/ballmac/templates/podcast/podcast-episodes"

export const metadata: Metadata = {
  title: "Episodes · The Long Table",
  description: "Every conversation, newest first.",
}

export default function Page() {
  return <PodcastEpisodes />
}
```

### `registry/ballmac/app/podcast/episodes/[slug]/page.tsx`

```tsx
// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast
import type { Metadata } from "next"
import { Suspense } from "react"

import { PodcastEpisode } from "@/components/ballmac/templates/podcast/podcast-episode"

export const metadata: Metadata = {
  title: "The Optimised Life · The Long Table",
  description: "A year without metrics, and what was left when she stopped counting.",
}

// Reading `params` is runtime data: with Cache Components (on by default in new Next.js 16.4 projects) it has to sit inside <Suspense>.
export default function Page({ params }: { params: Promise<{ slug: string }> }) {
  return (
    <Suspense fallback={null}>
      <EpisodeView params={params} />
    </Suspense>
  )
}

async function EpisodeView({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return <PodcastEpisode slug={slug} />
}
```

### `registry/ballmac/app/podcast/hosts/page.tsx`

```tsx
// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast
import type { Metadata } from "next"

import { PodcastHosts } from "@/components/ballmac/templates/podcast/podcast-hosts"

export const metadata: Metadata = {
  title: "Hosts · The Long Table",
  description: "Meet Nora Vale and Sam Okoye.",
}

export default function Page() {
  return <PodcastHosts />
}
```

### `registry/ballmac/app/podcast/subscribe/page.tsx`

```tsx
// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast
import type { Metadata } from "next"

import { PodcastSubscribe } from "@/components/ballmac/templates/podcast/podcast-subscribe"

export const metadata: Metadata = {
  title: "Subscribe · The Long Table",
  description: "Follow the show on any platform, or support it directly.",
}

export default function Page() {
  return <PodcastSubscribe />
}
```
