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.
// 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 })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`// 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-0.5 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 }// 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-0.5 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 }// 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 }// 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 }// 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-0.5 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 }// 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-0.5 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 }// 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 />
}// 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 />
}// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast
import type { Metadata } from "next"
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.",
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
return <PodcastEpisode slug={slug} />
}// 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 />
}// 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 />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-podcastInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/audio-player @ballmac/copy-button @ballmac/mediaCopy the source into your project.
components/ballmac/templates/podcast/podcast-fonts.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 })components/ballmac/templates/podcast/podcast-data.tsimport 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`components/ballmac/templates/podcast/podcast-theme.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-0.5 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 }components/ballmac/templates/podcast/podcast-home.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-0.5 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 }components/ballmac/templates/podcast/podcast-episodes.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 }components/ballmac/templates/podcast/podcast-episode.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 }components/ballmac/templates/podcast/podcast-hosts.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-0.5 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 }components/ballmac/templates/podcast/podcast-subscribe.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-0.5 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 }app/podcast/page.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 /> }app/podcast/episodes/page.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 /> }app/podcast/episodes/[slug]/page.tsx// Ballmac UI: Podcast template route. https://ui.ballmac.com/templates/template-podcast import type { Metadata } from "next" 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.", } export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params return <PodcastEpisode slug={slug} /> }app/podcast/hosts/page.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 /> }app/podcast/subscribe/page.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 /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Home/podcast
- Episodes/podcast/episodes
- Episode/podcast/episodes/the-optimised-life
- Hosts/podcast/hosts
- Subscribe/podcast/subscribe
- Fonts
- Young Serif, Nunito Sans (Google Fonts, loaded with next/font)
- Files
- 13 files · 3 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
Installs a five-page podcast site on the audio-player component. Change episodes in podcast-data.ts, add src URLs for real audio, and edit podcastCss for the palette. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI The Long Table: podcast (@ballmac/template-podcast) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Podcasts, audio shows and interview series
- Any site where a transcript should follow audio playback
Not for
- Music streaming with playlists and queues
Registry JSON: https://ui.ballmac.com/r/template-podcast.json
Free to use in personal and commercial projects.