camelmind 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/index.js +4 -1
  2. package/package.json +4 -2
  3. package/template/app/[...slug]/page.tsx +27 -5
  4. package/template/app/api/feedback/route.ts +40 -0
  5. package/template/app/api-reference/[[...slug]]/page.tsx +165 -0
  6. package/template/app/home/page.tsx +6 -0
  7. package/template/components/ApiReference/ApiOverview.tsx +63 -0
  8. package/template/components/ApiReference/ApiSidebar.tsx +69 -0
  9. package/template/components/ApiReference/CodeSamples.tsx +186 -0
  10. package/template/components/ApiReference/EndpointPage.tsx +92 -0
  11. package/template/components/ApiReference/ExpandableCode.tsx +132 -0
  12. package/template/components/ApiReference/MethodBadge.tsx +29 -0
  13. package/template/components/ApiReference/ParamsTable.tsx +70 -0
  14. package/template/components/ApiReference/ResponseContext.tsx +32 -0
  15. package/template/components/ApiReference/ResponseDocs.tsx +135 -0
  16. package/template/components/ApiReference/ResponseExample.tsx +98 -0
  17. package/template/components/ApiReference/SchemaViewer.tsx +205 -0
  18. package/template/components/DocFeedback/DocFeedback.tsx +155 -0
  19. package/template/components/LastUpdated/LastUpdated.tsx +23 -0
  20. package/template/components/Nav/TopNav.tsx +14 -1
  21. package/template/components/PageNav/PageNav.tsx +2 -2
  22. package/template/lib/api-reference.ts +441 -0
  23. package/template/lib/api-types.ts +102 -0
  24. package/template/lib/api-utils.ts +62 -0
  25. package/template/lib/config-types.ts +16 -0
  26. package/template/lib/config.ts +12 -0
  27. package/template/lib/mdx.ts +27 -1
  28. package/template/lib/versions.ts +26 -0
package/dist/index.js CHANGED
@@ -77,7 +77,10 @@ async function init(projectName, options) {
77
77
  process.exit(1);
78
78
  }
79
79
  await fs.copy(TEMPLATE_DIR, targetDir, {
80
- filter: (src) => !src.includes("node_modules") && !src.includes(".next") && !src.includes(".git")
80
+ filter: (src) => {
81
+ const rel = path.relative(TEMPLATE_DIR, src);
82
+ return !rel.includes("node_modules") && !rel.includes(".next") && !rel.includes(".git");
83
+ }
81
84
  });
82
85
  const pkgSrc = path.join(targetDir, "_package.json");
83
86
  const pkgDest = path.join(targetDir, "package.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camelmind",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Create and manage CamelMind documentation sites",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,8 +14,10 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsup",
17
+ "build:cli": "tsup",
17
18
  "dev": "tsup --watch",
18
- "prepublishOnly": "npm run build"
19
+ "sync-template": "node scripts/sync-template.mjs",
20
+ "prepublishOnly": "npm run sync-template && npm run build"
19
21
  },
