chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotEndpoint,
|
||||
InMemoryAgentRunner,
|
||||
BuiltInAgent,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { handle } from "hono/vercel";
|
||||
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
prompt: `You are the CopilotKit Showcase assistant. You help developers explore CopilotKit integrations, find the right agent framework, and try live demos.
|
||||
|
||||
You should:
|
||||
- Help users understand what CopilotKit does and how different frameworks integrate
|
||||
- Suggest demos they can try based on their interests
|
||||
- Explain features like generative UI, human-in-the-loop, tool rendering, etc.
|
||||
- Be concise and helpful — 1-3 sentences unless they ask for detail
|
||||
|
||||
When suggesting demos, provide links like /integrations/{slug}/{demoId}.
|
||||
Available integrations: LangGraph (Python) at /integrations/langgraph-python, Mastra at /integrations/mastra.
|
||||
Available features: agentic-chat, human-in-the-loop, tool-rendering, gen-ui-tool-based.`,
|
||||
maxSteps: 3,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore — BuiltInAgent type mismatch with AbstractAgent, pending upstream fix
|
||||
agents: { default: agent },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
export const GET = handle(app);
|
||||
export const POST = handle(app);
|
||||
@@ -0,0 +1,189 @@
|
||||
@import "tailwindcss";
|
||||
@import "@copilotkit/react-core/v2/styles.css";
|
||||
@import "highlight.js/styles/github.css";
|
||||
|
||||
:root {
|
||||
/* Backgrounds — match reference (oklch(0.9038 0.0149 286.1) ≈ #e6e6ec).
|
||||
* Shell keeps a slightly lighter cast so code blocks on white surface
|
||||
* still read well; key is that it's the same cool-gray family. */
|
||||
--bg: #eaeaee;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f1f1f6;
|
||||
--bg-hover: #e5e5eb;
|
||||
--border: #d9d9e0;
|
||||
--border-dim: #e9e9ef;
|
||||
--sidebar: #efeff3;
|
||||
--text: #010507;
|
||||
--text-secondary: #2b3238;
|
||||
--text-muted: #6a7079;
|
||||
--text-faint: #a3a8af;
|
||||
/* Reference primary is oklch(0.55 0.25 285) — a blue-violet. Hex approx. */
|
||||
--accent: #5a3cd1;
|
||||
--accent-light: #ebe7fa;
|
||||
--accent-dim: rgba(90, 60, 209, 0.06);
|
||||
--blue: #1a5fb4;
|
||||
--violet: #5a3cd1;
|
||||
--violet-light: #ebe7fa;
|
||||
|
||||
/* Font stacks — fed by next/font CSS variables in layout.tsx with
|
||||
* safe fallbacks for environments where the font fails to load.
|
||||
* We expose them under `--font-prose`/`--font-code` and use those
|
||||
* names in component CSS so Tailwind's own `--font-sans`/`--font-mono`
|
||||
* theme tokens don't collide. */
|
||||
--font-docs-prose:
|
||||
var(--font-prose, "Plus Jakarta Sans"), -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
--font-docs-code:
|
||||
var(--font-mono, "Spline Sans Mono"), ui-monospace, SFMono-Regular,
|
||||
"SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-docs-prose);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
/* Code font inherits Spline Sans Mono via CSS variable */
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--font-docs-code);
|
||||
}
|
||||
|
||||
/* Reference content (MDX) typography — calibrated against docs.copilotkit.ai.
|
||||
* The reference uses fumadocs' default prose scale: body ~15px, h2 ~1.5rem
|
||||
* with tighter tracking, and tonally-darker headings than body. */
|
||||
.reference-content {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
line-height: 1.65;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reference-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.reference-content h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.reference-content h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.reference-content p {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.reference-content ul,
|
||||
.reference-content ol {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.reference-content li {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.reference-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.reference-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.reference-content code {
|
||||
font-size: 0.8125rem;
|
||||
background: var(--bg-elevated);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: var(--font-docs-code);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Bare <pre> fallback (when an MDX page uses a triple-fence rather than
|
||||
* <Snippet>). Mirrors the chrome on <Snippet>'s figure so pages look
|
||||
* consistent whether the code is live-from-cell or inline. */
|
||||
.reference-content pre {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reference-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.reference-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.reference-content a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.reference-content table {
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.reference-content th,
|
||||
.reference-content td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reference-content th {
|
||||
background: var(--bg-elevated);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reference-content strong {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.reference-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#0a0a0f"/>
|
||||
<path d="M5 4 L27 4 L27 17 Q27 27 16 30 Q5 27 5 17 Z" fill="none" stroke="#ff44bb" stroke-width="1.5"/>
|
||||
<g transform="translate(8.19, 8.51) scale(0.055)">
|
||||
<path d="M76.4556 75.7547C97.0552 48.81 114.152 22.1656 120.731 0.648813C120.908 0.0633572 121.594 -0.185355 122.103 0.152636C144.979 15.3013 186.646 25.2726 223.5 25.5066C224.134 25.5107 224.571 26.1359 224.342 26.7272C212.088 57.8146 197.122 113.518 196.54 177.128C196.54 178.073 195.21 178.412 194.742 177.59C173.768 140.886 106.586 89.3107 76.7986 77.138C76.2477 76.9114 76.0919 76.2307 76.4556 75.7547Z" fill="#ff44bb"/>
|
||||
<path d="M145.956 59.273C113.757 69.4674 84.3336 75.1349 77.3077 76.4226C76.8608 76.5047 76.7673 77.1231 77.1934 77.2977C107.209 89.777 174.059 141.202 194.835 177.757C194.877 177.837 194.981 177.867 195.064 177.83C195.147 177.791 195.189 177.687 195.158 177.597L145.956 59.273Z" fill="#ff44bb"/>
|
||||
<path d="M122.197 0.0860308C149.76 15.1211 181.615 21.8738 223.875 25.4319C224.135 25.4546 224.228 25.8103 223.989 25.9339C218.585 28.7116 187.623 44.4667 164.633 52.905C158.47 55.1657 152.275 57.263 146.174 59.1979C146.039 59.2402 145.894 59.1736 145.842 59.0446L121.563 0.655481C121.397 0.262302 121.823 -0.117886 122.197 0.0860308Z" fill="#ff44bb"/>
|
||||
<path d="M72.9636 210.65L60.8346 212.356C67.1226 228.985 80.0206 236.249 95.4131 236.249C133.141 236.249 121.625 193.585 143.482 193.585C159.342 193.585 152.899 228.165 187.02 228.165C207.848 228.165 209.927 207.183 206.372 198.156C206.351 198.101 206.331 198.051 206.299 198.002L200.718 189.455C200.355 188.887 199.471 189.101 199.409 189.776L198.369 200.135C198.297 200.856 198.317 201.574 198.401 202.293C199.253 209.45 199.804 226.818 187.02 226.818C173.54 226.818 170.297 192.686 143.482 192.686C112.032 192.686 116.075 234.902 96.7643 234.902C84.0221 234.902 74.3043 220.531 72.9636 210.65Z" fill="#ff44bb"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { Integration } from "@/lib/registry";
|
||||
|
||||
interface DemoFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
interface DemoContent {
|
||||
readme: string | null;
|
||||
files: DemoFile[];
|
||||
backend_files?: DemoFile[];
|
||||
}
|
||||
|
||||
type TreeNode =
|
||||
| { kind: "file"; file: DemoFile; name: string }
|
||||
| { kind: "dir"; name: string; children: TreeNode[] };
|
||||
|
||||
function buildTree(files: DemoFile[]): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
for (const file of files) {
|
||||
const parts = file.filename.split("/");
|
||||
let level = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const dirName = parts[i];
|
||||
let existing = level.find(
|
||||
(n): n is Extract<TreeNode, { kind: "dir" }> =>
|
||||
n.kind === "dir" && n.name === dirName,
|
||||
);
|
||||
if (!existing) {
|
||||
existing = { kind: "dir", name: dirName, children: [] };
|
||||
level.push(existing);
|
||||
}
|
||||
level = existing.children;
|
||||
}
|
||||
level.push({ kind: "file", file, name: parts[parts.length - 1] });
|
||||
}
|
||||
// Sort each level: directories first, then files; alpha within each group;
|
||||
// but highlighted files float to top within their group.
|
||||
const sort = (nodes: TreeNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
|
||||
if (a.kind === "file" && b.kind === "file") {
|
||||
if (!!a.file.highlighted !== !!b.file.highlighted) {
|
||||
return a.file.highlighted ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const n of nodes) if (n.kind === "dir") sort(n.children);
|
||||
};
|
||||
sort(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseLineRange(spec: string | null): Set<number> {
|
||||
const highlighted = new Set<number>();
|
||||
if (!spec) return highlighted;
|
||||
for (const part of spec.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const [a, b] = trimmed.split("-").map((n) => parseInt(n, 10));
|
||||
if (Number.isNaN(a)) continue;
|
||||
const end = Number.isNaN(b) ? a : b;
|
||||
for (let i = a; i <= end; i++) highlighted.add(i);
|
||||
}
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
activeFilename,
|
||||
onSelect,
|
||||
}: {
|
||||
nodes: TreeNode[];
|
||||
depth: number;
|
||||
activeFilename?: string;
|
||||
onSelect: (f: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => {
|
||||
const indent = { paddingLeft: `${12 + depth * 14}px` };
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<div key={`d:${node.name}:${depth}`}>
|
||||
<div
|
||||
className="py-1 text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)]"
|
||||
style={indent}
|
||||
>
|
||||
{node.name}/
|
||||
</div>
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
activeFilename={activeFilename}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const active = activeFilename === node.file.filename;
|
||||
const hl = node.file.highlighted;
|
||||
return (
|
||||
<button
|
||||
key={`f:${node.file.filename}`}
|
||||
onClick={() => onSelect(node.file.filename)}
|
||||
style={indent}
|
||||
className={`block w-full py-1.5 pr-3 text-left text-xs font-mono transition-colors truncate ${
|
||||
active
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: hl
|
||||
? "text-[var(--text)] hover:bg-[var(--bg-elevated)]/50"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]/50"
|
||||
} ${hl ? "font-semibold" : ""}`}
|
||||
title={hl ? "core file" : undefined}
|
||||
>
|
||||
{hl && <span className="text-[var(--accent)] mr-1">★</span>}
|
||||
{node.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StandaloneCodePage() {
|
||||
const params = useParams<{ slug: string; demo: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [content, setContent] = useState<DemoContent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import("@/data/registry.json").then((mod) => {
|
||||
const registry = mod.default as { integrations: Integration[] };
|
||||
const integ = registry.integrations.find((i) => i.slug === params.slug);
|
||||
if (integ) setIntegration(integ);
|
||||
});
|
||||
|
||||
import("@/data/demo-content.json").then((mod) => {
|
||||
const data = mod.default as { demos: Record<string, DemoContent> };
|
||||
const key = `${params.slug}::${params.demo}`;
|
||||
setContent(data.demos[key] ?? null);
|
||||
});
|
||||
}, [params.slug, params.demo]);
|
||||
|
||||
const allFiles: DemoFile[] = useMemo(() => {
|
||||
if (!content) return [];
|
||||
// Prefer flat `files`. Fall back to combining files + backend_files for
|
||||
// older bundles that still emit that shape.
|
||||
return [...content.files, ...(content.backend_files ?? [])];
|
||||
}, [content]);
|
||||
|
||||
const hasHighlights = useMemo(
|
||||
() => allFiles.some((f) => f.highlighted),
|
||||
[allFiles],
|
||||
);
|
||||
|
||||
// `view` toggles the sidebar between "core" (highlights only) and "all"
|
||||
// (full tree). Default is "core" when any file is highlighted; otherwise
|
||||
// "all" (with nothing marked core, core view would be empty).
|
||||
const view: "core" | "all" =
|
||||
searchParams.get("view") === "all" ? "all" : hasHighlights ? "core" : "all";
|
||||
|
||||
const visibleFiles: DemoFile[] = useMemo(
|
||||
() => (view === "core" ? allFiles.filter((f) => f.highlighted) : allFiles),
|
||||
[allFiles, view],
|
||||
);
|
||||
|
||||
// Default-open a highlighted file if any; otherwise the first file.
|
||||
const defaultFile =
|
||||
allFiles.find((f) => f.highlighted)?.filename ?? allFiles[0]?.filename;
|
||||
const activeFilename = searchParams.get("file") ?? defaultFile;
|
||||
const activeFile =
|
||||
allFiles.find((f) => f.filename === activeFilename) ?? allFiles[0];
|
||||
const tree = useMemo(() => buildTree(visibleFiles), [visibleFiles]);
|
||||
|
||||
const highlightedLines = useMemo(
|
||||
() => parseLineRange(searchParams.get("lines")),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] items-center justify-center text-[var(--text-muted)]">
|
||||
Loading code…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!content || allFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] items-center justify-center text-[var(--text-muted)]">
|
||||
No source files bundled for this demo.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectFile = (filename: string) => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
next.set("file", filename);
|
||||
next.delete("lines");
|
||||
router.replace(`?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const setView = (v: "core" | "all") => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
if (v === "all") next.set("view", "all");
|
||||
else next.delete("view"); // "core" is default — omit the param
|
||||
router.replace(`?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)]">
|
||||
<div className="flex h-full w-64 flex-col border-r border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Files
|
||||
</span>
|
||||
{hasHighlights && (
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer select-none"
|
||||
title="Include scaffolding (Dockerfile, configs, etc.)"
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
show all files
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={view === "all"}
|
||||
onClick={() => setView(view === "all" ? "core" : "all")}
|
||||
className={`relative inline-block h-4 w-7 rounded-full border transition-colors ${
|
||||
view === "all"
|
||||
? "bg-[var(--accent)] border-[var(--accent)]"
|
||||
: "bg-[var(--bg-muted)] border-[var(--border-strong)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-[1px] left-[1px] h-3 w-3 rounded-full shadow-sm transition-transform ${
|
||||
view === "all"
|
||||
? "bg-white translate-x-3"
|
||||
: "bg-[var(--text-muted)] translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
depth={0}
|
||||
activeFilename={activeFile?.filename}
|
||||
onSelect={selectFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto border-t border-[var(--border)] p-3">
|
||||
<a
|
||||
href={integration.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-[var(--accent)] hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeFile && (
|
||||
<SyntaxHighlighter
|
||||
language={activeFile.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
// oneLight sets a near-white background on both <pre> and the
|
||||
// inner <code>. `customStyle` overrides the outer <pre>, but
|
||||
// the <code> child keeps its own background — and since <code>
|
||||
// renders inline, that bg hugs each line of text and shows as
|
||||
// a pale per-line rectangle against the page chrome. Unset it
|
||||
// so the chosen <pre> background reads as a flat block.
|
||||
codeTagProps={{ style: { background: "transparent" } }}
|
||||
showLineNumbers
|
||||
wrapLines={highlightedLines.size > 0}
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
lineProps={(lineNumber) =>
|
||||
highlightedLines.has(lineNumber)
|
||||
? {
|
||||
style: {
|
||||
display: "block",
|
||||
background: "rgba(250, 204, 21, 0.18)",
|
||||
},
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{activeFile.content}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { Demo, Integration } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
type Tab = "preview" | "code" | "docs";
|
||||
|
||||
interface DemoFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface DemoContent {
|
||||
readme: string | null;
|
||||
files: DemoFile[];
|
||||
backend_files?: DemoFile[];
|
||||
}
|
||||
|
||||
export default function DemoViewerPage() {
|
||||
const params = useParams<{ slug: string; demo: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("preview");
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [demo, setDemo] = useState<Demo | null>(null);
|
||||
const [demoContent, setDemoContent] = useState<DemoContent | null>(null);
|
||||
const [activeFile, setActiveFile] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
import("@/data/registry.json").then((mod) => {
|
||||
const registry = mod.default as { integrations: Integration[] };
|
||||
const integ = registry.integrations.find((i) => i.slug === params.slug);
|
||||
if (integ) {
|
||||
setIntegration(integ);
|
||||
setDemo(integ.demos.find((d) => d.id === params.demo) ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
import("@/data/demo-content.json").then((mod) => {
|
||||
const content = mod.default as {
|
||||
demos: Record<string, DemoContent | undefined>;
|
||||
};
|
||||
const key = `${params.slug}::${params.demo}`;
|
||||
const entry = content.demos[key];
|
||||
if (entry) {
|
||||
setDemoContent(entry);
|
||||
}
|
||||
});
|
||||
}, [params.slug, params.demo]);
|
||||
|
||||
if (!integration || !demo) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center text-[var(--text-muted)]">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Command-only demos (e.g. `langgraph-python::cli-start`) have no
|
||||
// `route`, so iframeSrc stays null and the preview tab shows the CLI
|
||||
// instructions instead of an iframe. For demos with a route, the
|
||||
// backend host is derived at request time from the injected runtime
|
||||
// config's host pattern — never the build-baked registry backend_url
|
||||
// (see preview/page.tsx for rationale). This line only runs
|
||||
// post-hydration, so the injected runtime config is present.
|
||||
const iframeSrc = demo.route
|
||||
? `${resolveBackendUrl(integration.slug, getRuntimeConfig().backendHostPattern)}${demo.route}`
|
||||
: null;
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "code", label: "Code" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] flex-col p-4 gap-3">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between border border-[var(--border)] bg-[var(--bg-surface)] px-6 py-3 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}`}
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
← {integration.name}
|
||||
</Link>
|
||||
<span className="text-[var(--text-muted)]">/</span>
|
||||
<span className="text-sm font-medium text-[var(--text)]">
|
||||
{demo.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 rounded-lg bg-[var(--bg)] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-[var(--border)]">
|
||||
{activeTab === "preview" &&
|
||||
(iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
className="h-full w-full border-0 rounded-xl"
|
||||
title={`${demo.name} demo`}
|
||||
allow="clipboard-read; clipboard-write; microphone"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-[var(--text-muted)]">
|
||||
<p className="text-sm font-semibold text-[var(--text)]">
|
||||
No live preview for this demo
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{demo.name} is a CLI-only demo. See the Docs tab or
|
||||
{demo.command ? (
|
||||
<>
|
||||
{" "}
|
||||
run{" "}
|
||||
<code className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 font-mono text-[var(--accent)]">
|
||||
{demo.command}
|
||||
</code>{" "}
|
||||
to get started.
|
||||
</>
|
||||
) : (
|
||||
" the integration page for instructions."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab === "code" && (
|
||||
<div className="flex h-full">
|
||||
{/* File tabs */}
|
||||
<div className="flex h-full flex-col border-r border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<div className="p-3 text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Files
|
||||
</div>
|
||||
{demoContent?.files.map((file, idx) => (
|
||||
<button
|
||||
key={file.filename}
|
||||
onClick={() => setActiveFile(idx)}
|
||||
className={`px-4 py-2 text-left text-xs font-mono transition-colors ${
|
||||
activeFile === idx
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]/50"
|
||||
}`}
|
||||
>
|
||||
{file.filename}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-auto border-t border-[var(--border)] p-3">
|
||||
<a
|
||||
href={integration.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-[var(--accent)] hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Code viewer */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{demoContent?.files[activeFile] ? (
|
||||
<SyntaxHighlighter
|
||||
language={demoContent.files[activeFile].language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{demoContent.files[activeFile].content}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--text-muted)]">
|
||||
No source files bundled for this demo.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "docs" && (
|
||||
<div className="h-full overflow-auto p-8">
|
||||
{demoContent?.readme ? (
|
||||
<div className="mx-auto max-w-3xl [&_h1]:text-2xl [&_h1]:font-light [&_h1]:text-[var(--text)] [&_h1]:mb-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[var(--text)] [&_h2]:mt-8 [&_h2]:mb-3 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-[var(--text)] [&_h3]:mt-6 [&_h3]:mb-2 [&_p]:text-sm [&_p]:text-[var(--text-secondary)] [&_p]:leading-relaxed [&_p]:mb-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-4 [&_li]:text-sm [&_li]:text-[var(--text-secondary)] [&_li]:mb-1 [&_strong]:text-[var(--text)] [&_code]:text-[var(--accent)] [&_code]:bg-[var(--bg-elevated)] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-[var(--bg-surface)] [&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:mb-4 [&_pre]:overflow-x-auto [&_hr]:border-[var(--border)] [&_hr]:my-6">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{demoContent.readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--text-muted)]">
|
||||
No documentation available for this demo.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Integration, Demo } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
export default function StandalonePreviewPage() {
|
||||
const params = useParams<{ slug: string; demo: string }>();
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [demo, setDemo] = useState<Demo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import("@/data/registry.json").then((mod) => {
|
||||
const registry = mod.default as { integrations: Integration[] };
|
||||
const integ = registry.integrations.find((i) => i.slug === params.slug);
|
||||
if (integ) {
|
||||
setIntegration(integ);
|
||||
setDemo(integ.demos.find((d) => d.id === params.demo) ?? null);
|
||||
}
|
||||
});
|
||||
}, [params.slug, params.demo]);
|
||||
|
||||
if (!integration || !demo) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] items-center justify-center text-[var(--text-muted)]">
|
||||
Loading preview…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Command-only demos (no `route`) have no iframe URL to build —
|
||||
// concatenating them would produce `${base}undefined`. Render the
|
||||
// command instead, matching the Get Started section on the profile page.
|
||||
if (!demo.route) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] w-full items-center justify-center px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="text-sm font-semibold text-[var(--text)]">
|
||||
{demo.name} has no live preview
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--text-muted)]">
|
||||
This demo is CLI-only.
|
||||
{demo.command ? (
|
||||
<>
|
||||
{" "}
|
||||
Run{" "}
|
||||
<code className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 font-mono text-[var(--accent)]">
|
||||
{demo.command}
|
||||
</code>{" "}
|
||||
to get started.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Derive the backend host at runtime from the injected config —
|
||||
// never from registry.json's backend_url, which is baked at Docker
|
||||
// build time (a staging shell would iframe PROD backends).
|
||||
// resolveBackendUrl keeps the NEXT_PUBLIC_LOCAL_BACKENDS local-dev
|
||||
// override. Safe to read here: this point is only reachable
|
||||
// post-hydration (registry loads in useEffect), so the real
|
||||
// window.__SHOWCASE_CONFIG__ is present.
|
||||
const base = resolveBackendUrl(
|
||||
integration.slug,
|
||||
getRuntimeConfig().backendHostPattern,
|
||||
);
|
||||
const src = `${base}${demo.route}`;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-52px)] w-full">
|
||||
<iframe
|
||||
src={src}
|
||||
className="h-full w-full border-0"
|
||||
title={`${integration.name} — ${demo.name}`}
|
||||
allow="clipboard-read; clipboard-write; microphone"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getIntegration,
|
||||
getIntegrations,
|
||||
getFeature,
|
||||
getCategoryLabel,
|
||||
getLanguageLabel,
|
||||
} from "@/lib/registry";
|
||||
import { ProfileClient } from "./profile-client";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getIntegrations().map((i) => ({ slug: i.slug }));
|
||||
}
|
||||
|
||||
export default async function IntegrationProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const integration = getIntegration(slug);
|
||||
if (!integration) notFound();
|
||||
|
||||
const featureInfos = integration.features.map((featureId) => {
|
||||
const feature = getFeature(featureId);
|
||||
return {
|
||||
id: featureId,
|
||||
name: feature?.name || featureId,
|
||||
hasDemo: integration.demos.some((d) => d.id === featureId),
|
||||
};
|
||||
});
|
||||
|
||||
// Other integrations that share demo IDs — for framework switcher
|
||||
const allIntegrations = getIntegrations().filter(
|
||||
(i) => i.deployed && i.slug !== slug,
|
||||
);
|
||||
// No backendUrl here: this page is statically prerendered
|
||||
// (generateStaticParams), so any URL computed server-side would be
|
||||
// frozen at build time — the exact baked-prod-URL defect this fixes.
|
||||
// ProfileClient derives backend URLs client-side from the runtime
|
||||
// config pattern.
|
||||
const demoAlternatives: Record<
|
||||
string,
|
||||
Array<{ slug: string; name: string }>
|
||||
> = {};
|
||||
for (const demo of integration.demos) {
|
||||
const alts = allIntegrations
|
||||
.filter((i) => i.demos.some((d) => d.id === demo.id))
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
name: i.name,
|
||||
}));
|
||||
if (alts.length > 0) {
|
||||
demoAlternatives[demo.id] = alts;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileClient
|
||||
integration={integration}
|
||||
featureInfos={featureInfos}
|
||||
categoryLabel={getCategoryLabel(integration.category)}
|
||||
languageLabel={getLanguageLabel(integration.language)}
|
||||
demoAlternatives={demoAlternatives}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { DemoDrawer } from "@/components/demo-drawer";
|
||||
import type { Demo, Integration } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
interface StarterFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FeatureInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
hasDemo: boolean;
|
||||
}
|
||||
|
||||
export function ProfileClient({
|
||||
integration,
|
||||
featureInfos,
|
||||
categoryLabel,
|
||||
languageLabel,
|
||||
demoAlternatives = {},
|
||||
}: {
|
||||
integration: Integration;
|
||||
featureInfos: FeatureInfo[];
|
||||
categoryLabel: string;
|
||||
languageLabel: string;
|
||||
demoAlternatives?: Record<string, Array<{ slug: string; name: string }>>;
|
||||
}) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeDemo, setActiveDemo] = useState<Demo | null>(null);
|
||||
const [starterFiles, setStarterFiles] = useState<StarterFile[] | null>(null);
|
||||
const [starterReadme, setStarterReadme] = useState<string | null>(null);
|
||||
const [starterTab, setStarterTab] = useState<"demo" | "code" | "docs">(
|
||||
"demo",
|
||||
);
|
||||
const [showAllFiles, setShowAllFiles] = useState(false);
|
||||
const [cloneCopied, setCloneCopied] = useState(false);
|
||||
const [copiedCommandId, setCopiedCommandId] = useState<string | null>(null);
|
||||
|
||||
const liveDemos = integration.demos.filter((d) => d.route && !d.command);
|
||||
const commandDemos = integration.demos.filter((d) => d.command);
|
||||
|
||||
// Load starter content dynamically when the integration has a starter
|
||||
useEffect(() => {
|
||||
if (!integration.starter) return;
|
||||
let cancelled = false;
|
||||
import("@/data/starter-content.json")
|
||||
.then((mod) => {
|
||||
if (cancelled) return;
|
||||
const content = mod.default as any;
|
||||
const starterData = content.starters[integration.slug];
|
||||
if (starterData) {
|
||||
setStarterFiles(starterData.files || []);
|
||||
setStarterReadme(starterData.readme || null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error("[profile] Failed to load starter content:", err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [integration.slug, integration.starter]);
|
||||
|
||||
function openDemo(demo: Demo) {
|
||||
setActiveDemo(demo);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function copyDemoCommand(demoId: string, command: string) {
|
||||
navigator.clipboard
|
||||
.writeText(command)
|
||||
.then(() => {
|
||||
setCopiedCommandId(demoId);
|
||||
setTimeout(() => setCopiedCommandId(null), 2000);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// navigator.clipboard requires HTTPS — fall back to window.prompt so
|
||||
// the user can still copy manually. Log the original failure so
|
||||
// missing-clipboard-and-blocked-prompt double failures are
|
||||
// diagnosable via devtools.
|
||||
console.warn(
|
||||
"[profile] clipboard write failed, falling back to prompt:",
|
||||
err,
|
||||
);
|
||||
window.prompt("Copy this command:", command);
|
||||
});
|
||||
}
|
||||
|
||||
function copyCloneCommand() {
|
||||
if (!integration.starter) return;
|
||||
const cmd =
|
||||
integration.starter.clone_command ||
|
||||
`npx degit CopilotKit/CopilotKit/${integration.starter.path} my-copilotkit-app`;
|
||||
navigator.clipboard
|
||||
.writeText(cmd)
|
||||
.then(() => {
|
||||
setCloneCopied(true);
|
||||
setTimeout(() => setCloneCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: show the command in a prompt if clipboard fails
|
||||
window.prompt("Copy this command:", cmd);
|
||||
});
|
||||
}
|
||||
|
||||
// Find the "key" agent file — prefer a Python file with "agent" or "main" in the name, else first backend file
|
||||
const keyFile = starterFiles
|
||||
? starterFiles.find(
|
||||
(f) => /agent|main/.test(f.filename) && f.language === "python",
|
||||
) ||
|
||||
starterFiles.find((f) => f.language === "python") ||
|
||||
starterFiles[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-5xl px-6 py-12">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
{integration.logo && (
|
||||
<img
|
||||
src={integration.logo}
|
||||
alt={`${integration.name} logo`}
|
||||
className="w-10 h-10 rounded-lg"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-light text-[var(--text)]">
|
||||
{integration.name}
|
||||
</h1>
|
||||
<span className="rounded-md bg-[var(--bg-elevated)] px-2 py-0.5 text-[10px] font-mono text-[var(--text-muted)] border border-[var(--border)]">
|
||||
{languageLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-mono uppercase tracking-widest text-[var(--accent)]">
|
||||
{categoryLabel}
|
||||
</p>
|
||||
<p className="mt-4 text-[var(--text-secondary)] max-w-2xl leading-relaxed">
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{integration.partner_docs && (
|
||||
<a
|
||||
href={integration.partner_docs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
Partner Docs
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={integration.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
Source Code
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/CopilotKit/CopilotKit/blob/main/showcase/STYLING-GUIDE.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
Developer Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Starter — wider breakout container */}
|
||||
{integration.starter && keyFile && (
|
||||
<div className="mx-auto max-w-[90rem] px-6">
|
||||
<section className="mt-0">
|
||||
<div className="rounded-xl border-2 border-[var(--accent)] bg-[var(--bg-elevated)] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-[var(--text)]">
|
||||
<span className="text-xl">🚀</span>
|
||||
Full Starter: {integration.starter.name}
|
||||
</h2>
|
||||
{integration.starter.description && (
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)] leading-relaxed">
|
||||
{integration.starter.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="mt-4 flex gap-1">
|
||||
{[
|
||||
{ id: "demo" as const, label: "Live Demo" },
|
||||
{ id: "code" as const, label: "Code" },
|
||||
{ id: "docs" as const, label: "Docs" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStarterTab(tab.id)}
|
||||
className={`px-3 py-1 rounded-md text-[11px] font-medium transition-colors ${
|
||||
starterTab === tab.id
|
||||
? "bg-[var(--bg-surface)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="mt-4">
|
||||
{/* Live Demo tab */}
|
||||
{starterTab === "demo" && (
|
||||
<div className="rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{integration.starter.demo_url ? (
|
||||
<iframe
|
||||
src={integration.starter.demo_url}
|
||||
className="w-full rounded-lg border border-[var(--border)]"
|
||||
style={{ height: "min(80vh, 900px)" }}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-20 text-sm text-[var(--text-muted)]">
|
||||
Coming soon — deploy in progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code tab */}
|
||||
{starterTab === "code" && (
|
||||
<>
|
||||
<div className="rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<span className="text-[11px] font-mono text-[var(--text-secondary)]">
|
||||
{keyFile.filename}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={keyFile.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg-surface)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
padding: "16px 20px",
|
||||
maxHeight: showAllFiles ? "none" : "600px",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-faint)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{showAllFiles
|
||||
? keyFile.content
|
||||
: keyFile.content.split("\n").slice(0, 30).join("\n")}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
||||
{/* Show all files toggle */}
|
||||
{starterFiles && starterFiles.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowAllFiles(!showAllFiles)}
|
||||
className="text-xs font-medium text-[var(--accent)] hover:underline transition-colors"
|
||||
>
|
||||
{showAllFiles
|
||||
? "Show less ▲"
|
||||
: `Show all ${starterFiles.length} files ▼`}
|
||||
</button>
|
||||
|
||||
{showAllFiles && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{starterFiles
|
||||
.filter((f) => f.filename !== keyFile.filename)
|
||||
.map((file, idx) => (
|
||||
<div
|
||||
key={`${file.filename}-${idx}`}
|
||||
className="rounded-lg border border-[var(--border)] overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<span className="text-[11px] font-mono text-[var(--text-secondary)]">
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={file.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg-surface)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
padding: "16px 20px",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-faint)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{file.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Docs tab */}
|
||||
{starterTab === "docs" && (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-6 overflow-auto"
|
||||
style={{ maxHeight: "600px" }}
|
||||
>
|
||||
{starterReadme ? (
|
||||
<div className="max-w-none [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-[var(--text)] [&_h1]:mb-3 [&_h1]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[var(--text)] [&_h2]:mt-6 [&_h2]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-[var(--text)] [&_h3]:mt-4 [&_h3]:mb-1 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-[var(--text)] [&_h4]:mt-3 [&_h4]:mb-1 [&_p]:text-sm [&_p]:text-[var(--text-secondary)] [&_p]:leading-relaxed [&_p]:mb-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-3 [&_li]:text-sm [&_li]:text-[var(--text-secondary)] [&_li]:mb-1 [&_strong]:text-[var(--text)] [&_code]:text-[var(--accent)] [&_code]:bg-[var(--bg-elevated)] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-[var(--bg-elevated)] [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_a]:text-[var(--accent)] [&_a]:underline [&_details]:mb-3 [&_details]:text-sm [&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-[var(--text)] [&_hr]:border-[var(--border)] [&_hr]:my-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
>
|
||||
{starterReadme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
No README available for this starter.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons (below tabs) */}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{integration.starter.github_url && (
|
||||
<a
|
||||
href={integration.starter.github_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-2 text-xs font-medium text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={copyCloneCommand}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--border)] px-4 py-2 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
{cloneCopied ? "Copied!" : "Clone"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 pb-12">
|
||||
{/* Get Started — CLI / command-only entries */}
|
||||
{commandDemos.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-4 text-xs font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Get Started
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{commandDemos.map((demo) => (
|
||||
<div
|
||||
key={demo.id}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-5"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)]">
|
||||
{demo.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{demo.description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-start gap-2">
|
||||
<code className="flex-1 min-w-0 whitespace-pre-wrap break-all rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2 text-xs font-mono text-[var(--text)]">
|
||||
{demo.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyDemoCommand(demo.id, demo.command ?? "")
|
||||
}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
{copiedCommandId === demo.id ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Live Demos */}
|
||||
{liveDemos.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-4 text-xs font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Live Demos
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{liveDemos.map((demo) => (
|
||||
<button
|
||||
key={demo.id}
|
||||
onClick={() => openDemo(demo)}
|
||||
className="group text-left rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-5 hover:border-[var(--accent)] hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)]">
|
||||
{demo.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{demo.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{demo.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-[var(--bg-elevated)] px-2 py-0.5 text-[10px] font-mono text-[var(--text-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Full page viewer link */}
|
||||
{activeDemo && (
|
||||
<p className="mt-4 text-xs text-[var(--text-muted)]">
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}/${activeDemo.id}`}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Open in full page →
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawer */}
|
||||
{/* Backend URLs are derived at runtime from the injected config
|
||||
pattern (never the build-baked registry backend_url). Safe:
|
||||
activeDemo is only set by a click, i.e. post-hydration, so
|
||||
window.__SHOWCASE_CONFIG__ is populated by the time these
|
||||
evaluate. */}
|
||||
{activeDemo && (
|
||||
<DemoDrawer
|
||||
isOpen={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
integrationSlug={integration.slug}
|
||||
integrationName={integration.name}
|
||||
demoId={activeDemo.id}
|
||||
demoName={activeDemo.name}
|
||||
backendUrl={resolveBackendUrl(
|
||||
integration.slug,
|
||||
getRuntimeConfig().backendHostPattern,
|
||||
)}
|
||||
demoRoute={activeDemo.route ?? ""}
|
||||
wide={
|
||||
activeDemo.id.includes("gen-ui") ||
|
||||
activeDemo.id.includes("shared-state") ||
|
||||
activeDemo.id.includes("subagent")
|
||||
}
|
||||
alternatives={demoAlternatives[activeDemo.id]?.map((alt) => ({
|
||||
...alt,
|
||||
backendUrl: resolveBackendUrl(
|
||||
alt.slug,
|
||||
getRuntimeConfig().backendHostPattern,
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
getIntegrations,
|
||||
getFeatures,
|
||||
getFeatureCategories,
|
||||
} from "@/lib/registry";
|
||||
import { IntegrationsTabs } from "@/components/integrations-tabs";
|
||||
import { FeatureCatalog } from "@/components/feature-catalog";
|
||||
|
||||
export default function ByFeaturePage() {
|
||||
const integrations = getIntegrations();
|
||||
const features = getFeatures();
|
||||
const categories = getFeatureCategories();
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<IntegrationsTabs />
|
||||
</div>
|
||||
<FeatureCatalog
|
||||
features={features}
|
||||
categories={categories}
|
||||
integrations={integrations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getIntegrations } from "@/lib/registry";
|
||||
import { IntegrationExplorer } from "@/components/integration-explorer";
|
||||
import { IntegrationsTabs } from "@/components/integrations-tabs";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: Promise<{ feature?: string }>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({
|
||||
searchParams,
|
||||
}: IntegrationsPageProps) {
|
||||
const integrations = getIntegrations();
|
||||
const params = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<IntegrationsTabs />
|
||||
</div>
|
||||
<IntegrationExplorer
|
||||
integrations={integrations}
|
||||
initialFeatureFilter={params.feature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Plus_Jakarta_Sans, Spline_Sans_Mono } from "next/font/google";
|
||||
import { BrandNav } from "@/components/brand-nav";
|
||||
import { FrameworkProvider } from "@/components/framework-provider";
|
||||
import { getIntegrations } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Serialize the runtime config for inline injection. We must
|
||||
* JSON.stringify-then-escape because the value lands inside a
|
||||
* `<script>...</script>` tag, where three substrings would otherwise
|
||||
* break out of (or corrupt) the parser:
|
||||
*
|
||||
* - `<` — guards against the `</script>` breakout (XSS).
|
||||
* `JSON.stringify` does NOT escape `<` by default, so a URL
|
||||
* containing `</script>` (e.g. a hostile env value) would
|
||||
* terminate the inline script and inject HTML. Escape every
|
||||
* `<` to `<` so the substring `</script>` can never appear.
|
||||
* - `
|
||||
` (LINE SEPARATOR) and `
|
||||
` (PARAGRAPH SEPARATOR) —
|
||||
* legal inside JSON strings, but a syntax error inside a JS
|
||||
* string literal in older engines / when the page is parsed as
|
||||
* `text/javascript`. Escape both.
|
||||
*
|
||||
* IMPORTANT: the regex sources below use explicit `
|
||||
` / `
|
||||
`
|
||||
* ECMAScript-Unicode escapes — the regex engine resolves the escape at
|
||||
* compile time, so `/
|
||||
/` matches the actual U+2028 codepoint.
|
||||
* Using a literal U+2028 / U+2029 character in the regex source would
|
||||
* break the parser (those codepoints terminate a regex literal in
|
||||
* pre-ES2019 engines, and are visually invisible — easy to ship
|
||||
* accidentally). Reviewers MUST confirm these regexes are written with
|
||||
* `
|
||||
` / `
|
||||
` escapes literally.
|
||||
*
|
||||
* Canonical OWASP-recommended escape for inline JSON in HTML.
|
||||
*/
|
||||
function serializeRuntimeConfig(cfg: unknown): string {
|
||||
return JSON.stringify(cfg)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
const plusJakartaSans = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-prose",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const splineSansMono = Spline_Sans_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CopilotKit Showcase",
|
||||
description:
|
||||
"Live integration gallery for CopilotKit — 17 AI frameworks, real-time health probes, and interactive demos",
|
||||
icons: { icon: "/icon.svg" },
|
||||
openGraph: {
|
||||
title: "CopilotKit Showcase",
|
||||
description:
|
||||
"Live integration gallery for CopilotKit — 17 AI frameworks, real-time health probes, and interactive demos",
|
||||
images: [{ url: "/og-image.png", width: 1200, height: 630 }],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// FrameworkProvider needs the set of known framework slugs so it can
|
||||
// detect URL-scoped framework views. The framework *selector* now
|
||||
// lives inside the docs sidebar, not in the top bar, so its own
|
||||
// options are wired up in the docs page-level server components.
|
||||
const knownFrameworks = getIntegrations().map((i) => i.slug);
|
||||
|
||||
// Server-side: read live env at request time. `unstable_noStore()`
|
||||
// inside getRuntimeConfig opts this segment out of the static cache
|
||||
// so the inline <script> below always reflects the current Railway
|
||||
// env vars rather than baked-in build-time values.
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const injection = `window.__SHOWCASE_CONFIG__=${serializeRuntimeConfig(runtimeConfig)};`;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${plusJakartaSans.variable} ${splineSansMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
{/* Must be the first (and currently only) child of <head>:
|
||||
* every client component reads window.__SHOWCASE_CONFIG__
|
||||
* during hydration; it must be populated by the time the
|
||||
* parser reaches <body>. */}
|
||||
<script
|
||||
id="__showcase_config__"
|
||||
dangerouslySetInnerHTML={{ __html: injection }}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<FrameworkProvider knownFrameworks={knownFrameworks}>
|
||||
<BrandNav />
|
||||
<main>{children}</main>
|
||||
</FrameworkProvider>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
right: "12px",
|
||||
fontSize: "10px",
|
||||
fontFamily: "monospace",
|
||||
color: "rgba(0,0,0,0.15)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 9999,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{(process.env.NEXT_PUBLIC_COMMIT_SHA || "dev").slice(0, 9)}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getIntegrations,
|
||||
getFeatures,
|
||||
getFeatureCategories,
|
||||
getCategoryLabel,
|
||||
} from "@/lib/registry";
|
||||
import { IntegrationsTabs } from "@/components/integrations-tabs";
|
||||
|
||||
export default function FeatureMatrixPage() {
|
||||
const integrations = getIntegrations();
|
||||
const features = getFeatures();
|
||||
const featureCategories = getFeatureCategories();
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<IntegrationsTabs />
|
||||
<h1 className="text-3xl font-light text-[var(--text)] text-center">
|
||||
Feature Matrix
|
||||
</h1>
|
||||
<p className="mt-6 text-[var(--text-muted)]">
|
||||
No integrations registered yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeFeatures = features.filter((feature) =>
|
||||
integrations.some((i) => i.features.includes(feature.id)),
|
||||
);
|
||||
|
||||
const featuresByCategory = featureCategories
|
||||
.map((cat) => ({
|
||||
...cat,
|
||||
features: activeFeatures.filter((f) => f.category === cat.id),
|
||||
}))
|
||||
.filter((cat) => cat.features.length > 0);
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<IntegrationsTabs />
|
||||
<h1 className="text-3xl font-light text-[var(--text)]">
|
||||
Feature Matrix
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{integrations.length} integration
|
||||
{integrations.length !== 1 ? "s" : ""} across {activeFeatures.length}{" "}
|
||||
features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
{/* Category header row */}
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th className="sticky left-0 z-20 bg-[var(--bg-elevated)] min-w-[200px]" />
|
||||
{featuresByCategory.map((cat) => (
|
||||
<th
|
||||
key={cat.id}
|
||||
colSpan={cat.features.length}
|
||||
className="border-l border-[var(--border)] bg-[var(--bg-elevated)] px-4 py-3 text-center"
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--accent)]">
|
||||
{cat.name}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* Feature name row */}
|
||||
<tr className="border-b-2 border-[var(--border)]">
|
||||
<th className="sticky left-0 z-20 bg-[var(--bg-elevated)] p-4 text-left min-w-[200px]">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Integration
|
||||
</span>
|
||||
</th>
|
||||
{featuresByCategory.map((cat) =>
|
||||
cat.features.map((feature, idx) => (
|
||||
<th
|
||||
key={feature.id}
|
||||
className={`bg-[var(--bg-elevated)] px-3 py-3 text-center min-w-[120px] ${
|
||||
idx === 0 ? "border-l border-[var(--border)]" : ""
|
||||
}`}
|
||||
title={feature.description}
|
||||
>
|
||||
<div className="text-[11px] font-medium text-[var(--text-secondary)] leading-tight">
|
||||
{feature.name}
|
||||
</div>
|
||||
</th>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{integrations.map((integration, rowIdx) => (
|
||||
<tr
|
||||
key={integration.slug}
|
||||
className={`border-t border-[var(--border)] hover:bg-[var(--bg-elevated)]/50 transition-colors ${
|
||||
rowIdx % 2 === 1 ? "bg-[var(--bg)]/30" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-[var(--bg-surface)] p-4 border-r border-[var(--border)]">
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}`}
|
||||
className="font-medium text-sm text-[var(--text)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{integration.name}
|
||||
</Link>
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||
{getCategoryLabel(integration.category)}
|
||||
</div>
|
||||
</td>
|
||||
{featuresByCategory.map((cat) =>
|
||||
cat.features.map((feature, idx) => {
|
||||
const supported = integration.features.includes(feature.id);
|
||||
const hasDemo = integration.demos.some(
|
||||
(d) => d.id === feature.id,
|
||||
);
|
||||
return (
|
||||
<td
|
||||
key={feature.id}
|
||||
className={`px-3 py-3 text-center ${
|
||||
idx === 0 ? "border-l border-[var(--border)]" : ""
|
||||
}`}
|
||||
>
|
||||
{supported && hasDemo ? (
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}/${feature.id}`}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--accent-dim)] text-[var(--accent)] hover:bg-[var(--accent-light)] transition-colors text-sm"
|
||||
title={`Try ${feature.name} demo`}
|
||||
>
|
||||
✓
|
||||
</Link>
|
||||
) : supported ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-muted)] text-sm"
|
||||
title="Supported, no demo yet"
|
||||
>
|
||||
○
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 text-[var(--border)] text-sm">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mx-auto max-w-7xl mt-4 flex gap-6 text-xs text-[var(--text-muted)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-[var(--accent-dim)] text-[var(--accent)] text-[10px]">
|
||||
✓
|
||||
</span>
|
||||
Supported with live demo
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] text-[10px]">
|
||||
○
|
||||
</span>
|
||||
Supported, no demo
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-[var(--border)] text-[10px]">
|
||||
—
|
||||
</span>
|
||||
Not supported
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getIntegrations,
|
||||
getFeatures,
|
||||
getFeatureCategories,
|
||||
} from "@/lib/registry";
|
||||
import { GuidedFlow } from "@/components/guided-flow";
|
||||
|
||||
// Derive framework list from registry sort_order — no hardcoded lists
|
||||
function getFrameworkNames(integrations: ReturnType<typeof getIntegrations>) {
|
||||
const seen = new Set<string>();
|
||||
return integrations
|
||||
.filter((i) => i.deployed)
|
||||
.map((i) => {
|
||||
// Group LangGraph variants under "LangGraph"
|
||||
const name = i.name.startsWith("LangGraph") ? "LangGraph" : i.name;
|
||||
if (seen.has(name)) return null;
|
||||
seen.add(name);
|
||||
return name;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const integrations = getIntegrations();
|
||||
const features = getFeatures();
|
||||
const categories = getFeatureCategories();
|
||||
|
||||
const deployedIntegrations = integrations.filter((i) => i.deployed);
|
||||
const totalDemos = integrations.reduce((sum, i) => sum + i.demos.length, 0);
|
||||
|
||||
const frameworkNames = getFrameworkNames(integrations);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-h-[calc(100vh-52px)] bg-[var(--bg)] px-6 py-16">
|
||||
{/* Hero */}
|
||||
<div className="max-w-2xl text-center mb-12">
|
||||
<h1 className="text-3xl font-semibold text-[var(--text)] tracking-tight mb-3">
|
||||
Build AI-powered apps with any agent framework
|
||||
</h1>
|
||||
<p className="text-base text-[var(--text-secondary)] leading-relaxed">
|
||||
Explore live integrations, compare features across frameworks, and
|
||||
find the right starting point for your project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="w-full max-w-lg mb-14">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm cursor-pointer hover:border-[var(--text-muted)] transition-colors">
|
||||
<svg
|
||||
className="w-4 h-4 text-[var(--text-muted)] shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-[var(--text-muted)] flex-1">
|
||||
Search integrations, features, demos...
|
||||
</span>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-[var(--border)] bg-[var(--bg-elevated)] text-[10px] font-mono text-[var(--text-faint)]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path cards */}
|
||||
<div className="w-full max-w-3xl grid grid-cols-1 sm:grid-cols-3 gap-4 mb-14">
|
||||
<PathCard
|
||||
href="/docs/quickstart"
|
||||
title="Get Started"
|
||||
description="Follow the quickstart guide and start building in minutes."
|
||||
icon={
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PathCard
|
||||
href="/integrations"
|
||||
title="Explore Integrations"
|
||||
description="Browse all integrations by framework, language, and feature."
|
||||
icon={
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M14 3v18"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PathCard
|
||||
href="/docs"
|
||||
title="Read the Docs"
|
||||
description="Full guides, integration docs, and troubleshooting."
|
||||
icon={
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Guided flow CTA */}
|
||||
<div className="mb-14">
|
||||
<GuidedFlow integrations={integrations} />
|
||||
</div>
|
||||
|
||||
{/* Framework pills */}
|
||||
<div className="w-full max-w-2xl mb-14">
|
||||
<h2 className="text-[11px] font-mono uppercase tracking-[1.5px] text-[var(--text-faint)] mb-4 text-center">
|
||||
Agent Frameworks
|
||||
</h2>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{frameworkNames.map((fw) => {
|
||||
const isLive = true; // already filtered to deployed
|
||||
const match = isLive
|
||||
? integrations.find(
|
||||
(i) => i.deployed && (i.name === fw || i.name.startsWith(fw)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (isLive && match) {
|
||||
return (
|
||||
<Link
|
||||
key={fw}
|
||||
href={`/integrations/${match.slug}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-[var(--bg-surface)] border border-[var(--border)] text-[var(--text)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]" />
|
||||
{fw}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={fw}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-[var(--bg-elevated)] border border-transparent text-[var(--text-faint)] cursor-default"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--text-faint)] opacity-40" />
|
||||
{fw}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap justify-center items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{deployedIntegrations.length}
|
||||
</span>{" "}
|
||||
live integrations
|
||||
</span>
|
||||
<span className="text-[var(--border)]">·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{totalDemos}
|
||||
</span>{" "}
|
||||
demos
|
||||
</span>
|
||||
<span className="text-[var(--border)]">·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{features.length}
|
||||
</span>{" "}
|
||||
features
|
||||
</span>
|
||||
<span className="text-[var(--border)]">·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{categories.length}
|
||||
</span>{" "}
|
||||
categories
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathCard({
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col gap-3 p-5 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--accent)] hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--accent-light)] flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-[var(--accent)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{icon}
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] transition-colors mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
/* trigger */
|
||||
Reference in New Issue
Block a user