@easydocs/dashboard 0.1.4 → 0.4.2

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/next.config.ts CHANGED
@@ -2,6 +2,22 @@ import type { NextConfig } from 'next'
2
2
 
3
3
  const config: NextConfig = {
4
4
  transpilePackages: ['@easydocs/core'],
5
+ serverExternalPackages: ['@libsql/client', 'libsql', 'better-sqlite3'],
6
+ webpack(webpackConfig, { isServer }) {
7
+ if (isServer) {
8
+ const existing = Array.isArray(webpackConfig.externals) ? webpackConfig.externals : []
9
+ webpackConfig.externals = [
10
+ ...existing,
11
+ ({ request }: { request?: string }, callback: (err?: Error | null, result?: string) => void) => {
12
+ if (request && /^(libsql|@libsql\/)/.test(request)) {
13
+ return callback(null, `commonjs ${request}`)
14
+ }
15
+ callback()
16
+ },
17
+ ]
18
+ }
19
+ return webpackConfig
20
+ },
5
21
  }
6
22
 
7
23
  export default config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easydocs/dashboard",
3
- "version": "0.1.4",
3
+ "version": "0.4.2",
4
4
  "description": "EasyDocs docs dashboard — view and export AI-generated OpenAPI specs",
5
5
  "files": [
6
6
  "src",
@@ -11,11 +11,15 @@
11
11
  "tsconfig.json"
12
12
  ],
