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.
@@ -0,0 +1,205 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { resolveRef } from "@/lib/api-utils"
5
+ import type { SchemaObject } from "@/lib/api-types"
6
+
7
+ // Correctly merges allOf items: shallow-merges scalar fields but additively merges
8
+ // `properties` and `required` so no allOf item's fields are lost.
9
+ function mergeAllOf(allOf: SchemaObject[], schemas: Record<string, SchemaObject>): SchemaObject {
10
+ const items = allOf.map((s) => (s.$ref ? (resolveRef(s.$ref, schemas) ?? s) : s))
11
+ const merged = Object.assign({} as SchemaObject, ...items)
12
+ const mergedProps: Record<string, SchemaObject> = {}
13
+ const mergedRequired: string[] = []
14
+ for (const item of items) {
15
+ if (item.properties) Object.assign(mergedProps, item.properties)
16
+ if (item.required) mergedRequired.push(...item.required)
17
+ }
18
+ if (Object.keys(mergedProps).length) merged.properties = mergedProps
19
+ if (mergedRequired.length) merged.required = [...new Set(mergedRequired)]
20
+ return merged
21
+ }
22
+
23
+ type Props = {
24
+ schema: SchemaObject
25
+ schemas: Record<string, SchemaObject>
26
+ required?: boolean
27
+ name?: string
28
+ depth?: number
29
+ }
30
+
31
+ function TypeLabel({ schema }: { schema: SchemaObject }) {
32
+ if (schema.$ref) {
33
+ const refName = schema.$ref.replace("#/components/schemas/", "")
34
+ return <span className="text-xs font-mono text-purple-600 dark:text-purple-400">{refName}</span>
35
+ }
36
+
37
+ if (schema.allOf?.length) {
38
+ return <span className="text-xs font-mono text-gray-500">object</span>
39
+ }
40
+
41
+ if (schema.type === "array") {
42
+ const items = schema.items
43
+ if (items?.$ref) {
44
+ const refName = items.$ref.replace("#/components/schemas/", "")
45
+ return <span className="text-xs font-mono text-gray-500">array of <span className="text-purple-600 dark:text-purple-400">{refName}</span></span>
46
+ }
47
+ return <span className="text-xs font-mono text-gray-500">array{items?.type ? ` of ${items.type}` : ""}</span>
48
+ }
49
+
50
+ if (schema.enum) {
51
+ return (
52
+ <span className="text-xs font-mono text-gray-500">
53
+ {schema.type ?? "string"}{" "}
54
+ <span className="text-gray-400">({schema.enum.map((v) => JSON.stringify(v)).join(" | ")})</span>
55
+ </span>
56
+ )
57
+ }
58
+
59
+ if (schema.format) {
60
+ return <span className="text-xs font-mono text-gray-500">{schema.type} <span className="text-gray-400">({schema.format})</span></span>
61
+ }
62
+
63
+ return <span className="text-xs font-mono text-gray-500">{schema.type ?? "any"}</span>
64
+ }
65
+
66
+ function PropertyRow({
67
+ name,
68
+ schema,
69
+ required,
70
+ schemas,
71
+ depth,
72
+ }: {
73
+ name: string
74
+ schema: SchemaObject
75
+ required: boolean
76
+ schemas: Record<string, SchemaObject>
77
+ depth: number
78
+ }) {
79
+ const [expanded, setExpanded] = useState(depth < 1)
80
+
81
+ const resolved: SchemaObject = schema.$ref ? (resolveRef(schema.$ref, schemas) ?? schema) : schema
82
+ const mergedSchema: SchemaObject = resolved.allOf?.length
83
+ ? mergeAllOf(resolved.allOf, schemas)
84
+ : resolved
85
+
86
+ const itemSchema = mergedSchema.type === "array" && mergedSchema.items
87
+ ? (mergedSchema.items.$ref ? (resolveRef(mergedSchema.items.$ref, schemas) ?? mergedSchema.items) : mergedSchema.items)
88
+ : null
89
+
90
+ const childProperties =
91
+ mergedSchema.type === "object" || mergedSchema.properties
92
+ ? mergedSchema.properties
93
+ : itemSchema?.type === "object" || itemSchema?.properties
94
+ ? itemSchema.properties
95
+ : null
96
+
97
+ const hasChildren = depth < 4 && !!childProperties && Object.keys(childProperties).length > 0
98
+
99
+ return (
100
+ <div className="border-b border-gray-100 dark:border-gray-800 last:border-0">
101
+ <div className="flex items-start gap-3 py-3 px-0">
102
+ <div className="flex-1 min-w-0">
103
+ <div className="flex items-center gap-2 flex-wrap">
104
+ <code className="text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{name}</code>
105
+ <TypeLabel schema={schema} />
106
+ {required && (
107
+ <span className="text-[10px] font-medium text-red-500 uppercase tracking-wide">required</span>
108
+ )}
109
+ {(resolved.readOnly || mergedSchema.readOnly) && (
110
+ <span className="text-[10px] font-medium text-gray-400 uppercase tracking-wide">read-only</span>
111
+ )}
112
+ {(schema.deprecated || resolved.deprecated) && (
113
+ <span className="text-[10px] font-medium text-amber-500 uppercase tracking-wide">deprecated</span>
114
+ )}
115
+ </div>
116
+ {(resolved.description || mergedSchema.description) && (
117
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
118
+ {resolved.description ?? mergedSchema.description}
119
+ </p>
120
+ )}
121
+ {resolved.default !== undefined && (
122
+ <p className="text-xs text-gray-400 mt-0.5">
123
+ Default: <code className="font-mono">{JSON.stringify(resolved.default)}</code>
124
+ </p>
125
+ )}
126
+ </div>
127
+
128
+ {hasChildren && (
129
+ <button
130
+ onClick={() => setExpanded((e) => !e)}
131
+ className="shrink-0 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1 mt-0.5"
132
+ >
133
+ {expanded ? "Hide" : "Show"} fields
134
+ <svg
135
+ className={`w-3 h-3 transition-transform ${expanded ? "rotate-0" : "rotate-180"}`}
136
+ fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
137
+ >
138
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
139
+ </svg>
140
+ </button>
141
+ )}
142
+ </div>
143
+
144
+ {hasChildren && expanded && (
145
+ <div className="ml-4 border-l-2 border-gray-100 dark:border-gray-800 pl-4 mb-3">
146
+ {(Object.entries(childProperties!) as [string, SchemaObject][]).map(([childName, childSchema]) => (
147
+ <PropertyRow
148
+ key={childName}
149
+ name={childName}
150
+ schema={childSchema}
151
+ required={(mergedSchema.required ?? itemSchema?.required ?? []).includes(childName)}
152
+ schemas={schemas}
153
+ depth={depth + 1}
154
+ />
155
+ ))}
156
+ </div>
157
+ )}
158
+ </div>
159
+ )
160
+ }
161
+
162
+ export function SchemaViewer({ schema, schemas, required, name, depth = 0 }: Props) {
163
+ const resolved: SchemaObject = schema.$ref ? (resolveRef(schema.$ref, schemas) ?? schema) : schema
164
+ const mergedSchema: SchemaObject = resolved.allOf?.length
165
+ ? mergeAllOf(resolved.allOf, schemas)
166
+ : resolved
167
+
168
+ const properties = mergedSchema.properties
169
+ const requiredFields = mergedSchema.required ?? []
170
+
171
+ if (name !== undefined) {
172
+ return (
173
+ <PropertyRow
174
+ name={name}
175
+ schema={schema}
176
+ required={required ?? false}
177
+ schemas={schemas}
178
+ depth={depth}
179
+ />
180
+ )
181
+ }
182
+
183
+ if (!properties || Object.keys(properties).length === 0) {
184
+ return (
185
+ <div className="text-sm text-gray-500 dark:text-gray-400 py-2">
186
+ <TypeLabel schema={schema} />
187
+ </div>
188
+ )
189
+ }
190
+
191
+ return (
192
+ <div className="divide-y divide-gray-100 dark:divide-gray-800">
193
+ {(Object.entries(properties) as [string, SchemaObject][]).map(([propName, propSchema]) => (
194
+ <PropertyRow
195
+ key={propName}
196
+ name={propName}
197
+ schema={propSchema}
198
+ required={requiredFields.includes(propName)}
199
+ schemas={schemas}
200
+ depth={depth}
201
+ />
202
+ ))}
203
+ </div>
204
+ )
205
+ }
@@ -0,0 +1,155 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { ThumbsUp, ThumbsDown } from "lucide-react"
5
+
6
+ type Step = "idle" | "yes" | "no" | "yes-done" | "no-done"
7
+
8
+ const YES_REASONS = [
9
+ { value: "accurate", label: "Accurate", description: "Accurately describes the product or feature." },
10
+ { value: "solved", label: "Solved my issue", description: "Helped me resolve an issue." },
11
+ { value: "clear", label: "Easy to understand", description: "Easy to follow and comprehend." },
12
+ { value: "decided", label: "Helped me decide to use the product", description: "Convinced me to adopt the product or feature." },
13
+ { value: "other", label: "Another reason", description: "" },
14
+ ]
15
+
16
+ const NO_REASONS = [
17
+ { value: "inaccurate", label: "Inaccurate", description: "Doesn't accurately describe the product or feature." },
18
+ { value: "missing", label: "Couldn't find what I was looking for", description: "Missing important information." },
19
+ { value: "unclear", label: "Hard to understand", description: "Too complicated or unclear." },
20
+ { value: "code", label: "Code sample errors", description: "One or more code samples are incorrect." },
21
+ { value: "other", label: "Another reason", description: "" },
22
+ ]
23
+
24
+ type Props = {
25
+ pageTitle: string
26
+ pageSlug: string
27
+ }
28
+
29
+ export function DocFeedback({ pageTitle, pageSlug }: Props) {
30
+ const [step, setStep] = useState<Step>("idle")
31
+ const [reason, setReason] = useState("")
32
+ const [allowFollowUp, setAllowFollowUp] = useState(false)
33
+ const [submitting, setSubmitting] = useState(false)
34
+
35
+ function handleYes() {
36
+ if (step === "yes") { setStep("idle"); setReason(""); return }
37
+ setStep("yes")
38
+ setReason("")
39
+ }
40
+
41
+ function handleNo() {
42
+ if (step === "no") { setStep("idle"); setReason(""); return }
43
+ setStep("no")
44
+ setReason("")
45
+ }
46
+
47
+ async function handleSubmit() {
48
+ setSubmitting(true)
49
+ try {
50
+ await fetch("/api/feedback", {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({
54
+ rating: step === "yes" ? "positive" : "negative",
55
+ reason,
56
+ allowFollowUp,
57
+ pageTitle,
58
+ pageSlug,
59
+ }),
60
+ })
61
+ } finally {
62
+ setStep(step === "yes" ? "yes-done" : "no-done")
63
+ setSubmitting(false)
64
+ }
65
+ }
66
+
67
+ const showPanel = step === "yes" || step === "no"
68
+ const reasons = step === "yes" ? YES_REASONS : NO_REASONS
69
+ const panelTitle = step === "yes" ? "What did you like?" : "What went wrong?"
70
+
71
+ const yesActive = step === "yes" || step === "yes-done"
72
+ const noActive = step === "no" || step === "no-done"
73
+ const done = step === "yes-done" || step === "no-done"
74
+
75
+ return (
76
+ <div className="relative flex flex-col items-end gap-3">
77
+ {showPanel && (
78
+ <div className="absolute bottom-full right-0 mb-2 w-80 bg-white border border-gray-200 dark:bg-[#1a1d23] dark:border-gray-700 rounded-lg p-5 shadow-lg z-50">
79
+ <p className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{panelTitle}</p>
80
+ <div className="flex flex-col gap-3">
81
+ {reasons.map((r) => (
82
+ <label key={r.value} className="flex items-start gap-3 cursor-pointer">
83
+ <input
84
+ type="radio"
85
+ name="feedback-reason"
86
+ value={r.value}
87
+ checked={reason === r.value}
88
+ onChange={() => setReason(r.value)}
89
+ className="mt-0.5 accent-blue-500"
90
+ />
91
+ <div>
92
+ <span className="text-sm font-medium text-gray-800 dark:text-white">{r.label}</span>
93
+ {r.description && (
94
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{r.description}</p>
95
+ )}
96
+ </div>
97
+ </label>
98
+ ))}
99
+ </div>
100
+ <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
101
+ <label className="flex items-start gap-2 cursor-pointer">
102
+ <input
103
+ type="checkbox"
104
+ checked={allowFollowUp}
105
+ onChange={(e) => setAllowFollowUp(e.target.checked)}
106
+ className="mt-0.5 accent-blue-500"
107
+ />
108
+ <span className="text-xs text-gray-500 dark:text-gray-400">
109
+ Yes, it&apos;s okay to follow up by email.
110
+ </span>
111
+ </label>
112
+ </div>
113
+ <button
114
+ onClick={handleSubmit}
115
+ disabled={!reason || submitting}
116
+ className="mt-4 w-full py-2 text-sm text-gray-600 bg-gray-100 rounded-md disabled:opacity-40 disabled:cursor-not-allowed hover:enabled:bg-gray-200 hover:enabled:text-gray-900 dark:text-gray-400 dark:bg-gray-800 dark:hover:enabled:bg-gray-700 dark:hover:enabled:text-white transition-colors"
117
+ >
118
+ {submitting ? "Sending…" : "Feedback"}
119
+ </button>
120
+ </div>
121
+ )}
122
+
123
+ <div className="flex items-center gap-3">
124
+ {done && (
125
+ <span className="text-sm text-gray-500 dark:text-gray-400">Thanks for your feedback!</span>
126
+ )}
127
+ {!done && <span className="text-sm text-gray-500 dark:text-gray-400">Was this page helpful?</span>}
128
+ <button
129
+ onClick={handleYes}
130
+ disabled={done}
131
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm border transition-colors disabled:cursor-default ${
132
+ yesActive
133
+ ? "bg-green-50 border-green-400 text-green-600 dark:bg-green-900/40 dark:border-green-700 dark:text-green-400"
134
+ : "border-gray-300 text-gray-500 hover:border-gray-500 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-400 dark:hover:text-gray-200"
135
+ }`}
136
+ >
137
+ <ThumbsUp size={14} strokeWidth={1.75} />
138
+ Yes
139
+ </button>
140
+ <button
141
+ onClick={handleNo}
142
+ disabled={done}
143
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm border transition-colors disabled:cursor-default ${
144
+ noActive
145
+ ? "bg-red-50 border-red-400 text-red-600 dark:bg-red-900/40 dark:border-red-700 dark:text-red-400"
146
+ : "border-gray-300 text-gray-500 hover:border-gray-500 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-400 dark:hover:text-gray-200"
147
+ }`}
148
+ >
149
+ <ThumbsDown size={14} strokeWidth={1.75} />
150
+ No
151
+ </button>
152
+ </div>
153
+ </div>
154
+ )
155
+ }
@@ -0,0 +1,23 @@
1
+ import { Clock } from "lucide-react"
2
+
3
+ type Props = {
4
+ date: Date
5
+ author?: string | null
6
+ }
7
+
8
+ export function LastUpdated({ date, author }: Props) {
9
+ const formatted = date.toLocaleDateString("en-US", {
10
+ month: "long",
11
+ day: "numeric",
12
+ year: "numeric",
13
+ })
14
+
15
+ return (
16
+ <div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
17
+ <Clock size={14} strokeWidth={1.75} />
18
+ <span>
19
+ {author ? `Last updated by ${author} on ${formatted}` : formatted}
20
+ </span>
21
+ </div>
22
+ )
23
+ }
@@ -19,9 +19,10 @@ type Props = {
19
19
  currentVersionId: string | null
20
20
  currentSlug: string
21
21
  versionSlugs: Record<string, string[]>
22
+ apiRef?: { label: string; href: string; roles: string[] } | null
22
23
  }
