← 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 &quot;Regenerate&quot; 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>
  )
}