Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions apps/registry/app/components/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";

import { PreviewPlaygroundTabs } from "@/components/playground";
import { QuickAdd } from "@/components/quick-add";
import { StorybookEmbed } from "@/components/storybook-embed";
import componentMetadata from "@/lib/component-metadata.json";
import {
breadcrumbLd,
jsonLdScript,
softwareSourceCodeLd,
} from "@/lib/jsonld";
import { breadcrumbLd, jsonLdScript, softwareSourceCodeLd } from "@/lib/jsonld";
import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og";
import {
getPlaygroundExample,
getRegistryPackageVersion,
} from "@/lib/playground";
import { canonical } from "@/lib/seo";
import {
getCategoryForComponent,
Expand Down Expand Up @@ -104,6 +104,8 @@ export default async function ComponentPage(props: Props) {
const meta = metadata_map[slug];
const displayTitle = meta?.title ?? component.title ?? component.name;
const displayDescription = meta?.description ?? component.description ?? "";
const playgroundExample = getPlaygroundExample(component);
const registryPackageVersion = getRegistryPackageVersion(registry.version);

// Read component source for code display
let componentCode = "";
Expand Down Expand Up @@ -159,15 +161,18 @@ export default async function ComponentPage(props: Props) {

const sections = [
{ id: "installation", title: "Installation" },
...(meta?.defaultStoryId ? [{ id: "preview", title: "Preview" }] : []),
...(meta?.defaultStoryId
? [{ id: "playground", title: "Playground" }]
: []),
...(meta?.defaultStoryId ? [{ id: "storybook", title: "Storybook" }] : []),
...(componentCode ? [{ id: "code", title: "Code" }] : []),
...(component.dependencies && component.dependencies.length > 0
? [{ id: "dependencies", title: "Dependencies" }]
: []),
] as { id: string; title: string }[];

const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai";

return (
<>
Expand Down Expand Up @@ -221,16 +226,14 @@ export default async function ComponentPage(props: Props) {
</div>
</div>

{/* Preview — Storybook Embed */}
{/* Preview + Playground */}
{meta?.defaultStoryId ? (
<div className="mb-8 scroll-mt-8">
<div className="rounded-lg border bg-card overflow-hidden">
<StorybookEmbed
componentName={component.name}
storyId={meta.defaultStoryId}
/>
</div>
</div>
<PreviewPlaygroundTabs
componentName={component.name}
example={playgroundExample}
packageVersion={registryPackageVersion}
storyId={meta.defaultStoryId}
/>
) : null}

{/* Installation */}
Expand Down
141 changes: 141 additions & 0 deletions apps/registry/app/components/[slug]/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Breadcrumb, Sidebar } from "@vllnt/ui";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";

import { ComponentPlaygroundShell } from "@/components/playground";
import componentMetadata from "@/lib/component-metadata.json";
import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og";
import {
getPlaygroundExample,
getRegistryPackageVersion,
} from "@/lib/playground";
import { canonical } from "@/lib/seo";
import {
getCategoryForComponent,
getSidebarSections,
} from "@/lib/sidebar-sections";
import registryData from "@/registry.json";
import type { Registry, RegistryComponent } from "@/types/registry";

type Props = {
params: Promise<{ slug: string }>;
};

const registry = registryData as Registry;
const metadata_map = componentMetadata as Record<
string,
{
category: string;
defaultStoryId: string;
description: string;
name: string;
stories: { id: string; name: string }[];
title: string;
}
>;

function findComponent(slug: string): RegistryComponent | undefined {
return registry.items.find(
(item): item is RegistryComponent =>
item.name === slug && item.type === "registry:component",
);
}

export async function generateStaticParams() {
return registry.items
.filter(
(item): item is RegistryComponent => item.type === "registry:component",
)
.map((item) => ({
slug: item.name,
}));
}

export async function generateMetadata(props: Props): Promise<Metadata> {
const { slug } = await props.params;
const component = findComponent(slug);

if (!component) {
return {};
}

const meta = metadata_map[slug];
const title = meta?.title ?? component.title;
const description =
meta?.description ??
component.description ??
"Edit a live VLLNT UI component sandbox.";

const ogParameters = {
category: getCategoryForComponent(slug),
description,
title,
type: "component" as const,
};

return {
alternates: { canonical: canonical(`/components/${slug}/playground`) },
description,
openGraph: generateOGMetadata(ogParameters),
title: `${title} Playground - VLLNT UI`,
twitter: generateTwitterMetadata(ogParameters),
};
}

export default async function ComponentPlaygroundPage(props: Props) {
const { slug } = await props.params;
const component = findComponent(slug);

if (!component) {
notFound();
}

const meta = metadata_map[slug];
const displayTitle = meta?.title ?? component.title ?? component.name;
const displayDescription =
meta?.description ?? component.description ?? "Edit this component live.";
const playgroundExample = getPlaygroundExample(component);
const registryPackageVersion = getRegistryPackageVersion(registry.version);

return (
<>
<Sidebar sections={getSidebarSections(getCategoryForComponent(slug))} />
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-background">
<div className="mx-auto max-w-7xl px-4 py-8 lg:px-8">
<Breadcrumb
className="mb-4 text-muted-foreground"
items={[
{ href: "/", label: "Home" },
{ href: "/components", label: "Components" },
{ href: `/components/${component.name}`, label: displayTitle },
{ label: "Playground" },
]}
/>
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-4xl font-semibold mb-2">
{displayTitle} Playground
</h1>
<p className="max-w-3xl text-lg text-muted-foreground">
{displayDescription}
</p>
</div>
<Link
className="inline-flex h-9 items-center rounded-md border border-border px-4 text-sm font-medium hover:bg-muted"
href={`/components/${component.name}`}
>
Back to component
</Link>
</div>
<ComponentPlaygroundShell
componentName={component.name}
example={playgroundExample}
packageVersion={registryPackageVersion}
surface="route"
/>
</div>
</main>
</>
);
}
43 changes: 43 additions & 0 deletions apps/registry/components/playground/component-playground-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import * as React from "react";

import dynamic from "next/dynamic";

import type { PlaygroundExample } from "@/lib/playground";

type ComponentPlaygroundShellProps = {
componentName: string;
example: PlaygroundExample;
packageVersion: string;
surface: "inline" | "route";
};

const SandpackPlayground = dynamic(
() =>
import("./sandpack-playground").then((module) => module.SandpackPlayground),
{
loading: () => (
<div className="flex min-h-[460px] items-center justify-center rounded-lg border bg-muted/30">
<p className="text-sm text-muted-foreground">Loading playground...</p>
</div>
),
ssr: false,
},
);

export function ComponentPlaygroundShell({
componentName,
example,
packageVersion,
surface,
}: ComponentPlaygroundShellProps): React.ReactElement {
return (
<SandpackPlayground
componentName={componentName}
example={example}
packageVersion={packageVersion}
surface={surface}
/>
);
}
2 changes: 2 additions & 0 deletions apps/registry/components/playground/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ComponentPlaygroundShell } from "./component-playground-shell";
export { PreviewPlaygroundTabs } from "./preview-playground-tabs";
89 changes: 89 additions & 0 deletions apps/registry/components/playground/preview-playground-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import * as React from "react";

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@vllnt/ui";
import { ExternalLink } from "lucide-react";
import Link from "next/link";

import type { PlaygroundExample } from "@/lib/playground";

import { StorybookEmbed } from "../storybook-embed";

import { ComponentPlaygroundShell } from "./component-playground-shell";

type PreviewPlaygroundTabsProps = {
componentName: string;
example: PlaygroundExample;
packageVersion: string;
storyId: string;
};

export function PreviewPlaygroundTabs({
componentName,
example,
packageVersion,
storyId,
}: PreviewPlaygroundTabsProps): React.ReactElement {
const [activeTab, setActiveTab] = React.useState("preview");

React.useEffect(() => {
function selectHashTab(): void {
if (window.location.hash === "#playground") {
setActiveTab("playground");
} else if (window.location.hash === "#preview") {
setActiveTab("preview");
}
}

selectHashTab();
window.addEventListener("hashchange", selectHashTab);

return () => {
window.removeEventListener("hashchange", selectHashTab);
};
}, []);

return (
<div className="mb-8 scroll-mt-8" id="preview">
<span aria-hidden="true" className="block scroll-mt-8" id="playground" />
<Tabs
className="my-0"
defaultValue={activeTab}
key={activeTab}
onValueChange={setActiveTab}
>
<div className="flex items-center justify-between gap-4 border-b">
<TabsList className="border-b-0">
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger className="hidden md:inline-flex" value="playground">
Playground
</TabsTrigger>
</TabsList>
<Link
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground md:hidden"
href={`/components/${componentName}/playground`}
>
Open in playground
<ExternalLink className="h-4 w-4" />
</Link>
</div>
<TabsContent className="pt-4" value="preview">
<div className="overflow-hidden rounded-lg border bg-card">
<StorybookEmbed componentName={componentName} storyId={storyId} />
</div>
</TabsContent>
<TabsContent className="hidden pt-4 md:block" value="playground">
<div>
<ComponentPlaygroundShell
componentName={componentName}
example={example}
packageVersion={packageVersion}
surface="inline"
/>
</div>
</TabsContent>
</Tabs>
</div>
);
}
Loading
Loading