← back to ClawCoder
src/app/builder/page.tsx
585 lines
'use client'
import { useEffect, useState, useCallback, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import type { Profile } from '@/lib/profiles/types'
import type { MarketplaceListing } from '@/lib/marketplace/data'
interface GeneratedBundle {
claudeMd: string
rulesFiles: { path: string; content: string }[]
settings: object
settingsJson: string
profile: {
slug: string
displayName: string
ageGroup: string
skillLevel: string
persona: string
}
}
type TabId = 'claude_md' | 'rules' | 'hooks' | 'bundle'
const tabs: { id: TabId; label: string }[] = [
{ id: 'claude_md', label: 'CLAUDE.md' },
{ id: 'rules', label: 'Rules' },
{ id: 'hooks', label: 'Hooks' },
{ id: 'bundle', label: 'Bundle' },
]
function BuilderContent() {
const searchParams = useSearchParams()
const profileSlug = searchParams.get('profile') || ''
const [profile, setProfile] = useState<Profile | null>(null)
const [bundle, setBundle] = useState<GeneratedBundle | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [activeTab, setActiveTab] = useState<TabId>('claude_md')
const [sidebarOpen, setSidebarOpen] = useState(true)
const [copied, setCopied] = useState(false)
const [marketplaceRecs, setMarketplaceRecs] = useState<MarketplaceListing[]>([])
const [copiedInstall, setCopiedInstall] = useState<string | null>(null)
// Project variables
const [vars, setVars] = useState({
projectName: 'My Project',
projectDescription: 'A new project built with Claude.',
techStack: 'TypeScript, Node.js',
installCmd: 'npm install',
devCmd: 'npm run dev',
testCmd: 'npm test',
lintCmd: 'npm run lint',
buildCmd: 'npm run build',
})
// Load profile
useEffect(() => {
if (!profileSlug) {
setLoading(false)
return
}
fetch(`/api/profiles?slug=${profileSlug}`)
.then((r) => {
if (!r.ok) throw new Error('Not found')
return r.json()
})
.then((data) => {
setProfile(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [profileSlug])
// Load marketplace recommendations for this profile
useEffect(() => {
if (!profileSlug) return
fetch(`/api/marketplace?profile=${profileSlug}`)
.then((r) => (r.ok ? r.json() : []))
.then((data: MarketplaceListing[]) => setMarketplaceRecs(data.slice(0, 5)))
.catch(() => setMarketplaceRecs([]))
}, [profileSlug])
// Generate bundle
const generate = useCallback(async () => {
if (!profileSlug) return
setGenerating(true)
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileSlug, vars }),
})
const data = await res.json()
if (res.ok) {
setBundle(data as GeneratedBundle)
}
} catch {
// silent
} finally {
setGenerating(false)
}
}, [profileSlug, vars])
// Auto-generate on first load
useEffect(() => {
if (profile && !bundle) {
generate()
}
}, [profile, bundle, generate])
const handleCopy = async (content: string) => {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleDownload = (content: string, filename: string) => {
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
const handleDownloadAll = () => {
if (!bundle) return
// Create a combined text file as a zip-like bundle
let combined = '=== CLAUDE.md ===\n\n'
combined += bundle.claudeMd + '\n\n'
if (bundle.rulesFiles.length > 0) {
for (const rf of bundle.rulesFiles) {
combined += `=== ${rf.path} ===\n\n`
combined += rf.content + '\n\n'
}
}
combined += '=== .claude/settings.json ===\n\n'
combined += bundle.settingsJson + '\n'
handleDownload(combined, `clawcoder-bundle-${profileSlug}.txt`)
}
function getTabContent(): string {
if (!bundle) return ''
switch (activeTab) {
case 'claude_md':
return bundle.claudeMd
case 'rules':
return bundle.rulesFiles.length > 0
? bundle.rulesFiles
.map((rf) => `--- ${rf.path} ---\n${rf.content}`)
.join('\n\n')
: '(No rules files for this profile)'
case 'hooks':
return bundle.settingsJson
case 'bundle':
return JSON.stringify(bundle, null, 2)
default:
return ''
}
}
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600" />
</div>
)
}
if (!profileSlug || !profile) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<h1 className="text-2xl font-bold text-slate-900">No profile selected</h1>
<p className="mt-2 text-slate-600">
Go to the{' '}
<a href="/" className="text-blue-600 hover:underline">
home page
</a>{' '}
to choose a profile, or use a URL like{' '}
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-sm">
/builder?profile=teen-beginner
</code>
</p>
</div>
)
}
const ageLabels: Record<string, string> = {
teen: 'Teen',
young_adult: 'Young Adult',
professional: 'Professional',
pre_senior: 'Pre-Senior',
senior: 'Senior',
}
const skillLabels: Record<string, string> = {
beginner: 'Beginner',
intermediate: 'Intermediate',
expert: 'Expert',
}
return (
<div className="flex flex-1 overflow-hidden">
{/* Sidebar Toggle (mobile) */}
<button
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="fixed bottom-20 left-4 z-30 min-h-[44px] min-w-[44px] rounded-full bg-slate-800 p-3 text-white shadow-lg lg:hidden"
aria-label={sidebarOpen ? 'Close sidebar' : 'Open sidebar'}
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
{sidebarOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
)}
</svg>
</button>
{/* Left Panel - Sidebar */}
<aside
className={`flex-shrink-0 overflow-y-auto border-r border-slate-200 bg-slate-50 transition-all ${
sidebarOpen ? 'w-80' : 'w-0 overflow-hidden'
} fixed inset-y-16 left-0 z-20 lg:relative lg:inset-auto`}
>
<div className="space-y-6 p-5">
{/* Profile Summary */}
<div>
<h2 className="text-lg font-bold text-slate-900">
{profile.displayName}
</h2>
<div className="mt-1 flex gap-2">
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{ageLabels[profile.ageGroup] || profile.ageGroup}
</span>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-600">
{skillLabels[profile.skillLevel] || profile.skillLevel}
</span>
</div>
<p className="mt-2 text-xs text-slate-500">{profile.persona}</p>
</div>
{/* Project Variables Form */}
<div>
<h3 className="mb-3 text-sm font-semibold text-slate-700">
Project Variables
</h3>
<div className="space-y-3">
{(
[
['projectName', 'Project Name'],
['projectDescription', 'Description'],
['techStack', 'Tech Stack'],
['installCmd', 'Install Command'],
['devCmd', 'Dev Command'],
['testCmd', 'Test Command'],
['lintCmd', 'Lint Command'],
['buildCmd', 'Build Command'],
] as const
).map(([key, label]) => (
<div key={key}>
<label
htmlFor={`var-${key}`}
className="mb-1 block text-xs font-medium text-slate-600"
>
{label}
</label>
<input
id={`var-${key}`}
type="text"
value={vars[key as keyof typeof vars]}
onChange={(e) =>
setVars((prev) => ({
...prev,
[key]: e.target.value,
}))
}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
))}
</div>
</div>
{/* Skills */}
{profile.skillBundle.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
Skills ({profile.skillBundle.length})
</h3>
<ul className="space-y-1">
{profile.skillBundle.map((s) => (
<li
key={s.name}
className="text-xs text-slate-600"
title={s.description}
>
{s.name}
</li>
))}
</ul>
</div>
)}
{/* MCP Servers */}
{profile.mcpServers.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
MCP Servers ({profile.mcpServers.length})
</h3>
<ul className="space-y-2">
{profile.mcpServers.map((m) => (
<li key={m.name}>
<p className="text-xs font-medium text-slate-700">
{m.name}
{m.requiresKey && (
<span className="ml-1 text-yellow-600">(key required)</span>
)}
</p>
<code className="block rounded bg-slate-200 px-2 py-1 text-[10px] text-slate-600">
{m.installCmd}
</code>
</li>
))}
</ul>
</div>
)}
{/* Subagents */}
{profile.subagents.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
Subagents ({profile.subagents.length})
</h3>
<ul className="space-y-1">
{profile.subagents.map((sa) => (
<li key={sa.name} className="text-xs text-slate-600">
{sa.name} ({sa.model}) - {sa.tools.join(', ')}
</li>
))}
</ul>
</div>
)}
{/* Hooks Summary */}
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
Hooks
</h3>
<ul className="space-y-1 text-xs text-slate-600">
{profile.hooks.PreToolUse && (
<li>PreToolUse: {profile.hooks.PreToolUse.length} rule(s)</li>
)}
{profile.hooks.PostToolUse && (
<li>PostToolUse: {profile.hooks.PostToolUse.length} rule(s)</li>
)}
{profile.hooks.Stop && (
<li>Stop: {profile.hooks.Stop.length} rule(s)</li>
)}
{profile.hooks.SessionStart && (
<li>SessionStart: {profile.hooks.SessionStart.length} rule(s)</li>
)}
{profile.hooks.PreCompact && (
<li>PreCompact: {profile.hooks.PreCompact.length} rule(s)</li>
)}
{!profile.hooks.PreToolUse &&
!profile.hooks.PostToolUse &&
!profile.hooks.Stop &&
!profile.hooks.SessionStart &&
!profile.hooks.PreCompact && (
<li className="text-slate-400">No hooks configured</li>
)}
</ul>
</div>
{/* Learning Resources */}
{profile.learningPath.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
Learning Resources
</h3>
<ul className="space-y-1">
{profile.learningPath.map((r) => (
<li key={r.url}>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline"
>
{r.title}
</a>
<span className="ml-1 text-[10px] text-slate-400">
({r.type})
</span>
</li>
))}
</ul>
</div>
)}
{/* Marketplace Recommendations */}
{marketplaceRecs.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
Recommended from{' '}
<a href="/marketplace" className="text-blue-600 hover:underline">
Marketplace
</a>
</h3>
<ul className="space-y-2">
{marketplaceRecs.map((rec) => (
<li
key={rec.id}
className="flex items-start justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-slate-700">
{rec.name}
</p>
<span
className={`mt-0.5 inline-block rounded-full px-1.5 py-0 text-[9px] font-semibold uppercase tracking-wide ${
rec.category === 'skill'
? 'bg-violet-100 text-violet-700'
: rec.category === 'subagent'
? 'bg-amber-100 text-amber-700'
: rec.category === 'mcp-server'
? 'bg-sky-100 text-sky-700'
: rec.category === 'hook'
? 'bg-rose-100 text-rose-700'
: 'bg-emerald-100 text-emerald-700'
}`}
>
{rec.category.replace('-', ' ')}
</span>
</div>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(rec.installCmd)
} catch {
const ta = document.createElement('textarea')
ta.value = rec.installCmd
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopiedInstall(rec.id)
setTimeout(() => setCopiedInstall(null), 2000)
}}
className="shrink-0 rounded-md bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100 transition-colors"
>
{copiedInstall === rec.id ? 'Copied!' : 'Install'}
</button>
</li>
))}
</ul>
</div>
)}
{/* Regenerate Button */}
<button
type="button"
onClick={generate}
disabled={generating}
className="w-full min-h-[44px] rounded-xl bg-blue-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{generating ? 'Regenerating...' : 'Regenerate'}
</button>
</div>
</aside>
{/* Right Panel - Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs */}
<div className="flex items-center justify-between border-b border-slate-200 bg-white px-4">
<div className="flex">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleCopy(getTabContent())}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
type="button"
onClick={() => {
const ext =
activeTab === 'hooks' ? 'json' : activeTab === 'bundle' ? 'json' : 'md'
handleDownload(
getTabContent(),
activeTab === 'claude_md'
? 'CLAUDE.md'
: `${activeTab}.${ext}`
)
}}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
Download
</button>
<button
type="button"
onClick={handleDownloadAll}
disabled={!bundle}
className="rounded-lg bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700 disabled:opacity-50"
>
Download All
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto bg-slate-50 p-6">
{generating ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600" />
</div>
) : bundle ? (
<pre className="whitespace-pre-wrap rounded-xl border border-slate-200 bg-white p-6 font-mono text-sm text-slate-700">
{getTabContent()}
</pre>
) : (
<p className="text-sm text-slate-500">
Click "Regenerate" to generate configuration files.
</p>
)}
</div>
</div>
</div>
)
}
export default function BuilderPage() {
return (
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600" />
</div>
}
>
<BuilderContent />
</Suspense>
)
}