13
13
  "dependencies": {
14
+ "@codemirror/lang-json": "^6.0.2",
15
+ "@codemirror/theme-one-dark": "^6.1.3",
16
+ "@uiw/react-codemirror": "^4.25.9",
14
17
  "js-yaml": "^4.1.0",
15
18
  "next": "15.1.6",
16
19
  "react": "^19.0.0",
17
20
  "react-dom": "^19.0.0",
18
- "@easydocs/core": "0.1.4"
21
+ "zod": "^3.25.76",
22
+ "@easydocs/core": "0.4.2"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@types/js-yaml": "^4.0.9",
@@ -139,7 +139,7 @@ export function Dashboard({ endpoints, projects, currentProject }: Props) {
139
139
  {/* Main */}
140
140
  <main className="flex-1 overflow-hidden">
141
141
  {selected ? (
142
- <EndpointDetail endpoint={selected} />
142
+ <EndpointDetail key={selected.id} endpoint={selected} />
143
143
  ) : (
144
144
  <div className="flex items-center justify-center h-full text-zinc-500 text-sm">
145
145
  Select an endpoint to view its documentation.
@@ -1,8 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useCallback } from 'react'
4
4
  import { MethodBadge } from './MethodBadge'
5
- import { SchemaViewer } from './SchemaViewer'
6
5
  import { SpecEditor } from './SpecEditor'
7
6
  import { ConflictBanner } from './ConflictBanner'
8
7
  import type { Endpoint } from '@easydocs/core/schema'
@@ -10,6 +9,10 @@ import type { Endpoint } from '@easydocs/core/schema'
10
9
  export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
11
10
  const [endpoint, setEndpoint] = useState(initial)
12
11
  const [editing, setEditing] = useState(false)
12
+ const [exampleOpen, setExampleOpen] = useState<Record<string, boolean>>({})
13
+ const toggleExample = useCallback((key: string) => {
14
+ setExampleOpen((prev) => ({ ...prev, [key]: !prev[key] }))
15
+ }, [])
13
16
 
14
17
  const activeSpec = endpoint.isManuallyEdited ? endpoint.manualSpec : endpoint.spec
15
18
 
@@ -28,12 +31,17 @@ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
28
31
  )}
29
32
 
30
33
  <div className="flex items-center justify-between px-8 pt-6 pb-2">
31
- <div className="flex items-center gap-3">
34
+ <div className="flex items-center gap-3 flex-wrap">
32
35
  <MethodBadge method={endpoint.method} />
33
36
  <code className="text-lg font-mono text-zinc-100">{endpoint.path}</code>
34
37
  {endpoint.isManuallyEdited && !endpoint.hasConflict && (
35
38
  <span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded">edited</span>
36
39
  )}
40
+ {activeSpec.tags?.map((tag) => (
41
+ <span key={tag} className="text-xs text-zinc-400 bg-zinc-800 border border-zinc-700 px-2 py-0.5 rounded-full">
42
+ {tag}
43
+ </span>
44
+ ))}
37
45
  </div>
38
46
  <button
39
47
  onClick={() => setEditing((e) => !e)}
@@ -45,7 +53,7 @@ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
45
53
 
46
54
  {editing ? (
47
55
  <div className="flex-1 overflow-hidden border-t border-zinc-800 mt-4">
48
- <SpecEditor endpoint={endpoint} onSaved={setEndpoint} />
56
+ <SpecEditor endpoint={endpoint} onSaved={setEndpoint} onCancel={() => setEditing(false)} />
49
57
  </div>
50
58
  ) : (
51
59
  <div className="flex-1 overflow-y-auto px-8 pb-8 space-y-8">
@@ -68,16 +76,20 @@ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
68
76
  </h3>
69
77
  <div className="rounded-lg border border-zinc-800 divide-y divide-zinc-800">
70
78
  {activeSpec.parameters.map((param) => (
71
- <div key={`${param.in}-${param.name}`} className="px-4 py-3 flex gap-4">
79
+ <div key={`${param.in}-${param.name}`} className={`px-4 py-3 flex gap-4 ${param.deprecated ? 'bg-yellow-500/5' : ''}`}>
72
80
  <div className="min-w-0 flex-1">
73
81
  <div className="flex items-center gap-2 flex-wrap">
74
- <code className="text-sm font-mono text-zinc-100">{param.name}</code>
82
+ <code className={`text-sm font-mono ${param.deprecated ? 'line-through text-zinc-500' : 'text-zinc-100'}`}>
83
+ {param.name}
84
+ </code>
75
85
  <span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded">
76
86
  {param.in}
77
87
  </span>
78
88
  {param.required && <span className="text-xs text-red-400">required</span>}
79
89
  {param.deprecated && (
80
- <span className="text-xs text-yellow-400">deprecated</span>
90
+ <span className="text-xs font-medium text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 px-1.5 py-0.5 rounded">
91
+ deprecated
92
+ </span>
81
93
  )}
82
94
  </div>
83
95
  {param.description && (
@@ -95,14 +107,27 @@ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
95
107
  </section>
96
108
  )}
97
109
 
110
+ {activeSpec.security && activeSpec.security.length > 0 && (
111
+ <section>
112
+ <h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
113
+ Security
114
+ </h3>
115
+ <div className="flex flex-wrap gap-2">
116
+ {activeSpec.security.flatMap((requirement) =>
117
+ Object.keys(requirement).map((scheme) => (
118
+ <SecurityBadge key={scheme} scheme={scheme} />
119
+ ))
120
+ )}
121
+ </div>
122
+ </section>
123
+ )}
124
+
98
125
  {activeSpec.requestBody && (
99
126
  <section>
100
127
  <h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
101
128
  Request Body
102
129
  </h3>
103
- <div className="rounded-lg border border-zinc-800 p-4 bg-zinc-900/50">
104
- <SchemaViewer data={activeSpec.requestBody.content} />
105
- </div>
130
+ <ContentFields content={activeSpec.requestBody.content} />
106
131
  </section>
107
132
  )}
108
133
 
@@ -112,19 +137,39 @@ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
112
137
  Responses
113
138
  </h3>
114
139
  <div className="space-y-3">
115
- {Object.entries(activeSpec.responses).map(([status, response]) => (
116
- <div key={status} className="rounded-lg border border-zinc-800 overflow-hidden">
117
- <div className="flex items-center gap-3 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800">
118
- <StatusBadge code={status} />
119
- <span className="text-sm text-zinc-400">{response.description}</span>
120
- </div>
121
- {response.content && (
122
- <div className="p-4">
123
- <SchemaViewer data={response.content} />
140
+ {Object.entries(activeSpec.responses).map(([status, response]) => {
141
+ const mediaType = response.content ? Object.values(response.content)[0] : null
142
+ const example = mediaType?.example
143
+ const hasExample = example !== undefined
144
+ const isOpen = exampleOpen[status] ?? false
145
+
146
+ return (
147
+ <div key={status} className="rounded-lg border border-zinc-800 overflow-hidden">
148
+ <div className="flex items-center gap-3 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800">
149
+ <StatusBadge code={status} />
150
+ <span className="text-sm text-zinc-400 flex-1">{response.description}</span>
151
+ {hasExample && (
152
+ <button
153
+ onClick={() => toggleExample(status)}
154
+ className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
155
+ >
156
+ {isOpen ? 'Hide example' : 'Example'}
157
+ </button>
158
+ )}
124
159
  </div>
125
- )}
126
- </div>
127
- ))}
160
+ {response.content && !isOpen && (
161
+ <div className="p-4">
162
+ <ContentFields content={response.content} />
163
+ </div>
164
+ )}
165
+ {hasExample && isOpen && (
166
+ <pre className="p-4 text-xs font-mono text-zinc-300 leading-5 overflow-x-auto">
167
+ {JSON.stringify(example, null, 2)}
168
+ </pre>
169
+ )}
170
+ </div>
171
+ )
172
+ })}
128
173
  </div>
129
174
  </section>
130
175
  )}
@@ -134,6 +179,143 @@ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
134
179
  )
135
180
  }
136
181
 
182
+ type JsonSchema = {
183
+ type?: string
184
+ format?: string
185
+ description?: string
186
+ enum?: unknown[]
187
+ properties?: Record<string, JsonSchema>
188
+ required?: string[]
189
+ items?: JsonSchema
190
+ }
191
+
192
+ function ContentFields({ content }: { content: Record<string, { schema?: JsonSchema }> }) {
193
+ const mediaType = Object.values(content ?? {})[0]
194
+ const schema = mediaType?.schema
195
+ if (!schema) return null
196
+
197
+ if (schema.type === 'object' && schema.properties) {
198
+ return (
199
+ <PropertiesTable properties={schema.properties} required={schema.required ?? []} depth={0} />
200
+ )
201
+ }
202
+
203
+ if (schema.type === 'array' && schema.items) {
204
+ return (
205
+ <div>
206
+ <p className="text-xs text-zinc-500 mb-2 font-mono">array of object</p>
207
+ {schema.items.properties && (
208
+ <PropertiesTable
209
+ properties={schema.items.properties}
210
+ required={schema.items.required ?? []}
211
+ depth={0}
212
+ />
213
+ )}
214
+ </div>
215
+ )
216
+ }
217
+
218
+ return (
219
+ <div className="rounded-lg border border-zinc-800 px-4 py-3 text-xs text-zinc-400 font-mono">
220
+ {schema.type ?? 'unknown'}
221
+ {schema.format ? <span className="text-zinc-500">({schema.format})</span> : null}
222
+ </div>
223
+ )
224
+ }
225
+
226
+ function PropertiesTable({
227
+ properties,
228
+ required,
229
+ depth,
230
+ }: {
231
+ properties: Record<string, JsonSchema>
232
+ required: string[]
233
+ depth: number
234
+ }) {
235
+ return (
236
+ <div className={`rounded-lg border border-zinc-800 divide-y divide-zinc-800 ${depth > 0 ? 'mt-2' : ''}`}>
237
+ {Object.entries(properties).map(([name, schema]) => {
238
+ const isRequired = required.includes(name)
239
+ const isArrayOfObject = schema.type === 'array' && schema.items?.properties
240
+ const typeLabel = isArrayOfObject
241
+ ? 'array of object'
242
+ : schema.type === 'array'
243
+ ? `array[${schema.items?.type ?? 'unknown'}]`
244
+ : (schema.type ?? 'unknown')
245
+ const formatSuffix = schema.format ? `(${schema.format})` : ''
246
+ const hasNestedObject = schema.type === 'object' && schema.properties
247
+ const hasNestedArray = isArrayOfObject
248
+
249
+ return (
250
+ <div key={name} className="px-4 py-3">
251
+ <div className="flex gap-4">
252
+ <div className="min-w-0 flex-1">
253
+ <div className="flex items-center gap-2 flex-wrap">
254
+ <code className="text-sm font-mono text-zinc-100">{name}</code>
255
+ {isRequired ? (
256
+ <span className="text-xs text-red-400">required</span>
257
+ ) : (
258
+ <span className="text-xs text-zinc-600">optional</span>
259
+ )}
260
+ {schema.description && (
261
+ <p className="text-sm text-zinc-500 mt-1 w-full">{schema.description}</p>
262
+ )}
263
+ {schema.enum && (
264
+ <p className="text-xs text-zinc-500 mt-1 w-full">
265
+ One of:{' '}
266
+ {schema.enum.map((v, i) => (
267
+ <code key={i} className="text-zinc-300 bg-zinc-800 px-1 rounded mx-0.5">
268
+ {String(v)}
269
+ </code>
270
+ ))}
271
+ </p>
272
+ )}
273
+ </div>
274
+ </div>
275
+ <div className="text-xs text-zinc-400 font-mono self-start whitespace-nowrap">
276
+ {typeLabel}
277
+ {formatSuffix && <span className="text-zinc-500">{formatSuffix}</span>}
278
+ </div>
279
+ </div>
280
+ {hasNestedObject && (
281
+ <PropertiesTable
282
+ properties={schema.properties!}
283
+ required={schema.required ?? []}
284
+ depth={depth + 1}
285
+ />
286
+ )}
287
+ {hasNestedArray && (
288
+ <PropertiesTable
289
+ properties={schema.items!.properties!}
290
+ required={schema.items!.required ?? []}
291
+ depth={depth + 1}
292
+ />
293
+ )}
294
+ </div>
295
+ )
296
+ })}
297
+ </div>
298
+ )
299
+ }
300
+
301
+ const SCHEME_META: Record<string, { label: string; detail: string }> = {
302
+ bearerAuth: { label: 'Bearer', detail: 'http, bearer' },
303
+ basicAuth: { label: 'Basic', detail: 'http, basic' },
304
+ apiKeyHeader: { label: 'API Key', detail: 'apiKey, header' },
305
+ apiKeyQuery: { label: 'API Key', detail: 'apiKey, query' },
306
+ cookieAuth: { label: 'Cookie', detail: 'apiKey, cookie' },
307
+ }
308
+
309
+ function SecurityBadge({ scheme }: { scheme: string }) {
310
+ const meta = SCHEME_META[scheme] ?? { label: scheme, detail: 'unknown' }
311
+ return (
312
+ <span className="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs">
313
+ <span className="text-amber-400 font-medium">{meta.label}</span>
314
+ <span className="text-zinc-500">({meta.detail})</span>
315
+ </span>
316
+ )
317
+ }
318
+
137
319
  function StatusBadge({ code }: { code: string }) {
138
320
  const n = parseInt(code, 10)
139
321
  const color =
@@ -1,30 +1,51 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useEffect } from 'react'
4
+ import CodeMirror from '@uiw/react-codemirror'
5
+ import { json } from '@codemirror/lang-json'
6
+ import { oneDark } from '@codemirror/theme-one-dark'
4
7
  import type { Endpoint } from '@easydocs/core/schema'
5
8
  import type { Operation } from '@easydocs/core'
9
+ import { OperationSchema } from '@/lib/operation-schema'
6
10
 
7
11
  interface Props {
8
12
  endpoint: Endpoint
9
13
  onSaved: (updated: Endpoint) => void
14
+ onCancel: () => void
10
15
  }
11
16
 
12
- export function SpecEditor({ endpoint, onSaved }: Props) {
17
+ function validate(raw: string): string | null {
18
+ let parsed: unknown
19
+ try {
20
+ parsed = JSON.parse(raw)
21
+ } catch {
22
+ return 'Invalid JSON'
23
+ }
24
+ const result = OperationSchema.safeParse(parsed)
25
+ if (!result.success) {
26
+ const first = result.error.errors[0]
27
+ return `${first.path.join('.') || 'root'}: ${first.message}`
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function SpecEditor({ endpoint, onSaved, onCancel }: Props) {
13
33
  const activeSpec = endpoint.isManuallyEdited ? endpoint.manualSpec : endpoint.spec
14
34
  const [value, setValue] = useState(() => JSON.stringify(activeSpec, null, 2))
15
- const [error, setError] = useState<string | null>(null)
35
+ const [validationError, setValidationError] = useState<string | null>(null)
36
+ const [saveError, setSaveError] = useState<string | null>(null)
16
37
  const [saving, setSaving] = useState(false)
17
38
 
39
+ useEffect(() => {
40
+ setValidationError(validate(value))
41
+ }, [value])
42
+
18
43
  async function handleSave() {
19
- setError(null)
20
- let parsed: Operation
21
- try {
22
- parsed = JSON.parse(value) as Operation
23
- } catch {
24
- setError('Invalid JSON')
25
- return
26
- }
44
+ const err = validate(value)
45
+ if (err) { setValidationError(err); return }
46
+ setSaveError(null)
27
47
  setSaving(true)
48
+ const parsed = JSON.parse(value) as Operation
28
49
  const res = await fetch(`/api/endpoints/${endpoint.id}/spec`, {
29
50
  method: 'PUT',
30
51
  headers: { 'Content-Type': 'application/json' },
@@ -34,31 +55,54 @@ export function SpecEditor({ endpoint, onSaved }: Props) {
34
55
  if (res.ok) {
35
56
  onSaved({ ...endpoint, manualSpec: parsed, isManuallyEdited: true, hasConflict: false })
36
57
  } else {
37
- setError('Failed to save')
58
+ setSaveError('Failed to save')
38
59
  }
39
60
  }
40
61
 
62
+ const invalid = validationError !== null
63
+
41
64
  return (
42
65
  <div className="flex flex-col h-full">
43
66
  <div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
44
67
  <span className="text-xs text-zinc-400">Edit spec (JSON)</span>
45
- <div className="flex gap-2">
46
- {error && <span className="text-xs text-red-400">{error}</span>}
68
+ <div className="flex items-center gap-2">
69
+ {saveError && <span className="text-xs text-red-400">{saveError}</span>}
70
+ <button
71
+ onClick={onCancel}
72
+ className="text-xs px-3 py-1 rounded bg-zinc-800 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
73
+ >
74
+ Cancel
75
+ </button>
47
76
  <button
48
77
  onClick={handleSave}
49
- disabled={saving}
50
- className="text-xs px-3 py-1 rounded bg-zinc-700 text-zinc-100 hover:bg-zinc-600 disabled:opacity-50 transition-colors"
78
+ disabled={saving || invalid}
79
+ className="text-xs px-3 py-1 rounded bg-zinc-700 text-zinc-100 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
51
80
  >
52
81
  {saving ? 'Saving…' : 'Save'}
53
82
  </button>
54
83
  </div>
55
84
  </div>
56
- <textarea
57
- className="flex-1 resize-none bg-zinc-950 text-zinc-200 font-mono text-xs p-4 focus:outline-none"
58
- value={value}
59
- onChange={(e) => setValue(e.target.value)}
60
- spellCheck={false}
61
- />
85
+ <div className="flex-1 overflow-auto">
86
+ <CodeMirror
87
+ value={value}
88
+ height="100%"
89
+ extensions={[json()]}
90
+ theme={oneDark}
91
+ onChange={setValue}
92
+ basicSetup={{
93
+ lineNumbers: true,
94
+ foldGutter: true,
95
+ bracketMatching: true,
96
+ autocompletion: true,
97
+ }}
98
+ style={{ fontSize: '12px', height: '100%' }}
99
+ />
100
+ </div>
101
+ {validationError && (
102
+ <div className="px-4 py-2 border-t border-red-900/50 bg-red-950/30 text-xs text-red-400 font-mono">
103
+ {validationError}
104
+ </div>
105
+ )}
62
106
  </div>
63
107
  )
64
108
  }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod'
2
+
3
+ const SchemaObject = z.record(z.any()).optional()
4
+
5
+ const MediaTypeSchema = z.object({
6
+ schema: SchemaObject,
7
+ example: z.any().optional(),
8
+ examples: z.record(z.any()).optional(),
9
+ })
10
+
11
+ const ParameterSchema = z.object({
12
+ name: z.string(),
13
+ in: z.enum(['query', 'header', 'path', 'cookie']),
14
+ description: z.string().optional(),
15
+ required: z.boolean().default(false),
16
+ deprecated: z.boolean().optional(),
17
+ schema: SchemaObject,
18
+ example: z.any().optional(),
19
+ })
20
+
21
+ const ResponseSchema = z.object({
22
+ description: z.string(),
23
+ headers: z.record(z.any()).optional(),
24
+ content: z.record(MediaTypeSchema).optional(),
25
+ })
26
+
27
+ export const OperationSchema = z.object({
28
+ tags: z.array(z.string()).optional(),
29
+ summary: z.string().optional(),
30
+ description: z.string().optional(),
31
+ operationId: z.string().optional(),
32
+ parameters: z.array(ParameterSchema).optional(),
33
+ requestBody: z
34
+ .object({
35
+ description: z.string().optional(),
36
+ required: z.boolean().optional(),
37
+ content: z.record(MediaTypeSchema),
38
+ })
39
+ .nullable()
40
+ .optional(),
41
+ responses: z.record(ResponseSchema).default({}),
42
+ deprecated: z.boolean().optional(),
43
+ security: z.array(z.record(z.array(z.string()))).optional(),
44
+ })