20
22
  "dependencies": {
21
23
  "chalk": "^5.3.0",
@@ -12,7 +12,7 @@ import {
12
12
  } from "@/lib/nav"
13
13
  import { loadMdxFile } from "@/lib/mdx"
14
14
  import { getSession, hasAccess, pageRequiresAuth, shouldRedirectToLogin } from "@/lib/auth"
15
- import { isAuthEnabled } from "@/lib/config"
15
+ import { getConfig, isAuthEnabled, showLastUpdated, showLastUpdateAuthor, showFeedbackWidget } from "@/lib/config"
16
16
  import { loadVersions, getVersionFromSlug, getNavForVersion } from "@/lib/versions"
17
17
  import { TopNav } from "@/components/Nav/TopNav"
18
18
  import { Sidebar } from "@/components/Sidebar/Sidebar"
@@ -21,6 +21,8 @@ import { Breadcrumbs } from "@/components/Breadcrumbs/Breadcrumbs"
21
21
  import { PageNav } from "@/components/PageNav/PageNav"
22
22
  import { SectionCards } from "@/components/SectionCards/SectionCards"
23
23
  import { DocActions } from "@/components/DocActions/DocActions"
24
+ import { LastUpdated } from "@/components/LastUpdated/LastUpdated"
25
+ import { DocFeedback } from "@/components/DocFeedback/DocFeedback"
24
26
  import { ZoomImages } from "@/components/ZoomImages/ZoomImages"
25
27
  import { mdxComponents } from "@/components/mdx"
26
28
  import type { NavGroup, NavEntry } from "@/lib/nav-types"
@@ -65,6 +67,12 @@ export default async function DocPage({ params }: Props) {
65
67
 
66
68
  const session = await getSession()
67
69
  const authEnabled = isAuthEnabled()
70
+ const apiRefConfig = getConfig().apiReference
71
+ const currentVersion = versions.find((v) => v.id === versionId)
72
+ const versionShowsApiRef = currentVersion ? (currentVersion.api_reference !== false) : true
73
+ const apiRef = apiRefConfig?.enabled && versionShowsApiRef
74
+ ? { label: apiRefConfig.navLabel ?? "API Reference", href: "/api-reference", roles: apiRefConfig.roles ?? [] }
75
+ : null
68
76
 
69
77
  if (shouldRedirectToLogin(navEntry.roles, session)) {
70
78
  redirect(`/login?returnTo=${encodeURIComponent(fullSlug)}`)
@@ -73,7 +81,7 @@ export default async function DocPage({ params }: Props) {
73
81
  if (pageRequiresAuth(navEntry.roles) && session && !hasAccess(navEntry.roles, session.roles)) {
74
82
  return (
75
83
  <div className="flex flex-col h-screen">
76
- <TopNav nav={nav.nav} userRoles={session.roles} userName={session.name} authEnabled={authEnabled} versions={versions} currentVersionId={versionId} currentSlug={fullSlug} versionSlugs={versionSlugs} />
84
+ <TopNav nav={nav.nav} userRoles={session.roles} userName={session.name} authEnabled={authEnabled} versions={versions} currentVersionId={versionId} currentSlug={fullSlug} versionSlugs={versionSlugs} apiRef={apiRef} />
77
85
  <div className="flex flex-1 items-center justify-center bg-[var(--cm-bg-primary)]">
78
86
  <div className="text-center">
79
87
  <h1 className="text-2xl font-bold text-[var(--cm-text-primary)] mb-2">Access Restricted</h1>
@@ -89,12 +97,12 @@ export default async function DocPage({ params }: Props) {
89
97
 
90
98
  const activeGroup = getGroupForSlugFromConfig(nav, fullSlug) as NavGroup | null
91
99
  const sectionEntry = getSectionForSlugFromConfig(nav, fullSlug) as NavEntry | null
92
- const { frontmatter, source, toc } = loadMdxFile(navEntry.file)
100
+ const { frontmatter, source, toc, lastUpdated, lastUpdatedAuthor } = loadMdxFile(navEntry.file)
93
101
  const isSectionRoot = sectionEntry?.slug === fullSlug
94
102
 
95
103
  return (
96
104
  <div className="flex flex-col h-screen">
97
- <TopNav nav={nav.nav} userRoles={session?.roles ?? []} userName={session?.name ?? null} authEnabled={authEnabled} versions={versions} currentVersionId={versionId} currentSlug={fullSlug} versionSlugs={versionSlugs} />
105
+ <TopNav nav={nav.nav} userRoles={session?.roles ?? []} userName={session?.name ?? null} authEnabled={authEnabled} versions={versions} currentVersionId={versionId} currentSlug={fullSlug} versionSlugs={versionSlugs} apiRef={apiRef} />
98
106
  <div className="flex flex-1 overflow-hidden">
99
107
  <Sidebar activeGroup={activeGroup} currentSlug={fullSlug} userRoles={session?.roles ?? []} authEnabled={authEnabled} />
100
108
  <main className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
@@ -137,7 +145,21 @@ export default async function DocPage({ params }: Props) {
137
145
  <SectionCards entry={sectionEntry} />
138
146
  </div>
139
147
  )}
140
- <div data-print="hide">
148
+ <div data-print="hide" className="mt-12">
149
+ <div className="flex items-center justify-between mb-6">
150
+ {showLastUpdated() && lastUpdated ? (
151
+ <LastUpdated
152
+ date={lastUpdated}
153
+ author={showLastUpdateAuthor() ? lastUpdatedAuthor : null}
154
+ />
155
+ ) : <div />}
156
+ {showFeedbackWidget() && (
157
+ <DocFeedback
158
+ pageTitle={frontmatter.title}
159
+ pageSlug={fullSlug}
160
+ />
161
+ )}
162
+ </div>
141
163
  <PageNav activeGroup={activeGroup} currentSlug={fullSlug} />
142
164
  </div>
143
165
  </article>
@@ -0,0 +1,40 @@
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ export async function POST(req: NextRequest) {
4
+ const webhookUrl = process.env.SLACK_WEBHOOK_URL
5
+ if (!webhookUrl) {
6
+ return NextResponse.json({ error: "Webhook not configured" }, { status: 503 })
7
+ }
8
+
9
+ const { rating, reason, allowFollowUp, pageTitle, pageSlug } = await req.json()
10
+
11
+ const emoji = rating === "positive" ? "👍" : "👎"
12
+ const ratingLabel = rating === "positive" ? "Helpful" : "Not helpful"
13
+
14
+ await fetch(webhookUrl, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({
18
+ blocks: [
19
+ {
20
+ type: "section",
21
+ text: {
22
+ type: "mrkdwn",
23
+ text: `${emoji} *Doc Feedback — ${ratingLabel}*`,
24
+ },
25
+ },
26
+ {
27
+ type: "section",
28
+ fields: [
29
+ { type: "mrkdwn", text: `*Page:*\n${pageTitle}` },
30
+ { type: "mrkdwn", text: `*Path:*\n\`${pageSlug}\`` },
31
+ { type: "mrkdwn", text: `*Reason:*\n${reason || "—"}` },
32
+ { type: "mrkdwn", text: `*Follow-up OK:*\n${allowFollowUp ? "Yes" : "No"}` },
33
+ ],
34
+ },
35
+ ],
36
+ }),
37
+ })
38
+
39
+ return NextResponse.json({ ok: true })
40
+ }
@@ -0,0 +1,165 @@
1
+ import { notFound, redirect } from "next/navigation"
2
+ import { getConfig, isAuthEnabled } from "@/lib/config"
3
+ import { loadApiSpec } from "@/lib/api-reference"
4
+ import { getSession, hasAccess } from "@/lib/auth"
5
+ import { loadVersions, getNavForVersion, getApiSpecForVersion } from "@/lib/versions"
6
+ import { getSlugsFromConfig } from "@/lib/nav"
7
+ import { TopNav } from "@/components/Nav/TopNav"
8
+ import { ApiSidebar } from "@/components/ApiReference/ApiSidebar"
9
+ import { EndpointPage } from "@/components/ApiReference/EndpointPage"
10
+ import { ApiOverview } from "@/components/ApiReference/ApiOverview"
11
+
12
+ type Props = {
13
+ params: Promise<{ slug?: string[] }>
14
+ }
15
+
16
+ // Detect if the first slug segment is a known version ID.
17
+ // /api-reference/v2.0/applications/create → versionId="v2.0", rest=["applications","create"]
18
+ // /api-reference/applications/create → versionId=null, rest=["applications","create"]
19
+ function parseVersionFromSlug(
20
+ slug: string[] | undefined,
21
+ versionIds: string[]
22
+ ): { versionId: string | null; rest: string[] } {
23
+ if (!slug?.length) return { versionId: null, rest: [] }
24
+ if (versionIds.includes(slug[0])) return { versionId: slug[0], rest: slug.slice(1) }
25
+ return { versionId: null, rest: slug }
26
+ }
27
+
28
+ export async function generateStaticParams() {
29
+ let config
30
+ try { config = getConfig() } catch { return [] }
31
+
32
+ const apiRef = config.apiReference
33
+ if (!apiRef?.enabled) return []
34
+
35
+ const { versions } = loadVersions()
36
+ const params: Array<{ slug: string[] | undefined }> = [{ slug: undefined }]
37
+
38
+ for (const version of versions) {
39
+ const resolved = getApiSpecForVersion(version.id, apiRef.spec, apiRef.languages)
40
+ if (!resolved) continue
41
+
42
+ let spec
43
+ try { spec = loadApiSpec(resolved.spec, resolved.languages) } catch { continue }
44
+
45
+ params.push({ slug: [version.id] })
46
+
47
+ for (const op of spec.allOperations) {
48
+ params.push({ slug: [version.id, op.tagSlug, op.operationId] })
49
+ }
50
+ }
51
+
52
+ // Also enumerate no-version-prefix routes using the default spec
53
+ let defaultSpec
54
+ try { defaultSpec = loadApiSpec(apiRef.spec, apiRef.languages) } catch { return params }
55
+ for (const op of defaultSpec.allOperations) {
56
+ params.push({ slug: [op.tagSlug, op.operationId] })
57
+ }
58
+
59
+ return params
60
+ }
61
+
62
+ export default async function ApiReferencePage({ params }: Props) {
63
+ const config = getConfig()
64
+ const apiRef = config.apiReference
65
+ if (!apiRef?.enabled) return notFound()
66
+
67
+ const { slug: rawSlug } = await params
68
+ const { versions } = loadVersions()
69
+ const versionIds = versions.map((v) => v.id)
70
+
71
+ const { versionId, rest: slug } = parseVersionFromSlug(rawSlug, versionIds)
72
+ const base = versionId ? `/api-reference/${versionId}` : "/api-reference"
73
+
74
+ const resolved = getApiSpecForVersion(versionId, apiRef.spec, apiRef.languages)
75
+ if (!resolved) return notFound()
76
+
77
+ let spec
78
+ try { spec = loadApiSpec(resolved.spec, resolved.languages) } catch { return notFound() }
79
+
80
+ const session = await getSession()
81
+ const authEnabled = isAuthEnabled()
82
+ const roles = apiRef.roles ?? []
83
+
84
+ if (!hasAccess(roles, session?.roles ?? [])) {
85
+ if (!session) redirect(`/login?returnTo=${base}`)
86
+ return (
87
+ <div className="flex flex-col h-screen">
88
+ <div className="flex flex-1 items-center justify-center">
89
+ <div className="text-center">
90
+ <h1 className="text-2xl font-bold text-gray-900 mb-2">Access Restricted</h1>
91
+ <p className="text-gray-500">You don&apos;t have permission to view the API reference.</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ const versionSlugs: Record<string, string[]> = Object.fromEntries(
99
+ versions.map((v) => {
100
+ const nav = getNavForVersion(v.id)
101
+ return [v.id, getSlugsFromConfig(nav)]
102
+ })
103
+ )
104
+ const nav = getNavForVersion(versionId)
105
+ const apiRefProp = { label: apiRef.navLabel ?? "API Reference", href: "/api-reference", roles }
106
+ const currentSlug = slug.length ? `${base}/${slug.join("/")}` : base
107
+
108
+ if (!slug.length) {
109
+ return (
110
+ <div className="flex flex-col h-screen">
111
+ <TopNav
112
+ nav={nav.nav}
113
+ userRoles={session?.roles ?? []}
114
+ userName={session?.name ?? null}
115
+ authEnabled={authEnabled}
116
+ versions={versions}
117
+ currentVersionId={versionId}
118
+ currentSlug={base}
119
+ versionSlugs={versionSlugs}
120
+ apiRef={apiRefProp}
121
+ />
122
+ <div className="flex flex-1 overflow-hidden">
123
+ <ApiSidebar spec={spec} currentSlug={base} base={base} />
124
+ <main className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
125
+ <ApiOverview spec={spec} base={base} />
126
+ </main>
127
+ </div>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ if (slug.length === 1) {
133
+ const tag = spec.tags.find((t) => t.slug === slug[0])
134
+ if (!tag || !tag.operations.length) return notFound()
135
+ redirect(`${base}/${tag.slug}/${tag.operations[0].operationId}`)
136
+ }
137
+
138
+ const [tagSlug, operationId] = slug
139
+ const op = spec.allOperations.find(
140
+ (o) => o.tagSlug === tagSlug && o.operationId === operationId
141
+ )
142
+ if (!op) return notFound()
143
+
144
+ return (
145
+ <div className="flex flex-col h-screen">
146
+ <TopNav
147
+ nav={nav.nav}
148
+ userRoles={session?.roles ?? []}
149
+ userName={session?.name ?? null}
150
+ authEnabled={authEnabled}
151
+ versions={versions}
152
+ currentVersionId={versionId}
153
+ currentSlug={currentSlug}
154
+ versionSlugs={versionSlugs}
155
+ apiRef={apiRefProp}
156
+ />
157
+ <div className="flex flex-1 overflow-hidden">
158
+ <ApiSidebar spec={spec} currentSlug={currentSlug} base={base} />
159
+ <main className="flex-1 overflow-hidden bg-white dark:bg-gray-950 flex">
160
+ <EndpointPage operation={op} spec={spec} />
161
+ </main>
162
+ </div>
163
+ </div>
164
+ )
165
+ }
@@ -61,6 +61,11 @@ export default async function HomePage() {
61
61
  versions.map((v) => [v.id, getSlugsFromConfig(getNavForVersion(v.id))])
62
62
  )
63
63
 
64
+ const apiRefConfig = config.apiReference
65
+ const apiRef = apiRefConfig?.enabled
66
+ ? { label: apiRefConfig.navLabel ?? "API Reference", href: "/api-reference", roles: apiRefConfig.roles ?? [] }
67
+ : null
68
+
64
69
  return (
65
70
  <div className="flex flex-col min-h-screen">
66
71
  <TopNav
@@ -72,6 +77,7 @@ export default async function HomePage() {
72
77
  currentVersionId={null}
73
78
  currentSlug="/home"
74
79
  versionSlugs={versionSlugs}
80
+ apiRef={apiRef}
75
81
  />
76
82
 
77
83
  <main className="flex-1 bg-[var(--cm-bg-primary)]">
@@ -0,0 +1,63 @@
1
+ import Link from "next/link"
2
+ import type { ParsedSpec } from "@/lib/api-types"
3
+ import { MethodBadge } from "./MethodBadge"
4
+
5
+ type Props = {
6
+ spec: ParsedSpec
7
+ base?: string
8
+ }
9
+
10
+ export function ApiOverview({ spec, base = "/api-reference" }: Props) {
11
+ return (
12
+ <div className="flex-1 px-8 py-8 max-w-4xl overflow-y-auto">
13
+ <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 mb-2">{spec.info.title}</h1>
14
+ <div className="flex items-center gap-2 mb-6">
15
+ <span className="text-xs font-mono text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
16
+ {spec.info.version}
17
+ </span>
18
+ {spec.baseUrl && (
19
+ <code className="text-xs text-gray-500 dark:text-gray-400">{spec.baseUrl}</code>
20
+ )}
21
+ </div>
22
+
23
+ {spec.info.description && (
24
+ <p className="text-gray-600 dark:text-gray-400 mb-10 leading-relaxed whitespace-pre-line max-w-2xl">
25
+ {spec.info.description}
26
+ </p>
27
+ )}
28
+
29
+ {/* Tag groups */}
30
+ <div className="space-y-8">
31
+ {spec.tags.map((tag) => (
32
+ <section key={tag.slug}>
33
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{tag.name}</h2>
34
+ {tag.description && (
35
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-3">{tag.description}</p>
36
+ )}
37
+
38
+ <div className="rounded-lg border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
39
+ {tag.operations.map((op) => (
40
+ <Link
41
+ key={op.operationId}
42
+ href={`${base}/${tag.slug}/${op.operationId}`}
43
+ className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors group"
44
+ >
45
+ <MethodBadge method={op.method} size="sm" />
46
+ <code className="text-sm font-mono text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 flex-1">
47
+ {op.path}
48
+ </code>
49
+ <span className="text-sm text-gray-500 dark:text-gray-400 hidden sm:block">
50
+ {op.summary}
51
+ </span>
52
+ {op.deprecated && (
53
+ <span className="text-[10px] font-medium text-amber-500 uppercase tracking-wide">deprecated</span>
54
+ )}
55
+ </Link>
56
+ ))}
57
+ </div>
58
+ </section>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,69 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import type { ParsedSpec } from "@/lib/api-types"
5
+ import { MethodBadge } from "./MethodBadge"
6
+
7
+ type Props = {
8
+ spec: ParsedSpec
9
+ currentSlug: string
10
+ base?: string
11
+ }
12
+
13
+ export function ApiSidebar({ spec, currentSlug, base = "/api-reference" }: Props) {
14
+ return (
15
+ <aside className="hidden md:block w-64 shrink-0 border-r border-gray-200 dark:border-gray-800 overflow-y-auto bg-white dark:bg-gray-950">
16
+ <div className="px-4 py-4">
17
+ {/* Overview link */}
18
+ <div className="mb-4">
19
+ <Link
20
+ href={base}
21
+ style={currentSlug === base ? { color: "var(--sf-active)" } : {}}
22
+ className={`block px-2 py-1.5 text-sm rounded transition-colors ${
23
+ currentSlug === base
24
+ ? "font-medium bg-black/5 dark:bg-white/10"
25
+ : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
26
+ }`}
27
+ >
28
+ Overview
29
+ </Link>
30
+ </div>
31
+
32
+ {/* Tags */}
33
+ {spec.tags.map((tag) => (
34
+ <div key={tag.slug} className="mb-4">
35
+ {/* Tag header — ALL CAPS */}
36
+ <p className="px-2 pb-1 text-xs font-semibold uppercase tracking-widest text-gray-400 dark:text-gray-500 select-none">
37
+ {tag.name}
38
+ </p>
39
+
40
+ {/* Operations */}
41
+ <ul className="space-y-0.5">
42
+ {tag.operations.map((op) => {
43
+ const href = `${base}/${tag.slug}/${op.operationId}`
44
+ const isActive = currentSlug === href
45
+
46
+ return (
47
+ <li key={op.operationId}>
48
+ <Link
49
+ href={href}
50
+ style={isActive ? { borderColor: "var(--sf-active-border)", color: "var(--sf-active)" } : {}}
51
+ className={`flex items-center gap-2 py-1.5 pl-2 pr-2 text-xs border-l-2 rounded-r transition-colors ${
52
+ isActive
53
+ ? "font-medium bg-black/5 dark:bg-white/10"
54
+ : "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:border-gray-300 dark:hover:border-gray-600"
55
+ }`}
56
+ >
57
+ <MethodBadge method={op.method} size="sm" />
58
+ <span className="truncate text-[12px]">{op.summary}</span>
59
+ </Link>
60
+ </li>
61
+ )
62
+ })}
63
+ </ul>
64
+ </div>
65
+ ))}
66
+ </div>
67
+ </aside>
68
+ )
69
+ }
@@ -0,0 +1,186 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import type { CodeSample } from "@/lib/api-types"
5
+
6
+ const LANG_LABELS: Record<string, string> = {
7
+ curl: "cURL",
8
+ python: "Python",
9
+ javascript: "JavaScript",
10
+ go: "Go",
11
+ ruby: "Ruby",
12
+ java: "Java",
13
+ csharp: "C#",
14
+ php: "PHP",
15
+ }
16
+
17
+ const CopyIcon = () => (
18
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
19
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
20
+ </svg>
21
+ )
22
+
23
+ const CheckIcon = () => (
24
+ <svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
25
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
26
+ </svg>
27
+ )
28
+
29
+ const ExpandIcon = () => (
30
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
31
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
32
+ </svg>
33
+ )
34
+
35
+ const CloseIcon = () => (
36
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
37
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
38
+ </svg>
39
+ )
40
+
41
+ type TabsProps = {
42
+ samples: CodeSample[]
43
+ active: number
44
+ onSelect: (i: number) => void
45
+ }
46
+
47
+ function LangTabs({ samples, active, onSelect }: TabsProps) {
48
+ return (
49
+ <>
50
+ {samples.map((sample, i) => (
51
+ <button
52
+ key={`${sample.lang}-${i}`}
53
+ onClick={() => onSelect(i)}
54
+ className={`px-3 py-2 text-xs font-medium shrink-0 transition-colors ${
55
+ active === i
56
+ ? "text-white border-b-2 border-white"
57
+ : "text-gray-400 hover:text-gray-200"
58
+ }`}
59
+ >
60
+ {sample.label ?? LANG_LABELS[sample.lang.toLowerCase()] ?? sample.lang}
61
+ </button>
62
+ ))}
63
+ </>
64
+ )
65
+ }
66
+
67
+ type Props = {
68
+ samples: CodeSample[]
69
+ }
70
+
71
+ export function CodeSamples({ samples }: Props) {
72
+ const [active, setActive] = useState(0)
73
+ const [copied, setCopied] = useState(false)
74
+ const [expanded, setExpanded] = useState(false)
75
+ const [modalCopied, setModalCopied] = useState(false)
76
+
77
+ const current = samples[active]
78
+
79
+ useEffect(() => {
80
+ if (!expanded) return
81
+ function onKey(e: KeyboardEvent) {
82
+ if (e.key === "Escape") setExpanded(false)
83
+ }
84
+ window.addEventListener("keydown", onKey)
85
+ return () => window.removeEventListener("keydown", onKey)
86
+ }, [expanded])
87
+
88
+ if (!samples.length) return null
89
+
90
+ function copy(setFlag: (v: boolean) => void) {
91
+ navigator.clipboard.writeText(current.source).then(() => {
92
+ setFlag(true)
93
+ setTimeout(() => setFlag(false), 1500)
94
+ })
95
+ }
96
+
97
+ const lines = current.source.split("\n")
98
+
99
+ return (
100
+ <>
101
+ {/* Inline code block */}
102
+ <div className="rounded-lg overflow-hidden border border-gray-700 bg-[#1a1d23]">
103
+ {/* Tabs + actions */}
104
+ <div className="flex items-center border-b border-gray-700 overflow-x-auto">
105
+ <LangTabs samples={samples} active={active} onSelect={setActive} />
106
+ <div className="ml-auto flex items-center gap-0.5 pr-2 shrink-0">
107
+ <button
108
+ onClick={() => copy(setCopied)}
109
+ className="p-1.5 text-gray-400 hover:text-gray-200 transition-colors"
110
+ title="Copy"
111
+ >
112
+ {copied ? <CheckIcon /> : <CopyIcon />}
113
+ </button>
114
+ <button
115
+ onClick={() => setExpanded(true)}
116
+ className="p-1.5 text-gray-400 hover:text-gray-200 transition-colors"
117
+ title="Expand"
118
+ >
119
+ <ExpandIcon />
120
+ </button>
121
+ </div>
122
+ </div>
123
+
124
+ {/* Code */}
125
+ <pre className="p-4 text-[13px] leading-relaxed text-gray-200 overflow-x-auto font-mono whitespace-pre">
126
+ <code>{current.source}</code>
127
+ </pre>
128
+ </div>
129
+
130
+ {/* Expanded modal */}
131
+ {expanded && (
132
+ <div
133
+ className="fixed inset-0 z-50 flex items-center justify-center p-6"
134
+ onClick={(e) => { if (e.target === e.currentTarget) setExpanded(false) }}
135
+ >
136
+ {/* Backdrop */}
137
+ <div className="absolute inset-0 bg-black/70" />
138
+
139
+ {/* Modal */}
140
+ <div className="relative w-full max-w-4xl max-h-[85vh] flex flex-col rounded-xl overflow-hidden border border-gray-700 bg-[#1a1d23] shadow-2xl">
141
+ {/* Modal header */}
142
+ <div className="flex items-center border-b border-gray-700 shrink-0">
143
+ <div className="flex items-center overflow-x-auto">
144
+ <LangTabs samples={samples} active={active} onSelect={setActive} />
145
+ </div>
146
+ <div className="ml-auto flex items-center gap-0.5 pr-3 shrink-0">
147
+ <button
148
+ onClick={() => copy(setModalCopied)}
149
+ className="p-1.5 text-gray-400 hover:text-gray-200 transition-colors"
150
+ title="Copy"
151
+ >
152
+ {modalCopied ? <CheckIcon /> : <CopyIcon />}
153
+ </button>
154
+ <button
155
+ onClick={() => setExpanded(false)}
156
+ className="p-1.5 text-gray-400 hover:text-gray-200 transition-colors"
157
+ title="Close"
158
+ >
159
+ <CloseIcon />
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Code with line numbers */}
165
+ <div className="overflow-auto flex-1">
166
+ <table className="w-full border-collapse">
167
+ <tbody>
168
+ {lines.map((line, i) => (
169
+ <tr key={i} className="hover:bg-white/5">
170
+ <td className="select-none text-right text-gray-600 text-[13px] font-mono px-4 py-0 leading-6 w-10 shrink-0">
171
+ {i + 1}
172
+ </td>
173
+ <td className="text-gray-200 text-[13px] font-mono pl-4 pr-8 py-0 leading-6 whitespace-pre">
174
+ {line || " "}
175
+ </td>
176
+ </tr>
177
+ ))}
178
+ </tbody>
179
+ </table>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ )}
184
+ </>
185
+ )
186
+ }