camelmind 0.1.1 → 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.
- package/package.json +2 -1
- package/template/app/[...slug]/page.tsx +27 -5
- package/template/app/api/feedback/route.ts +40 -0
- package/template/app/api-reference/[[...slug]]/page.tsx +165 -0
- package/template/app/home/page.tsx +6 -0
- package/template/components/ApiReference/ApiOverview.tsx +63 -0
- package/template/components/ApiReference/ApiSidebar.tsx +69 -0
- package/template/components/ApiReference/CodeSamples.tsx +186 -0
- package/template/components/ApiReference/EndpointPage.tsx +92 -0
- package/template/components/ApiReference/ExpandableCode.tsx +132 -0
- package/template/components/ApiReference/MethodBadge.tsx +29 -0
- package/template/components/ApiReference/ParamsTable.tsx +70 -0
- package/template/components/ApiReference/ResponseContext.tsx +32 -0
- package/template/components/ApiReference/ResponseDocs.tsx +135 -0
- package/template/components/ApiReference/ResponseExample.tsx +98 -0
- package/template/components/ApiReference/SchemaViewer.tsx +205 -0
- package/template/components/DocFeedback/DocFeedback.tsx +155 -0
- package/template/components/LastUpdated/LastUpdated.tsx +23 -0
- package/template/components/Nav/TopNav.tsx +14 -1
- package/template/components/PageNav/PageNav.tsx +2 -2
- package/template/lib/api-reference.ts +441 -0
- package/template/lib/api-types.ts +102 -0
- package/template/lib/api-utils.ts +62 -0
- package/template/lib/config-types.ts +16 -0
- package/template/lib/config.ts +12 -0
- package/template/lib/mdx.ts +27 -1
- package/template/lib/versions.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "camelmind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Create and manage CamelMind documentation sites",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsup",
|
|
17
|
+
"build:cli": "tsup",
|
|
17
18
|
"dev": "tsup --watch",
|
|
18
19
|
"sync-template": "node scripts/sync-template.mjs",
|
|
19
20
|
"prepublishOnly": "npm run sync-template && npm run build"
|
|
@@ -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'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
|
+
}
|