23
24
 
24
- export function TopNav({ nav, userRoles, userName, authEnabled = false, versions, currentVersionId, currentSlug, versionSlugs }: Props) {
25
+ export function TopNav({ nav, userRoles, userName, authEnabled = false, versions, currentVersionId, currentSlug, versionSlugs, apiRef }: Props) {
25
26
  const [openDropdown, setOpenDropdown] = useState<string | null>(null)
26
27
  const [drawerOpen, setDrawerOpen] = useState(false)
27
28
  const navRef = useRef<HTMLElement>(null)
@@ -108,6 +109,18 @@ export function TopNav({ nav, userRoles, userName, authEnabled = false, versions
108
109
  })}
109
110
  </div>
110
111
 
112
+ {/* API Reference link */}
113
+ {apiRef && (apiRef.roles.length === 0 || apiRef.roles.some((r) => userRoles.includes(r))) && (
114
+ <Link
115
+ href={apiRef.href}
116
+ className={`hidden md:block text-sm font-medium transition-colors ${
117
+ currentSlug.startsWith("/api-reference") ? "text-[var(--cm-oasis-teal)]" : "text-[var(--cm-parchment)] hover:text-[var(--cm-oasis-teal)]"
118
+ }`}
119
+ >
120
+ {apiRef.label}
121
+ </Link>
122
+ )}
123
+
111
124
  {/* Right side */}
112
125
  <div className="ml-auto flex items-center gap-3">
113
126
  <SearchTrigger />
@@ -1,5 +1,5 @@
1
1
  import Link from "next/link"
2
- import type { NavGroup, NavChild } from "@/lib/nav-types"
2
+ import type { NavGroup } from "@/lib/nav-types"
3
3
 
4
4
  type PageEntry = { label: string; slug: string }
5
5
 
@@ -39,7 +39,7 @@ export function PageNav({ activeGroup, currentSlug }: Props) {
39
39
  if (!prev && !next) return null
40
40
 
41
41
  return (
42
- <div className="flex justify-between items-center mt-12 pt-6 border-t border-gray-200 dark:border-gray-800">
42
+ <div className="flex justify-between items-center pt-6 border-t border-gray-200 dark:border-gray-800">
43
43
  {prev ? (
44
44
  <Link
45
45
  href={prev.slug}