Skip to content
Merged
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
212 changes: 212 additions & 0 deletions docs/app/(home)/integrations/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { Footer } from "@/app/(home)/sections/Footer/Footer";
import { ArrowLeft, ArrowRight, ArrowUpRight, Check } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CopyCommand } from "../copy-command";
import {
getIntegrationCategory,
getRelatedIntegrations,
integrationBySlug,
integrations,
} from "../data";
import { IntegrationLogo } from "../integration-logo";
import styles from "../page.module.css";

export const dynamicParams = false;

export function generateStaticParams(): { slug: string }[] {
return integrations.map((integration) => ({ slug: integration.slug }));
}

export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await props.params;
const integration = integrationBySlug.get(slug);
if (!integration) notFound();

return {
title: `${integration.name} integration`,
description: integration.summary,
alternates: { canonical: `/integrations/${integration.slug}` },
openGraph: {
title: `${integration.name} × OpenUI`,
description: integration.summary,
url: `/integrations/${integration.slug}`,
type: "article",
},
};
}

export default async function IntegrationDetailPage(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const integration = integrationBySlug.get(slug);
if (!integration) notFound();

const category = getIntegrationCategory(integration.category);
const related = getRelatedIntegrations(integration);

return (
<main className={styles.detailPage} data-accent={category.accent}>
<div className={styles.detailHero}>
<div className={styles.detailHeroInner}>
<Link className={styles.detailBackLink} href="/integrations">
<ArrowLeft size={15} aria-hidden="true" />
All integrations
</Link>

<div className={styles.detailLockup}>
<IntegrationLogo className={styles.detailMark} integration={integration} />
<div className={styles.detailTitleBlock}>
<div className={styles.detailTags}>
<span>{integration.type}</span>
</div>
<h1>{integration.name}</h1>
<p>{integration.summary}</p>
</div>
</div>
</div>
</div>

<div className={styles.detailBand}>
<div className={styles.detailLayout}>
<article className={styles.detailArticle}>
<section>
<p className={styles.detailEyebrow}>Integration overview</p>
<h2>How it connects to OpenUI</h2>
<p className={styles.detailLead}>{integration.howItWorks}</p>
</section>

<section className={styles.flowSection}>
<h2>The integration path</h2>
<ol className={styles.flowList}>
<li>
<span className={styles.flowNumber}>1</span>
<div>
<h3>Describe the interface</h3>
<p>
Generate a component prompt from the same OpenUI library that will render the
response.
</p>
</div>
</li>
<li>
<span className={styles.flowNumber}>2</span>
<div>
<h3>Stream structured output</h3>
<p>
Let {integration.name} own its part of the stack while OpenUI Lang travels as
incremental text or mapped agent events.
</p>
</div>
</li>
<li>
<span className={styles.flowNumber}>3</span>
<div>
<h3>Render and interact</h3>
<p>
Parse the stream with the matching OpenUI runtime, render real components, and
return validated actions to the application.
</p>
</div>
</li>
</ol>
</section>

{integration.install ? (
<section className={styles.installSection}>
<div>
<p className={styles.detailEyebrow}>Start here</p>
<h2>Install or scaffold</h2>
</div>
<div className={styles.codeBlock}>
<code>{integration.install}</code>
<CopyCommand command={integration.install} />
</div>
</section>
) : null}

<section className={styles.checklistSection}>
<h2>What stays consistent</h2>
<div className={styles.checkGrid}>
{[
"One schema for prompting and rendering",
"Progressive rendering while output streams",
"Typed components instead of arbitrary markup",
"Host application retains its runtime behavior",
].map((item) => (
<div className={styles.checkItem} key={item}>
<Check size={15} aria-hidden="true" />
<span>{item}</span>
</div>
))}
</div>
</section>
</article>

<aside className={styles.resourceSidebar}>
<div className={styles.resourceCard}>
<p className={styles.resourceTitle}>Resources</p>
<div className={styles.resourceLinks}>
{integration.links.map((link) => {
const external = link.href.startsWith("http");
const content = (
<>
<span>
<small>{link.kind}</small>
{link.label}
</span>
{external ? (
<ArrowUpRight size={15} aria-hidden="true" />
) : (
<ArrowRight size={15} aria-hidden="true" />
)}
</>
);

return external ? (
<a
href={link.href}
key={`${link.kind}-${link.href}`}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
) : (
<Link href={link.href} key={`${link.kind}-${link.href}`}>
{content}
</Link>
);
})}
</div>
</div>

<div className={styles.resourceCard}>
<p className={styles.resourceTitle}>Related {category.shortTitle.toLowerCase()}</p>
<div className={styles.relatedLinks}>
{related.map((item) => (
<Link href={`/integrations/${item.slug}`} key={item.slug}>
<IntegrationLogo className={styles.relatedMark} integration={item} />
<span>{item.name}</span>
<ArrowRight size={14} aria-hidden="true" />
</Link>
))}
</div>
</div>
</aside>
</div>

<div className={styles.detailBackRow}>
<Link href={`/integrations#${category.id}`}>
Browse {category.shortTitle.toLowerCase()}
<ArrowRight size={16} aria-hidden="true" />
</Link>
</div>
</div>

<Footer />
</main>
);
}
45 changes: 45 additions & 0 deletions docs/app/(home)/integrations/category-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import type { MouseEvent } from "react";
import styles from "./page.module.css";

interface CategoryNavItem {
count: number;
id: string;
label: string;
}

export function CategoryNav({ categories }: { categories: CategoryNavItem[] }) {
const scrollToCategory = (event: MouseEvent<HTMLAnchorElement>, id: string) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}

const target = document.getElementById(id);
if (!target) return;

event.preventDefault();
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
target.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "start" });

const url = new URL(window.location.href);
url.hash = id;
window.history.pushState(null, "", url);
};

return (
<nav className={styles.categoryNav} aria-label="Integration categories">
{categories.map((category) => (
<a
className={styles.categoryNavLink}
href={`#${category.id}`}
key={category.id}
onClick={(event) => scrollToCategory(event, category.id)}
>
{category.label}
<span>{category.count}</span>
</a>
))}
</nav>
);
}
38 changes: 38 additions & 0 deletions docs/app/(home)/integrations/copy-command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { copyText } from "@/lib/copy-text";
import { Check, Copy } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import styles from "./page.module.css";

export function CopyCommand({ command }: { command: string }) {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<number | null>(null);

useEffect(
() => () => {
if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
},
[],
);

async function handleCopy() {
if (!(await copyText(command))) return;

setCopied(true);
if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
resetTimer.current = window.setTimeout(() => setCopied(false), 2000);
}

return (
<button
aria-label={copied ? "Command copied" : "Copy command"}
className={styles.copyCommand}
onClick={handleCopy}
type="button"
>
{copied ? <Check size={14} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
<span aria-live="polite">{copied ? "Copied" : "Copy"}</span>
</button>
);
}
Loading
Loading