mirror of
https://github.com/czl9707/build-your-own-openclaw.git
synced 2026-08-14 00:47:59 +00:00
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:
@@ -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
@@ -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
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{fromStep.title}" with "{toStep.title}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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  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 
|
||||
result = result.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
(match, alt, src) => {
|
||||
if (!src.startsWith('http') && !src.startsWith('/')) {
|
||||
return ``
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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
|
||||
Generated
+11803
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
build-your-own-openclaw.kiyo-n-zane.com
|
||||
@@ -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/`)
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user