Feat/web UI (#2)

* feat: add learning website with Next.js

Add interactive web interface for the Build Your Own OpenClaw tutorial:
- Next.js app with TypeScript and Tailwind CSS
- Theme provider with dark/light mode toggle
- Step card components with diff visualization
- Utilities for loading step metadata and file diffs

* chore: ignore worktrees directory

* feat(web): implement learning website with step navigation and diff viewer

- Landing page with hero section and steps overview grouped by phase
- Step detail pages with README rendering and syntax highlighting (Shiki)
- Diff comparison pages with side-by-side GitHub-style viewer
- Custom 404 Not Found page
- Components: ReadmeRenderer, CodeBlock, DiffViewer, DiffSelector
- Generates 175 static pages (18 steps + 153 diff combinations)

* feat(web): enhance UI with zane-portfolio theme and improved UX

- Update color scheme to match zane-portfolio style (warm beige light, pure black dark)
- Add scrollbar styling for light/dark modes
- Fix card border visibility in dark mode
- Add GitHub CTA section with clone command and copy button
- Add star on GitHub call-to-action
- Use favicons from zane-portfolio
- Improve diff viewer with scroll sync and collapsible files
- Add sticky header with file navigation dropdown
- Rewrite README links to GitHub and internal step pages
- Remove default create-next-app template files

* ci: add GitHub Pages deployment workflow

- Add workflow to build and deploy Next.js static site
- Configure custom domain (build-your-own-openclaw.kiyo-n-zane.com)
- Trigger on push to main branch
This commit is contained in:
Zane Chen
2026-03-14 17:47:12 -04:00
committed by GitHub
parent 1ec8d2aeb1
commit 18aa162942
47 changed files with 14938 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Build
working-directory: web
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: web/out
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+5 -1
View File
@@ -54,4 +54,8 @@ generate_diff_md.py
cleanup_docstrings.py
compare_steps.py
generate_diff.sh
**/DIFF*.md
**/DIFF*.md
.worktrees/
# build time populate
web/public/steps
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+176
View File
@@ -0,0 +1,176 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist);
--font-mono: var(--font-red-hat-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
/* Warm beige background matching zane-portfolio */
--background: oklch(0.92 0.02 75);
--foreground: oklch(0.20 0.01 260);
--card: oklch(0.94 0.015 75);
--card-foreground: oklch(0.20 0.01 260);
--popover: oklch(0.94 0.015 75);
--popover-foreground: oklch(0.20 0.01 260);
--primary: oklch(0.20 0.01 260);
--primary-foreground: oklch(0.96 0.01 75);
--secondary: oklch(0.88 0.02 75);
--secondary-foreground: oklch(0.25 0.01 260);
--muted: oklch(0.88 0.02 75);
--muted-foreground: oklch(0.40 0.01 260);
/* Subtle muted accent */
--accent: oklch(0.35 0.02 260);
--accent-foreground: oklch(0.96 0.01 75);
--destructive: oklch(0.55 0.22 25);
/* Semi-transparent borders matching zane-portfolio style */
--border: oklch(0.20 0.01 260 / 0.5);
--input: oklch(0.20 0.01 260 / 0.25);
--ring: oklch(0.35 0.02 260);
--chart-1: oklch(0.55 0.12 260);
--chart-2: oklch(0.65 0.15 260);
--chart-3: oklch(0.70 0.12 150);
--chart-4: oklch(0.60 0.18 300);
--chart-5: oklch(0.55 0.14 200);
/* Smaller border radius matching zane-portfolio (4px) */
--radius: 0.25rem;
--sidebar: oklch(0.94 0.015 75);
--sidebar-foreground: oklch(0.20 0.01 260);
--sidebar-primary: oklch(0.20 0.01 260);
--sidebar-primary-foreground: oklch(0.96 0.01 75);
--sidebar-accent: oklch(0.35 0.02 260);
--sidebar-accent-foreground: oklch(0.96 0.01 75);
--sidebar-border: oklch(0.20 0.01 260 / 0.5);
--sidebar-ring: oklch(0.35 0.02 260);
}
.dark {
/* Pure black background matching zane-portfolio */
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.12 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.12 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0.12 0 0);
--secondary: oklch(0.18 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.18 0 0);
--muted-foreground: oklch(0.70 0 0);
/* Subtle muted accent */
--accent: oklch(0.70 0.02 260);
--accent-foreground: oklch(0.12 0 0);
--destructive: oklch(0.65 0.20 25);
/* Lighter borders for dark mode */
--border: oklch(0.40 0 0);
--input: oklch(0.30 0 0);
--ring: oklch(0.70 0.02 260);
--chart-1: oklch(0.60 0.10 260);
--chart-2: oklch(0.70 0.12 260);
--chart-3: oklch(0.65 0.15 150);
--chart-4: oklch(0.60 0.18 300);
--chart-5: oklch(0.55 0.14 200);
--sidebar: oklch(0.12 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.70 0.02 260);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.70 0.02 260);
--sidebar-accent-foreground: oklch(0.12 0 0);
--sidebar-border: oklch(0.40 0 0);
--sidebar-ring: oklch(0.70 0.02 260);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
/* Scrollbar styling matching zane-portfolio */
::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: oklch(0.40 0 0 / 0.3);
border-radius: 0.25rem;
}
::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.40 0 0 / 0.5);
}
::-webkit-scrollbar-button {
background-color: transparent;
width: 0;
height: 0;
}
.dark ::-webkit-scrollbar-thumb {
background-color: oklch(0.70 0 0 / 0.3);
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.70 0 0 / 0.5);
}
}
/* Center the container utility */
.container {
margin-inline: auto;
padding-right: 2rem;
padding-left: 2rem;
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from 'next'
import { Geist, Red_Hat_Mono } from 'next/font/google'
import './globals.css'
import { ThemeProvider } from '@/components/theme-provider'
import { Header } from '@/components/header'
const geist = Geist({
subsets: ['latin'],
variable: '--font-geist',
})
const redHatMono = Red_Hat_Mono({
subsets: ['latin'],
variable: '--font-red-hat-mono',
})
export const metadata: Metadata = {
title: 'Build Your Own OpenClaw',
description: 'A step-by-step tutorial to build your own AI agent',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning className={`${geist.variable} ${redHatMono.variable}`}>
<body>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
</ThemeProvider>
</body>
</html>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { H1, P } from '@/components/ui/typography'
import Link from 'next/link'
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh]">
<H1>404 - Page Not Found</H1>
<P className="text-muted-foreground">The step or diff you're looking for doesn't exist.</P>
<Link
href="/"
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-medium h-8 gap-1.5 px-2.5 hover:bg-primary/80 transition-colors"
>
Go Home
</Link>
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import Link from 'next/link'
import { getStepsByPhase } from '@/lib/steps'
import { PHASES } from '@/lib/constants'
import { H1, H2, Lead, Muted } from '@/components/ui/typography'
import { StepCard } from '@/components/step-card'
import { CloneCommand } from '@/components/clone-command'
import { GithubIcon, StarIcon } from 'lucide-react'
const GITHUB_REPO_URL = 'https://github.com/czl9707/build-your-own-openclaw'
export default function Home() {
const stepsByPhase = getStepsByPhase()
return (
<div className="container py-12">
{/* Hero Section */}
<section className="flex flex-col items-center text-center my-32">
<H1 className="mb-4">Build Your Own OpenClaw</H1>
<Lead className="max-w-2xl mb-8">
Learn to build a production-ready AI agent through 18 progressive steps.
From a simple chat loop to a fully autonomous multi-agent system.
</Lead>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/steps/00"
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-medium h-9 gap-1.5 px-4 hover:bg-primary/80 transition-colors"
>
Start Learning
</Link>
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center justify-center rounded-lg border border-border bg-background text-sm font-medium h-9 gap-1.5 px-4 hover:bg-muted transition-colors"
>
<GithubIcon className="size-4" />
View on GitHub
</a>
</div>
</section>
{/* Clone Command */}
<section className="mb-16">
<CloneCommand />
</section>
{/* Star CTA */}
<section className="mb-16 text-center">
<a
href={`${GITHUB_REPO_URL}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border hover:bg-muted transition-colors"
>
<StarIcon className="size-4" />
<span className="text-sm">If you find this helpful, give us a star on GitHub!</span>
</a>
</section>
{/* Steps Overview */}
<section className="space-y-12">
{Object.entries(PHASES).map(([phaseNum, phase]) => {
const phaseSteps = stepsByPhase[parseInt(phaseNum, 10)] || []
return (
<div key={phaseNum}>
<div className="mb-6">
<H2 className="mb-2">Phase {phaseNum}: {phase.name}</H2>
<Muted>{phase.description}</Muted>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{phaseSteps.map((step) => (
<StepCard key={step.id} step={step} />
))}
</div>
</div>
)
})}
</section>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getStep, getSteps } from '@/lib/steps'
import { getChangedFiles, getUnchangedFiles, type FileDiff } from '@/lib/files'
import { DiffViewer } from '@/components/diff-viewer'
import { FileNavDropdown } from '@/components/file-nav-dropdown'
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { H2, Muted } from '@/components/ui/typography'
import { PlusIcon, MinusIcon } from 'lucide-react'
interface DiffPageProps {
params: Promise<{
id: string
to: string
}>
}
export async function generateStaticParams() {
const steps = getSteps()
const params: { id: string; to: string }[] = []
// Generate all valid combinations where from < to
for (let i = 0; i < steps.length - 1; i++) {
for (let j = i + 1; j < steps.length; j++) {
params.push({
id: steps[i].id,
to: steps[j].id,
})
}
}
return params // 153 combinations (18 * 17 / 2)
}
export async function generateMetadata({ params }: DiffPageProps) {
const { id, to } = await params
const fromStep = getStep(id)
const toStep = getStep(to)
if (!fromStep || !toStep) {
return { title: 'Diff Not Found' }
}
return {
title: `Diff: Step ${fromStep.id} to Step ${toStep.id} | Build Your Own OpenClaw`,
}
}
// Status type for changed files (excludes 'unchanged')
type ChangedStatus = 'added' | 'removed' | 'modified'
// Get status icon for file
function getStatusIcon(status: ChangedStatus) {
switch (status) {
case 'added':
return <PlusIcon className="size-3 text-green-500" />
case 'removed':
return <MinusIcon className="size-3 text-red-500" />
case 'modified':
return null
}
}
export default async function DiffPage({ params }: DiffPageProps) {
const { id, to } = await params
const fromStep = getStep(id)
const toStep = getStep(to)
// Validate steps exist and from < to
if (!fromStep || !toStep) {
notFound()
}
if (parseInt(id, 10) >= parseInt(to, 10)) {
notFound()
}
const changedFiles = getChangedFiles(fromStep.folderName, toStep.folderName)
const unchangedFiles = getUnchangedFiles(fromStep.folderName, toStep.folderName)
return (
<div className="container py-8">
{/* Breadcrumb */}
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/" />}>
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink render={<Link href={`/steps/${fromStep.id}`} />}>
Step {fromStep.id}: {fromStep.title}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
Diff to Step {toStep.id}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* Sticky Page Header */}
<div className="sticky top-14 z-40 -mx-4 px-4 py-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b mb-8">
<div className="flex items-center justify-between">
<div>
<H2 className="mb-1">
Step {fromStep.id} to Step {toStep.id}
</H2>
<Muted>
Comparing &quot;{fromStep.title}&quot; with &quot;{toStep.title}&quot;
</Muted>
</div>
{changedFiles.length > 0 && (
<FileNavDropdown
files={changedFiles.map((file) => ({
path: file.path,
status: file.status,
anchorId: `file-${file.path.replace(/[^a-zA-Z0-9]/g, '-')}`,
}))}
/>
)}
</div>
</div>
{/* Changed Files Diff */}
{changedFiles.length > 0 ? (
<>
{changedFiles.map((file) => {
const anchorId = `file-${file.path.replace(/[^a-zA-Z0-9]/g, '-')}`
return (
<div key={file.path} id={anchorId} className="scroll-mt-32">
<DiffViewer
fromContent={file.fromContent ?? ''}
toContent={file.toContent ?? ''}
fromLabel={`Step ${fromStep.id}: ${fromStep.title}`}
toLabel={`Step ${toStep.id}: ${toStep.title}`}
filename={file.path}
/>
</div>
)
})}
</>
) : (
<div className="text-center py-12 text-muted-foreground">
No file changes between these steps.
</div>
)}
{/* Unchanged Files */}
{unchangedFiles.length > 0 && (
<div className="mt-12">
<div className="flex items-center gap-4 mb-6">
<h3 className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{unchangedFiles.length} unchanged files
</h3>
<div className="flex-1 h-px bg-border" />
</div>
{unchangedFiles.map((file) => (
<DiffViewer
key={file.path}
defaultOpen={false}
fromContent={file.fromContent ?? ''}
toContent={file.toContent ?? ''}
fromLabel={`Step ${fromStep.id}: ${fromStep.title}`}
toLabel={`Step ${toStep.id}: ${toStep.title}`}
filename={file.path}
/>
))}
</div>
)}
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import fs from 'fs'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { getStep, getSteps } from '@/lib/steps'
import { ReadmeRenderer } from '@/components/readme-renderer'
import { DiffSelector } from '@/components/diff-selector'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { H1 } from '@/components/ui/typography'
// Button-like link styles for Server Components (matches buttonVariants outline)
const buttonOutlineStyles =
'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg border border-border bg-background text-sm font-medium h-8 px-2.5 hover:bg-muted hover:text-foreground transition-colors'
interface PageProps {
params: Promise<{ id: string }>
}
export async function generateStaticParams() {
const steps = getSteps()
return steps.map((step) => ({ id: step.id }))
}
export async function generateMetadata({ params }: PageProps) {
const { id } = await params
const step = getStep(id)
if (!step) {
return { title: 'Step Not Found' }
}
return {
title: `Step ${step.id}: ${step.title}`,
}
}
export default async function StepPage({ params }: PageProps) {
const { id } = await params
const step = getStep(id)
if (!step) {
notFound()
}
// Read README content
const readmeContent = fs.readFileSync(step.readmePath, 'utf-8')
// Get all steps for navigation
const steps = getSteps()
const currentIndex = steps.findIndex((s) => s.id === step.id)
const prevStep = currentIndex > 0 ? steps[currentIndex - 1] : null
const nextStep = currentIndex < steps.length - 1 ? steps[currentIndex + 1] : null
// Steps available for diff comparison (excluding current step)
const diffTargets = steps.filter((s) => s.id !== step.id)
return (
<div className="container py-8">
{/* Breadcrumb */}
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/" />}>
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/#steps" />}>
Steps
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
Step {step.id}: {step.title}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* Header with title and diff action */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<H1 className="text-3xl">
Step {step.id}: {step.title}
</H1>
{/* Diff action dropdown */}
<DiffSelector currentStepId={step.id} steps={diffTargets} />
</div>
{/* README Content */}
<div className="mb-8">
<ReadmeRenderer content={readmeContent} stepFolder={step.folderName} />
</div>
{/* Prev/Next Navigation */}
<div className="flex items-center justify-between border-t pt-6">
<div>
{prevStep && (
<Link href={`/steps/${prevStep.id}`} className={buttonOutlineStyles}>
<ChevronLeft className="size-4" />
Previous: Step {prevStep.id}
</Link>
)}
</div>
<div>
{nextStep && (
<Link href={`/steps/${nextStep.id}`} className={buttonOutlineStyles}>
Next: Step {nextStep.id}
<ChevronRight className="size-4" />
</Link>
)}
</div>
</div>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useState } from 'react'
import { Card } from '@/components/ui/card'
import { CheckIcon, CopyIcon } from 'lucide-react'
const CLONE_COMMAND = 'git clone https://github.com/czl9707/build-your-own-openclaw.git'
export function CloneCommand() {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(CLONE_COMMAND)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Card className="flex flex-row items-center justify-between gap-2 p-3 max-w-2xl mx-auto ring-0 bg-muted">
<code className="flex-1 text-sm font-mono">{CLONE_COMMAND}</code>
<button
onClick={handleCopy}
className="shrink-0 p-2 rounded hover:bg-background transition-colors"
aria-label="Copy to clipboard"
>
{copied ? (
<CheckIcon className="size-4 text-green-500" />
) : (
<CopyIcon className="size-4 text-muted-foreground" />
)}
</button>
</Card>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { codeToHtml } from 'shiki'
import { cn } from '@/lib/utils'
interface CodeBlockProps {
code: string
language: string
className?: string
}
export async function CodeBlock({ code, language, className }: CodeBlockProps) {
const html = await codeToHtml(code, {
lang: language,
theme: 'github-dark',
})
return (
<div
className={cn(
'my-6 overflow-x-auto rounded-lg border border-border',
className
)}
>
<div
dangerouslySetInnerHTML={{ __html: html }}
className="[&>pre]:p-4 [&>pre]:text-sm [&>pre]:leading-relaxed [&>pre]:w-fit [&>pre]:min-w-full"
/>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
'use client'
import { useRouter } from 'next/navigation'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { Step } from '@/lib/steps'
interface DiffSelectorProps {
currentStepId: string
steps: Step[]
}
export function DiffSelector({ currentStepId, steps }: DiffSelectorProps) {
const router = useRouter()
const handleValueChange = (value: string | null) => {
if (value) {
router.push(`/steps/${currentStepId}/diff/${value}`)
}
}
return (
<Select onValueChange={handleValueChange}>
<SelectTrigger className="min-w-64">
<SelectValue placeholder="Compare with..." />
</SelectTrigger>
<SelectContent>
{steps.map((target) => (
<SelectItem key={target.id} value={target.id}>
Step {target.id}: {target.title}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+327
View File
@@ -0,0 +1,327 @@
'use client'
import * as React from 'react'
import { codeToHtml } from 'shiki'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
import { computeDiff, type DiffLine } from '@/lib/diff'
import { ChevronDownIcon } from 'lucide-react'
interface DiffViewerProps {
fromContent: string
toContent: string
fromLabel?: string
toLabel?: string
filename?: string
defaultOpen?: boolean
}
/**
* Highlight a single line of code using shiki
*/
async function highlightLine(content: string, lang: string = 'python'): Promise<string> {
try {
// Wrap single line in a block for shiki, then extract the line
const html = await codeToHtml(content, {
lang,
theme: 'github-dark',
})
// shiki wraps in <pre><code>, extract just the inner content
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/)
return match ? match[1] : content
} catch {
// Fallback to escaped content if highlighting fails
return escapeHtml(content)
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
interface DiffLineRendererProps {
line: DiffLine
highlightedContent: string
}
function DiffLineRenderer({ line, highlightedContent }: DiffLineRendererProps) {
const bgColor =
line.type === 'added'
? 'bg-green-500/20'
: line.type === 'removed'
? 'bg-red-500/20'
: ''
const borderColor =
line.type === 'added'
? 'border-l-green-500'
: line.type === 'removed'
? 'border-l-red-500'
: 'border-l-transparent'
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
return (
<div
className={cn(
'flex font-mono text-sm leading-6 border-l-2 w-max min-w-full',
bgColor,
borderColor
)}
>
<span className="w-12 shrink-0 select-none text-right pr-3 text-muted-foreground/50 border-r border-border/50">
{line.oldLineNumber ?? ''}
</span>
<span className="w-12 shrink-0 select-none text-right pr-3 text-muted-foreground/50 border-r border-border/50">
{line.newLineNumber ?? ''}
</span>
<span
className={cn(
'w-6 shrink-0 select-none text-center',
line.type === 'added' ? 'text-green-500' : line.type === 'removed' ? 'text-red-500' : 'text-muted-foreground/30'
)}
>
{prefix}
</span>
<span
className="pl-3 whitespace-pre"
dangerouslySetInnerHTML={{ __html: highlightedContent }}
/>
</div>
)
}
/**
* Custom hook for synced scrolling between two scroll areas
* Uses a unique instance ID to target specific scroll areas when multiple diffs exist
*/
function useSyncedScroll(instanceId: string) {
const [viewports, setViewports] = React.useState<{
left: HTMLDivElement | null
right: HTMLDivElement | null
}>({ left: null, right: null })
const isScrolling = React.useRef(false)
// Find viewport elements after mount
React.useEffect(() => {
const leftRoot = document.querySelector(
`[data-diff-instance="${instanceId}"][data-left-panel] [data-slot="scroll-area-viewport"]`
) as HTMLDivElement | null
const rightRoot = document.querySelector(
`[data-diff-instance="${instanceId}"][data-right-panel] [data-slot="scroll-area-viewport"]`
) as HTMLDivElement | null
if (leftRoot || rightRoot) {
setViewports({ left: leftRoot, right: rightRoot })
}
}, [instanceId])
// Setup scroll event listeners when viewports are found
React.useEffect(() => {
const { left, right } = viewports
if (!left || !right) return
const syncScroll = (source: HTMLDivElement, target: HTMLDivElement) => {
if (isScrolling.current) return
isScrolling.current = true
target.scrollTop = source.scrollTop
requestAnimationFrame(() => {
isScrolling.current = false
})
}
const onLeftScroll = () => syncScroll(left, right)
const onRightScroll = () => syncScroll(right, left)
left.addEventListener('scroll', onLeftScroll)
right.addEventListener('scroll', onRightScroll)
return () => {
left.removeEventListener('scroll', onLeftScroll)
right.removeEventListener('scroll', onRightScroll)
}
}, [viewports])
return instanceId
}
export function DiffViewer({
fromContent,
toContent,
fromLabel = 'From',
toLabel = 'To',
filename,
defaultOpen = true,
}: DiffViewerProps) {
const diffResult = React.useMemo(() => {
return computeDiff(fromContent, toContent)
}, [fromContent, toContent])
const [highlightedLines, setHighlightedLines] = React.useState<Map<number, string>>(new Map())
// Generate unique instance ID for scroll sync
const instanceId = React.useId()
useSyncedScroll(instanceId)
// Highlight all lines with shiki
React.useEffect(() => {
async function highlightAllLines() {
const newHighlighted = new Map<number, string>()
for (let i = 0; i < diffResult.lines.length; i++) {
const line = diffResult.lines[i]
if (line.content.trim()) {
const highlighted = await highlightLine(line.content)
newHighlighted.set(i, highlighted)
} else {
newHighlighted.set(i, '')
}
}
setHighlightedLines(newHighlighted)
}
highlightAllLines()
}, [diffResult.lines])
const leftLines = diffResult.lines.filter(l => l.type !== 'added')
const rightLines = diffResult.lines.filter(l => l.type !== 'removed')
// Create line number maps for proper indexing
const leftLineMap = React.useMemo(() => {
const map = new Map<number, number>()
let leftIdx = 0
diffResult.lines.forEach((line, idx) => {
if (line.type !== 'added') {
map.set(leftIdx, idx)
leftIdx++
}
})
return map
}, [diffResult.lines])
const rightLineMap = React.useMemo(() => {
const map = new Map<number, number>()
let rightIdx = 0
diffResult.lines.forEach((line, idx) => {
if (line.type !== 'removed') {
map.set(rightIdx, idx)
rightIdx++
}
})
return map
}, [diffResult.lines])
const diffContent = (
<>
<div className="flex flex-col md:flex-row gap-4 h-[600px]">
{/* Left Panel - From */}
<div className="flex-1 min-w-0 flex flex-col h-full" data-left-panel data-diff-instance={instanceId}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border border-b-0 border-border rounded-t-lg">
<span className="text-sm font-medium text-muted-foreground">{fromLabel}</span>
<span className="text-xs text-muted-foreground">
{leftLines.length} lines
</span>
</div>
<div className="flex-1 min-h-0">
<ScrollArea className="h-full border border-t-0 border-border rounded-b-lg">
<div className="font-mono text-sm w-fit">
{leftLines.map((line, leftIdx) => {
const originalIdx = leftLineMap.get(leftIdx) ?? 0
const highlightedContent = highlightedLines.get(originalIdx) ?? escapeHtml(line.content)
return (
<DiffLineRenderer
key={`left-${leftIdx}`}
line={line}
highlightedContent={highlightedContent}
/>
)
})}
</div>
</ScrollArea>
</div>
</div>
{/* Right Panel - To */}
<div className="flex-1 min-w-0 flex flex-col h-full" data-right-panel data-diff-instance={instanceId}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border border-b-0 border-border rounded-t-lg">
<span className="text-sm font-medium text-muted-foreground">{toLabel}</span>
<span className="text-xs text-muted-foreground">
{rightLines.length} lines
</span>
</div>
<div className="flex-1 min-h-0">
<ScrollArea className="h-full border border-t-0 border-border rounded-b-lg">
<div className="font-mono text-sm w-fit">
{rightLines.map((line, rightIdx) => {
const originalIdx = rightLineMap.get(rightIdx) ?? 0
const highlightedContent = highlightedLines.get(originalIdx) ?? escapeHtml(line.content)
return (
<DiffLineRenderer
key={`right-${rightIdx}`}
line={line}
highlightedContent={highlightedContent}
/>
)
})}
</div>
</ScrollArea>
</div>
</div>
</div>
{/* Stats */}
<div className="mt-4 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-green-500/50 rounded-sm" />
<span className="text-muted-foreground">{diffResult.addedCount} additions</span>
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-red-500/50 rounded-sm" />
<span className="text-muted-foreground">{diffResult.removedCount} deletions</span>
</span>
</div>
</>
)
// If no filename, render without collapsible wrapper
if (!filename) {
return (
<div className="w-full">
{diffContent}
</div>
)
}
return (
<Collapsible defaultOpen={defaultOpen} className="w-full">
<CollapsibleTrigger className="group flex items-center gap-2 w-full py-2 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-md transition-colors">
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
<span className="font-mono text-xs">{filename}</span>
<span className="ml-auto text-xs text-muted-foreground">
{diffResult.addedCount > 0 && (
<span className="text-green-500 mr-2">+{diffResult.addedCount}</span>
)}
{diffResult.removedCount > 0 && (
<span className="text-red-500">-{diffResult.removedCount}</span>
)}
</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 mb-8">
{diffContent}
</CollapsibleContent>
</Collapsible>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { PlusIcon, MinusIcon } from 'lucide-react'
interface FileItem {
path: string
status: string
anchorId: string
}
interface FileNavDropdownProps {
files: FileItem[]
}
function getStatusIcon(status: string) {
switch (status) {
case 'added':
return <PlusIcon className="size-3 text-green-500" />
case 'removed':
return <MinusIcon className="size-3 text-red-500" />
default:
return null
}
}
export function FileNavDropdown({ files }: FileNavDropdownProps) {
const handleValueChange = (anchorId: string | null) => {
if (!anchorId) return
const element = document.getElementById(anchorId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
return (
<Select onValueChange={handleValueChange}>
<SelectTrigger className="min-w-64">
<SelectValue placeholder={`${files.length} files`} />
</SelectTrigger>
<SelectContent align="end">
{files.map((file) => (
<SelectItem key={file.anchorId} value={file.anchorId}>
<div className="flex items-center gap-2">
{getStatusIcon(file.status)}
<span className="font-mono text-xs">{file.path}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link'
import { Github } from 'lucide-react'
import { ThemeToggle } from './theme-toggle'
export function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between">
<Link href="/" className="flex items-center space-x-2">
<span className="font-bold">Build Your Own OpenClaw</span>
</Link>
<nav className="flex items-center gap-2">
<a
href="https://github.com/czl9707/build-your-own-openclaw"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground h-9 w-9"
aria-label="GitHub repository"
>
<Github className="h-5 w-5" />
</a>
<ThemeToggle />
</nav>
</div>
</header>
)
}
+212
View File
@@ -0,0 +1,212 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
import rehypeRaw from 'rehype-raw'
import { H1, H2, H3, H4, P } from '@/components/ui/typography'
import { CodeBlock } from './code-block'
import type { Components } from 'react-markdown'
// GitHub repository base URL for source file links
const GITHUB_REPO_URL = 'https://github.com/czl9707/build-your-own-openclaw'
// Step folder to step ID mapping (e.g., "01-tools" -> "01")
const STEP_FOLDER_PATTERN = /^(\d{2})-/
interface ReadmeRendererProps {
content: string
stepFolder: string
}
/**
* Strips the first H1 heading from markdown content
* to avoid duplication when the title is shown separately
*/
function stripFirstH1(markdown: string): string {
const lines = markdown.split('\n')
const firstNonEmptyIndex = lines.findIndex(line => line.trim() !== '')
if (firstNonEmptyIndex !== -1 && lines[firstNonEmptyIndex].trim().startsWith('# ')) {
lines.splice(firstNonEmptyIndex, 1)
if (lines[firstNonEmptyIndex]?.trim() === '') {
lines.splice(firstNonEmptyIndex, 1)
}
}
return lines.join('\n')
}
/**
* Rewrites relative image paths to absolute public paths
* Handles both HTML <img> tags and markdown ![](src) syntax
*/
function rewriteImagePaths(markdown: string, stepFolder: string): string {
// Rewrite HTML <img src="..."> tags
let result = markdown.replace(
/<img\s+([^>]*?)src=["']([^"']+)["']([^>]*)>/gi,
(match, before, src, after) => {
if (!src.startsWith('http') && !src.startsWith('/')) {
return `<img ${before}src="/steps/${stepFolder}/${src}"${after}>`
}
return match
}
)
// Rewrite markdown image syntax ![alt](src)
result = result.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(match, alt, src) => {
if (!src.startsWith('http') && !src.startsWith('/')) {
return `![${alt}](/steps/${stepFolder}/${src})`
}
return match
}
)
return result
}
/**
* Rewrites links to handle:
* 1. Source file links (.py, .yaml, etc.) -> point to GitHub
* 2. Step directory links (../02-skills/, 01-tools/) -> point to internal step pages
*/
function rewriteLinks(markdown: string, stepFolder: string): string {
// Match markdown links: [text](href)
return markdown.replace(
/\[([^\]]*)\]\(([^)]+)\)/g,
(match, text, href) => {
// Skip external links
if (href.startsWith('http://') || href.startsWith('https://')) {
return match
}
// Skip image links (handled separately)
if (match.startsWith('!')) {
return match
}
// Check for step directory links (e.g., ../02-skills/, 01-tools/, ./03-persistence/)
const stepMatch = href.match(/^(?:\.\.\/|\.\/)?(\d{2}-[\w-]+)\/?$/)
if (stepMatch) {
const folderName = stepMatch[1]
const idMatch = folderName.match(STEP_FOLDER_PATTERN)
if (idMatch) {
return `[${text}](/steps/${idMatch[1]})`
}
}
// Check for source file links (.py, .yaml, .json, .toml, .txt, etc.)
const sourceFileExtensions = ['.py', '.yaml', '.yml', '.json', '.toml', '.txt', '.md', '.cfg', '.ini']
const isSourceFile = sourceFileExtensions.some(ext => href.endsWith(ext))
if (isSourceFile && !href.startsWith('/')) {
// Resolve relative path
const resolvedPath = href.startsWith('../')
? href.replace(/^\.\.\//, '') // For now, just remove ../ (assumes main branch)
: href
return `[${text}](${GITHUB_REPO_URL}/tree/main/${stepFolder}/${resolvedPath})`
}
return match
}
)
}
const components: Components = {
h1: ({ children }) => <H1>{children}</H1>,
h2: ({ children }) => <H2>{children}</H2>,
h3: ({ children }) => <H3>{children}</H3>,
h4: ({ children }) => <H4>{children}</H4>,
p: ({ children }) => <P>{children}</P>,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isInline = !match
if (isInline) {
return (
<code
className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
)
}
return (
<CodeBlock
language={match[1]}
code={String(children).replace(/\n$/, '')}
/>
)
},
pre: ({ children }) => <>{children}</>,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline underline-offset-4 hover:text-primary/80 transition-colors"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
ul: ({ children }) => <ul className="my-6 ml-6 list-disc [&>li]:mt-2">{children}</ul>,
ol: ({ children }) => <ol className="my-6 ml-6 list-decimal [&>li]:mt-2">{children}</ol>,
li: ({ children }) => <li className="leading-7">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="my-6 border-l-4 border-primary pl-6 italic text-muted-foreground">
{children}
</blockquote>
),
hr: () => <hr className="my-8 border-border" />,
table: ({ children }) => (
<div className="my-6 w-full overflow-y-auto">
<table className="w-full border-collapse border border-border">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border even:bg-muted/50">{children}</tr>
),
th: ({ children }) => (
<th className="border border-border px-4 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-4 py-2">{children}</td>
),
img: ({ src, alt, ...props }) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
className="max-w-full h-auto rounded-lg my-6"
{...props}
/>
),
}
export function ReadmeRenderer({ content, stepFolder }: ReadmeRendererProps) {
const processedContent = rewriteLinks(
rewriteImagePaths(stripFirstH1(content), stepFolder),
stepFolder
)
return (
<div className="prose prose-neutral dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeRaw]}
components={components}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Muted } from '@/components/ui/typography'
import type { Step } from '@/lib/steps'
interface StepCardProps {
step: Step
}
export function StepCard({ step }: StepCardProps) {
return (
<Link href={`/steps/${step.id}`}>
<Card className="h-full transition-colors hover:border-primary">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Badge variant="outline">{step.id}</Badge>
</div>
<CardTitle className="text-lg">{step.title}</CardTitle>
</CardHeader>
<CardContent>
<Muted className="line-clamp-2">{step.description || `Phase ${step.phase}`}</Muted>
</CardContent>
</Card>
</Link>
)
}
+11
View File
@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+38
View File
@@ -0,0 +1,38 @@
'use client'
import * as React from 'react'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground h-10 w-10 border border-input bg-background">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+60
View File
@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-border has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+86
View File
@@ -0,0 +1,86 @@
import { cn } from '@/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import React from 'react'
const typographyVariants = cva('', {
variants: {
variant: {
h1: 'scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl',
h2: 'scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0',
h3: 'scroll-m-20 text-2xl font-semibold tracking-tight',
h4: 'scroll-m-20 text-xl font-semibold tracking-tight',
p: 'leading-7 [&:not(:first-child)]:mt-6',
lead: 'text-xl text-muted-foreground',
large: 'text-lg font-semibold',
small: 'text-sm font-medium leading-none',
muted: 'text-sm text-muted-foreground',
},
},
})
type TypographyVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'lead' | 'large' | 'small' | 'muted'
interface TypographyProps extends React.HTMLAttributes<HTMLElement> {
variant?: TypographyVariant
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div' | 'small'
}
export function Typography({
variant = 'p',
as,
className,
...props
}: TypographyProps) {
const Element = as ?? (
variant === 'h1' ? 'h1' :
variant === 'h2' ? 'h2' :
variant === 'h3' ? 'h3' :
variant === 'h4' ? 'h4' :
variant === 'lead' || variant === 'muted' ? 'p' :
variant === 'large' ? 'div' :
variant === 'small' ? 'small' : 'p'
)
return (
<Element
className={cn(typographyVariants({ variant }), className)}
{...props}
/>
)
}
// Convenience exports
export function H1(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="h1" as="h1" {...props} />
}
export function H2(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="h2" as="h2" {...props} />
}
export function H3(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="h3" as="h3" {...props} />
}
export function H4(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="h4" as="h4" {...props} />
}
export function P(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="p" as="p" {...props} />
}
export function Lead(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="lead" as="p" {...props} />
}
export function Large(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="large" as="div" {...props} />
}
export function Small(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="small" as="small" {...props} />
}
export function Muted(props: Omit<TypographyProps, 'variant' | 'as'>) {
return <Typography variant="muted" as="p" {...props} />
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+35
View File
@@ -0,0 +1,35 @@
export const PHASES = {
1: {
name: 'Capable Single Agent',
steps: ['00', '01', '02', '03', '04', '05', '06'],
description: 'Build a fully-functional agent that can chat, use tools, learn skills, remember conversations, and access the internet.',
},
2: {
name: 'Event-Driven Architecture',
steps: ['07', '08', '09', '10'],
description: 'Refactor to event-driven architecture for scalability and multi-platform support.',
},
3: {
name: 'Autonomous & Multi-Agent',
steps: ['11', '12', '13', '14', '15'],
description: 'Add scheduled tasks, agent collaboration, and intelligent routing.',
},
4: {
name: 'Production & Scale',
steps: ['16', '17'],
description: 'Production features for reliability and long-term memory.',
},
} as const
export const SKIP_PATTERNS = [
'**/.venv/**',
'**/__pycache__/**',
'**/node_modules/**',
'**/*.lock',
'**/*.svg',
'**/*.png',
'**/*.jpg',
'**/*.pyc',
]
export const STEPS_DIR = '../' // Relative to web/ directory
+62
View File
@@ -0,0 +1,62 @@
import * as Diff from 'diff'
export interface DiffLine {
type: 'added' | 'removed' | 'unchanged'
content: string
oldLineNumber?: number
newLineNumber?: number
}
export interface DiffResult {
lines: DiffLine[]
addedCount: number
removedCount: number
}
/**
* Compute line-by-line diff between two strings
*/
export function computeDiff(fromContent: string, toContent: string): DiffResult {
const changes = Diff.diffLines(fromContent, toContent)
const lines: DiffLine[] = []
let addedCount = 0
let removedCount = 0
let oldLine = 1
let newLine = 1
for (const change of changes) {
const changeLines = change.value.split('\n')
// Remove last empty string if content ends with newline
if (changeLines[changeLines.length - 1] === '') {
changeLines.pop()
}
for (const line of changeLines) {
if (change.added) {
lines.push({
type: 'added',
content: line,
newLineNumber: newLine++,
})
addedCount++
} else if (change.removed) {
lines.push({
type: 'removed',
content: line,
oldLineNumber: oldLine++,
})
removedCount++
} else {
lines.push({
type: 'unchanged',
content: line,
oldLineNumber: oldLine++,
newLineNumber: newLine++,
})
}
}
}
return { lines, addedCount, removedCount }
}
+114
View File
@@ -0,0 +1,114 @@
import fs from 'fs'
import path from 'path'
import { glob } from 'glob'
import { SKIP_PATTERNS, STEPS_DIR } from './constants'
export interface FileDiff {
path: string
status: 'added' | 'removed' | 'modified' | 'unchanged'
fromContent?: string
toContent?: string
}
/**
* Get all Python files in a step directory
*/
function getPythonFiles(stepFolder: string): string[] {
const stepPath = path.join(process.cwd(), STEPS_DIR, stepFolder, 'src', 'mybot')
if (!fs.existsSync(stepPath)) {
return []
}
const files = glob.sync('**/*.py', {
cwd: stepPath,
ignore: SKIP_PATTERNS,
nodir: true,
})
return files
}
/**
* Read file content from a step directory
*/
function readFileContent(stepFolder: string, relativePath: string): string | null {
const filePath = path.join(process.cwd(), STEPS_DIR, stepFolder, 'src', 'mybot', relativePath)
if (!fs.existsSync(filePath)) {
return null
}
return fs.readFileSync(filePath, 'utf-8')
}
/**
* Discover and compare files between two steps
*/
export function discoverFiles(fromFolder: string, toFolder: string): FileDiff[] {
const fromFiles = new Set(getPythonFiles(fromFolder))
const toFiles = new Set(getPythonFiles(toFolder))
const allFiles = new Set([...fromFiles, ...toFiles])
const results: FileDiff[] = []
for (const file of allFiles) {
const inFrom = fromFiles.has(file)
const inTo = toFiles.has(file)
const fromContent = inFrom ? (readFileContent(fromFolder, file) ?? undefined) : undefined
const toContent = inTo ? (readFileContent(toFolder, file) ?? undefined) : undefined
let status: FileDiff['status']
if (!inFrom && inTo) {
status = 'added'
} else if (inFrom && !inTo) {
status = 'removed'
} else if (fromContent !== toContent) {
status = 'modified'
} else {
status = 'unchanged'
}
results.push({
path: file,
status,
fromContent,
toContent,
})
}
// Sort: modified first, then added, then removed, then unchanged
const statusOrder: Record<FileDiff['status'], number> = {
modified: 0,
added: 1,
removed: 2,
unchanged: 3,
}
results.sort((a, b) => {
const orderDiff = statusOrder[a.status] - statusOrder[b.status]
if (orderDiff !== 0) return orderDiff
return a.path.localeCompare(b.path)
})
return results
}
/**
* Get only changed files (exclude unchanged)
*/
export function getChangedFiles(fromFolder: string, toFolder: string): FileDiff[] {
return discoverFiles(fromFolder, toFolder).filter(
(f) => f.status !== 'unchanged'
)
}
/**
* Get only unchanged files
*/
export function getUnchangedFiles(fromFolder: string, toFolder: string): FileDiff[] {
return discoverFiles(fromFolder, toFolder).filter(
(f) => f.status === 'unchanged'
)
}
+120
View File
@@ -0,0 +1,120 @@
import fs from 'fs'
import path from 'path'
import { PHASES, STEPS_DIR } from './constants'
export interface Step {
id: string
title: string
description: string
phase: number
folderName: string
readmePath: string
}
/**
* Get the folder name for a step ID (e.g., "00" -> "00-chat-loop")
*/
function getFolderName(stepId: string): string | null {
const stepsDir = path.join(process.cwd(), STEPS_DIR)
const entries = fs.readdirSync(stepsDir, { withFileTypes: true })
const folder = entries.find(
(e) => e.isDirectory() && e.name.startsWith(`${stepId}-`)
)
return folder?.name ?? null
}
/**
* Parse title from README H1
* Format: "# Step XX: Title Here" -> "Title Here"
*/
function parseTitle(readmeContent: string): string {
const h1Match = readmeContent.match(/^# .+/m)
if (!h1Match) return 'Unknown'
const fullTitle = h1Match[0].replace('# ', '')
return fullTitle.split(': ')[1] ?? fullTitle
}
/**
* Parse the first blockquote from README
* Format: "> Description text here" -> "Description text here"
*/
function parseDescription(readmeContent: string): string {
const blockquoteMatch = readmeContent.match(/^>\s*(.+)$/m)
return blockquoteMatch?.[1]?.trim() ?? ''
}
/**
* Get phase number for a step ID
*/
function getPhase(stepId: string): number {
const phaseEntries = Object.entries(PHASES) as [string, typeof PHASES[1]][]
for (const [phase, data] of phaseEntries) {
if (data.steps.includes(stepId as any)) {
return parseInt(phase, 10)
}
}
return 0
}
/**
* Load all steps from the parent directory
*/
export function loadSteps(): Step[] {
const steps: Step[] = []
const phaseEntries = Object.entries(PHASES) as [string, typeof PHASES[1]][]
for (const [phaseStr, phaseData] of phaseEntries) {
for (const stepId of phaseData.steps) {
const folderName = getFolderName(stepId)
if (!folderName) continue
const readmePath = path.join(process.cwd(), STEPS_DIR, folderName, 'README.md')
let title = `Step ${stepId}`
let description = ''
if (fs.existsSync(readmePath)) {
const content = fs.readFileSync(readmePath, 'utf-8')
title = parseTitle(content)
description = parseDescription(content)
}
steps.push({
id: stepId,
title,
description,
phase: parseInt(phaseStr, 10),
folderName,
readmePath,
})
}
}
return steps
}
// Cache steps at module level for build
let _steps: Step[] | null = null
export function getSteps(): Step[] {
if (!_steps) {
_steps = loadSteps()
}
return _steps
}
export function getStep(id: string): Step | undefined {
return getSteps().find((s) => s.id === id)
}
export function getStepsByPhase(): Record<number, Step[]> {
const steps = getSteps()
const result: Record<number, Step[]> = {}
for (const step of steps) {
if (!result[step.phase]) {
result[step.phase] = []
}
result[step.phase].push(step)
}
return result
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export',
// basePath: '/build-your-own-openclaw', // Uncomment for GitHub Pages
}
export default nextConfig
+11803
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"copy-assets": "node scripts/copy-step-assets.js",
"dev": "npm run copy-assets && next dev",
"build": "npm run copy-assets && next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^8.0.3",
"glob": "^13.0.6",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.0.6",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
build-your-own-openclaw.kiyo-n-zane.com
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..', '..')
const publicDir = path.resolve(__dirname, '..', 'public', 'steps')
// Ensure public/steps directory exists
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true })
}
// Get all step directories
const stepDirs = fs.readdirSync(rootDir).filter(dir => {
const fullPath = path.join(rootDir, dir)
return fs.statSync(fullPath).isDirectory() && /^\d{2}-/.test(dir)
})
console.log(`Found ${stepDirs.length} step directories`)
// Copy SVG and other asset files from each step directory
let copiedCount = 0
for (const stepDir of stepDirs) {
const stepPath = path.join(rootDir, stepDir)
const files = fs.readdirSync(stepPath)
// Create destination directory for this step
const destStepDir = path.join(publicDir, stepDir)
if (!fs.existsSync(destStepDir)) {
fs.mkdirSync(destStepDir, { recursive: true })
}
// Copy image files (svg, png, jpg, gif)
const imageFiles = files.filter(file =>
/\.(svg|png|jpe?g|gif|webp)$/i.test(file)
)
for (const file of imageFiles) {
const srcPath = path.join(stepPath, file)
const destPath = path.join(destStepDir, file)
fs.copyFileSync(srcPath, destPath)
copiedCount++
console.log(`Copied: ${stepDir}/${file}`)
}
}
console.log(`\nDone! Copied ${copiedCount} files to public/steps/`)
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}