@easydocs/dashboard 0.1.4

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 EasyDocs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/next.config.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from 'next'
2
+
3
+ const config: NextConfig = {
4
+ transpilePackages: ['@easydocs/core'],
5
+ }
6
+
7
+ export default config
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@easydocs/dashboard",
3
+ "version": "0.1.4",
4
+ "description": "EasyDocs docs dashboard — view and export AI-generated OpenAPI specs",
5
+ "files": [
6
+ "src",
7
+ "public",
8
+ "next.config.ts",
9
+ "tailwind.config.ts",
10
+ "postcss.config.mjs",
11
+ "tsconfig.json"
12
+ ],
13
+ "dependencies": {
14
+ "js-yaml": "^4.1.0",
15
+ "next": "15.1.6",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0",
18
+ "@easydocs/core": "0.1.4"
19
+ },
20
+ "devDependencies": {
21
+ "@types/js-yaml": "^4.0.9",
22
+ "@types/node": "^20",
23
+ "@types/react": "^19",
24
+ "@types/react-dom": "^19",
25
+ "eslint": "^9",
26
+ "eslint-config-next": "15.1.6",
27
+ "postcss": "^8",
28
+ "tailwindcss": "^3.4.1",
29
+ "typescript": "^5"
30
+ },
31
+ "scripts": {
32
+ "dev": "next dev --port 4999",
33
+ "build": "next build",
34
+ "start": "next start --port 4999",
35
+ "typecheck": "tsc --noEmit",
36
+ "lint": "next lint"
37
+ }
38
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ },
5
+ }
6
+
7
+ export default config
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { createDB, saveManualSpec, resolveConflict } from '@easydocs/core'
3
+ import type { Operation } from '@easydocs/core'
4
+
5
+ function getDb() {
6
+ return createDB(process.env.EASYDOCS_DB_URL)
7
+ }
8
+
9
+ // Save a manual spec edit
10
+ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params
12
+ const body = await req.json() as { spec: Operation }
13
+ if (!body.spec) return NextResponse.json({ error: 'spec required' }, { status: 400 })
14
+ await saveManualSpec(getDb(), id, body.spec)
15
+ return NextResponse.json({ ok: true })
16
+ }
17
+
18
+ // Resolve a conflict: keep 'ai' or 'manual'
19
+ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
20
+ const { id } = await params
21
+ const body = await req.json() as { keep: 'ai' | 'manual' }
22
+ if (body.keep !== 'ai' && body.keep !== 'manual') {
23
+ return NextResponse.json({ error: 'keep must be "ai" or "manual"' }, { status: 400 })
24
+ }
25
+ await resolveConflict(getDb(), id, body.keep)
26
+ return NextResponse.json({ ok: true })
27
+ }
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { fetchEndpoints } from '@/lib/db'
3
+ import { createDB, deleteEndpointById } from '@easydocs/core'
4
+
5
+ export async function GET(req: NextRequest) {
6
+ const project = req.nextUrl.searchParams.get('project') ?? undefined
7
+ const endpoints = await fetchEndpoints(project)
8
+ return NextResponse.json(endpoints)
9
+ }
10
+
11
+ export async function DELETE(req: Request) {
12
+ const { id } = await req.json()
13
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
14
+ const db = createDB(process.env.EASYDOCS_DB_URL)
15
+ await deleteEndpointById(db, id as string)
16
+ return NextResponse.json({ ok: true })
17
+ }
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { fetchEndpoints, buildFullSpec } from '@/lib/db'
3
+ import yaml from 'js-yaml'
4
+
5
+ export async function GET(req: NextRequest) {
6
+ const format = req.nextUrl.searchParams.get('format') ?? 'json'
7
+ const project = req.nextUrl.searchParams.get('project') ?? undefined
8
+ const endpoints = await fetchEndpoints(project)
9
+ const spec = buildFullSpec(endpoints, project)
10
+ const filename = project ? `openapi-${project}` : 'openapi'
11
+
12
+ if (format === 'yaml') {
13
+ return new NextResponse(yaml.dump(spec), {
14
+ headers: {
15
+ 'Content-Type': 'application/yaml',
16
+ 'Content-Disposition': `attachment; filename="${filename}.yaml"`,
17
+ },
18
+ })
19
+ }
20
+
21
+ return new NextResponse(JSON.stringify(spec, null, 2), {
22
+ headers: {
23
+ 'Content-Type': 'application/json',
24
+ 'Content-Disposition': `attachment; filename="${filename}.json"`,
25
+ },
26
+ })
27
+ }
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { fetchAllProjects } from '@/lib/db'
3
+
4
+ export async function GET() {
5
+ const projects = await fetchAllProjects()
6
+ return NextResponse.json(projects)
7
+ }
@@ -0,0 +1,8 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { fetchEndpoints, buildFullSpec } from '@/lib/db'
3
+
4
+ export async function GET(req: NextRequest) {
5
+ const project = req.nextUrl.searchParams.get('project') ?? undefined
6
+ const endpoints = await fetchEndpoints(project)
7
+ return NextResponse.json(buildFullSpec(endpoints, project))
8
+ }
@@ -0,0 +1,13 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ * {
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ html,
10
+ body {
11
+ height: 100%;
12
+ margin: 0;
13
+ }
@@ -0,0 +1,15 @@
1
+ import type { Metadata } from 'next'
2
+ import './globals.css'
3
+
4
+ export const metadata: Metadata = {
5
+ title: 'EasyDocs',
6
+ description: 'Auto-generated API documentation',
7
+ }
8
+
9
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
10
+ return (
11
+ <html lang="en" className="dark">
12
+ <body className="antialiased">{children}</body>
13
+ </html>
14
+ )
15
+ }
@@ -0,0 +1,18 @@
1
+ import { fetchEndpoints, fetchAllProjects } from '@/lib/db'
2
+ import { Dashboard } from '@/components/Dashboard'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ interface Props {
7
+ searchParams: Promise<{ project?: string }>
8
+ }
9
+
10
+ export default async function HomePage({ searchParams }: Props) {
11
+ const { project } = await searchParams
12
+ const [endpoints, projects] = await Promise.all([
13
+ fetchEndpoints(project),
14
+ fetchAllProjects(),
15
+ ])
16
+
17
+ return <Dashboard endpoints={endpoints} projects={projects} currentProject={project} />
18
+ }
@@ -0,0 +1,60 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { Endpoint } from '@easydocs/core/schema'
5
+
6
+ interface Props {
7
+ endpoint: Endpoint
8
+ onResolved: (updated: Endpoint) => void
9
+ }
10
+
11
+ export function ConflictBanner({ endpoint, onResolved }: Props) {
12
+ const [resolving, setResolving] = useState(false)
13
+
14
+ async function resolve(keep: 'ai' | 'manual') {
15
+ setResolving(true)
16
+ const res = await fetch(`/api/endpoints/${endpoint.id}/spec`, {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json' },
19
+ body: JSON.stringify({ keep }),
20
+ })
21
+ setResolving(false)
22
+ if (res.ok) {
23
+ onResolved({
24
+ ...endpoint,
25
+ hasConflict: false,
26
+ isManuallyEdited: keep === 'manual',
27
+ manualSpec: keep === 'ai' ? null : endpoint.manualSpec,
28
+ spec: keep === 'manual' ? endpoint.manualSpec : endpoint.spec,
29
+ })
30
+ }
31
+ }
32
+
33
+ return (
34
+ <div className="mx-8 mt-6 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 flex items-center justify-between gap-4">
35
+ <div>
36
+ <p className="text-sm font-medium text-amber-400">Spec conflict</p>
37
+ <p className="text-xs text-amber-300/70 mt-0.5">
38
+ New traffic generated an AI spec that differs from your manual edits. Choose which version
39
+ to keep.
40
+ </p>
41
+ </div>
42
+ <div className="flex gap-2 flex-shrink-0">
43
+ <button
44
+ onClick={() => resolve('manual')}
45
+ disabled={resolving}
46
+ className="text-xs px-3 py-1.5 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 disabled:opacity-50 transition-colors"
47
+ >
48
+ Keep mine
49
+ </button>
50
+ <button
51
+ onClick={() => resolve('ai')}
52
+ disabled={resolving}
53
+ className="text-xs px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 transition-colors"
54
+ >
55
+ Use AI
56
+ </button>
57
+ </div>
58
+ </div>
59
+ )
60
+ }
@@ -0,0 +1,151 @@
1
+ 'use client'
2
+
3
+ import { useState, useMemo } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { MethodBadge } from './MethodBadge'
6
+ import { EndpointDetail } from './EndpointDetail'
7
+ import type { Endpoint, Project } from '@easydocs/core/schema'
8
+
9
+ interface Props {
10
+ endpoints: Endpoint[]
11
+ projects: Project[]
12
+ currentProject?: string
13
+ }
14
+
15
+ export function Dashboard({ endpoints, projects, currentProject }: Props) {
16
+ const router = useRouter()
17
+ const [selected, setSelected] = useState<Endpoint | null>(endpoints[0] ?? null)
18
+ const [search, setSearch] = useState('')
19
+
20
+ const filtered = useMemo(
21
+ () =>
22
+ endpoints.filter(
23
+ (e) =>
24
+ e.path.toLowerCase().includes(search.toLowerCase()) ||
25
+ e.method.toLowerCase().includes(search.toLowerCase()) ||
26
+ e.spec?.summary?.toLowerCase().includes(search.toLowerCase())
27
+ ),
28
+ [endpoints, search]
29
+ )
30
+
31
+ const grouped = useMemo(() => {
32
+ const groups: Record<string, Endpoint[]> = {}
33
+ for (const e of filtered) {
34
+ const tag = e.spec?.tags?.[0] ?? 'other'
35
+ if (!groups[tag]) groups[tag] = []
36
+ groups[tag].push(e)
37
+ }
38
+ return groups
39
+ }, [filtered])
40
+
41
+ function handleProjectChange(slug: string) {
42
+ router.push(slug === '__all' ? '/' : `/?project=${slug}`)
43
+ }
44
+
45
+ const exportBase = currentProject ? `/api/export?project=${currentProject}` : '/api/export'
46
+
47
+ return (
48
+ <div className="flex h-screen bg-zinc-950 text-zinc-100">
49
+ {/* Sidebar */}
50
+ <aside className="w-72 flex-shrink-0 border-r border-zinc-800 flex flex-col">
51
+ <div className="p-4 border-b border-zinc-800 space-y-3">
52
+ <div className="flex items-center justify-between">
53
+ <span className="text-sm font-semibold text-zinc-100">EasyDocs</span>
54
+ <span className="text-xs text-zinc-500">{endpoints.length} endpoints</span>
55
+ </div>
56
+
57
+ {projects.length > 1 && (
58
+ <select
59
+ value={currentProject ?? '__all'}
60
+ onChange={(e) => handleProjectChange(e.target.value)}
61
+ className="w-full rounded-md bg-zinc-900 border border-zinc-700 px-2 py-1.5 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-zinc-500"
62
+ >
63
+ <option value="__all">All projects</option>
64
+ {projects.map((p) => (
65
+ <option key={p.id} value={p.slug}>
66
+ {p.name}
67
+ </option>
68
+ ))}
69
+ </select>
70
+ )}
71
+
72
+ {projects.length === 1 && (
73
+ <div className="text-xs text-zinc-500">
74
+ Project: <span className="text-zinc-300">{projects[0].name}</span>
75
+ </div>
76
+ )}
77
+
78
+ <input
79
+ type="text"
80
+ placeholder="Search endpoints…"
81
+ value={search}
82
+ onChange={(e) => setSearch(e.target.value)}
83
+ className="w-full rounded-md bg-zinc-900 border border-zinc-700 px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
84
+ />
85
+ </div>
86
+
87
+ {endpoints.length === 0 ? (
88
+ <div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
89
+ <p className="text-zinc-500 text-sm">No endpoints documented yet.</p>
90
+ <p className="text-zinc-600 text-xs mt-1">
91
+ Add EasyDocs middleware to your server and make some requests.
92
+ </p>
93
+ </div>
94
+ ) : (
95
+ <nav className="flex-1 overflow-y-auto p-2">
96
+ {Object.entries(grouped).map(([tag, items]) => (
97
+ <div key={tag} className="mb-4">
98
+ <div className="px-2 py-1 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
99
+ {tag}
100
+ </div>
101
+ {items.map((e) => (
102
+ <button
103
+ key={e.id}
104
+ onClick={() => setSelected(e)}
105
+ className={`w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors ${
106
+ selected?.id === e.id
107
+ ? 'bg-zinc-800 text-zinc-100'
108
+ : 'text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200'
109
+ }`}
110
+ >
111
+ <MethodBadge method={e.method} />
112
+ <span className="text-xs font-mono truncate flex-1">{e.path}</span>
113
+ {e.hasConflict && (
114
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
115
+ )}
116
+ </button>
117
+ ))}
118
+ </div>
119
+ ))}
120
+ </nav>
121
+ )}
122
+
123
+ <div className="p-3 border-t border-zinc-800 flex gap-2">
124
+ <a
125
+ href={`${exportBase}&format=json`}
126
+ className="flex-1 text-center text-xs py-1.5 rounded-md bg-zinc-800 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
127
+ >
128
+ Export JSON
129
+ </a>
130
+ <a
131
+ href={`${exportBase}&format=yaml`}
132
+ className="flex-1 text-center text-xs py-1.5 rounded-md bg-zinc-800 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
133
+ >
134
+ Export YAML
135
+ </a>
136
+ </div>
137
+ </aside>
138
+
139
+ {/* Main */}
140
+ <main className="flex-1 overflow-hidden">
141
+ {selected ? (
142
+ <EndpointDetail endpoint={selected} />
143
+ ) : (
144
+ <div className="flex items-center justify-center h-full text-zinc-500 text-sm">
145
+ Select an endpoint to view its documentation.
146
+ </div>
147
+ )}
148
+ </main>
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,152 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { MethodBadge } from './MethodBadge'
5
+ import { SchemaViewer } from './SchemaViewer'
6
+ import { SpecEditor } from './SpecEditor'
7
+ import { ConflictBanner } from './ConflictBanner'
8
+ import type { Endpoint } from '@easydocs/core/schema'
9
+
10
+ export function EndpointDetail({ endpoint: initial }: { endpoint: Endpoint }) {
11
+ const [endpoint, setEndpoint] = useState(initial)
12
+ const [editing, setEditing] = useState(false)
13
+
14
+ const activeSpec = endpoint.isManuallyEdited ? endpoint.manualSpec : endpoint.spec
15
+
16
+ if (!activeSpec) {
17
+ return (
18
+ <div className="flex items-center justify-center h-full text-zinc-500 text-sm">
19
+ No spec available for this endpoint.
20
+ </div>
21
+ )
22
+ }
23
+
24
+ return (
25
+ <div className="flex flex-col h-full overflow-hidden">
26
+ {endpoint.hasConflict && (
27
+ <ConflictBanner endpoint={endpoint} onResolved={setEndpoint} />
28
+ )}
29
+
30
+ <div className="flex items-center justify-between px-8 pt-6 pb-2">
31
+ <div className="flex items-center gap-3">
32
+ <MethodBadge method={endpoint.method} />
33
+ <code className="text-lg font-mono text-zinc-100">{endpoint.path}</code>
34
+ {endpoint.isManuallyEdited && !endpoint.hasConflict && (
35
+ <span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded">edited</span>
36
+ )}
37
+ </div>
38
+ <button
39
+ onClick={() => setEditing((e) => !e)}
40
+ className="text-xs px-3 py-1.5 rounded bg-zinc-800 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
41
+ >
42
+ {editing ? 'View docs' : 'Edit spec'}
43
+ </button>
44
+ </div>
45
+
46
+ {editing ? (
47
+ <div className="flex-1 overflow-hidden border-t border-zinc-800 mt-4">
48
+ <SpecEditor endpoint={endpoint} onSaved={setEndpoint} />
49
+ </div>
50
+ ) : (
51
+ <div className="flex-1 overflow-y-auto px-8 pb-8 space-y-8">
52
+ {activeSpec.summary && (
53
+ <p className="text-zinc-300 text-sm">{activeSpec.summary}</p>
54
+ )}
55
+ {activeSpec.description && activeSpec.description !== activeSpec.summary && (
56
+ <p className="text-zinc-500 text-sm">{activeSpec.description}</p>
57
+ )}
58
+ {activeSpec.deprecated && (
59
+ <span className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-yellow-500/15 text-yellow-400 ring-1 ring-yellow-500/30">
60
+ Deprecated
61
+ </span>
62
+ )}
63
+
64
+ {activeSpec.parameters && activeSpec.parameters.length > 0 && (
65
+ <section>
66
+ <h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
67
+ Parameters
68
+ </h3>
69
+ <div className="rounded-lg border border-zinc-800 divide-y divide-zinc-800">
70
+ {activeSpec.parameters.map((param) => (
71
+ <div key={`${param.in}-${param.name}`} className="px-4 py-3 flex gap-4">
72
+ <div className="min-w-0 flex-1">
73
+ <div className="flex items-center gap-2 flex-wrap">
74
+ <code className="text-sm font-mono text-zinc-100">{param.name}</code>
75
+ <span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded">
76
+ {param.in}
77
+ </span>
78
+ {param.required && <span className="text-xs text-red-400">required</span>}
79
+ {param.deprecated && (
80
+ <span className="text-xs text-yellow-400">deprecated</span>
81
+ )}
82
+ </div>
83
+ {param.description && (
84
+ <p className="text-sm text-zinc-500 mt-1">{param.description}</p>
85
+ )}
86
+ </div>
87
+ {param.schema && (
88
+ <div className="text-xs text-zinc-400 font-mono self-start">
89
+ {(param.schema as Record<string, unknown>).type as string}
90
+ </div>
91
+ )}
92
+ </div>
93
+ ))}
94
+ </div>
95
+ </section>
96
+ )}
97
+
98
+ {activeSpec.requestBody && (
99
+ <section>
100
+ <h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
101
+ Request Body
102
+ </h3>
103
+ <div className="rounded-lg border border-zinc-800 p-4 bg-zinc-900/50">
104
+ <SchemaViewer data={activeSpec.requestBody.content} />
105
+ </div>
106
+ </section>
107
+ )}
108
+
109
+ {Object.keys(activeSpec.responses ?? {}).length > 0 && (
110
+ <section>
111
+ <h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
112
+ Responses
113
+ </h3>
114
+ <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} />
124
+ </div>
125
+ )}
126
+ </div>
127
+ ))}
128
+ </div>
129
+ </section>
130
+ )}
131
+ </div>
132
+ )}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ function StatusBadge({ code }: { code: string }) {
138
+ const n = parseInt(code, 10)
139
+ const color =
140
+ n < 300
141
+ ? 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
142
+ : n < 400
143
+ ? 'bg-blue-500/15 text-blue-400 ring-blue-500/30'
144
+ : n < 500
145
+ ? 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
146
+ : 'bg-red-500/15 text-red-400 ring-red-500/30'
147
+ return (
148
+ <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold ring-1 ring-inset font-mono ${color}`}>
149
+ {code}
150
+ </span>
151
+ )
152
+ }
@@ -0,0 +1,17 @@
1
+ const styles: Record<string, string> = {
2
+ GET: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
3
+ POST: 'bg-blue-500/15 text-blue-400 ring-blue-500/30',
4
+ PUT: 'bg-amber-500/15 text-amber-400 ring-amber-500/30',
5
+ PATCH: 'bg-orange-500/15 text-orange-400 ring-orange-500/30',
6
+ DELETE: 'bg-red-500/15 text-red-400 ring-red-500/30',
7
+ }
8
+
9
+ export function MethodBadge({ method }: { method: string }) {
10
+ return (
11
+ <span
12
+ className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold ring-1 ring-inset font-mono min-w-[46px] justify-center ${styles[method] ?? 'bg-zinc-500/15 text-zinc-400 ring-zinc-500/30'}`}
13
+ >
14
+ {method}
15
+ </span>
16
+ )
17
+ }
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ function ValueNode({ value, depth = 0 }: { value: unknown; depth?: number }) {
4
+ if (value === null) return <span className="text-zinc-500">null</span>
5
+ if (value === undefined) return <span className="text-zinc-500">undefined</span>
6
+
7
+ if (typeof value === 'boolean')
8
+ return <span className="text-purple-400">{String(value)}</span>
9
+ if (typeof value === 'number')
10
+ return <span className="text-amber-400">{String(value)}</span>
11
+ if (typeof value === 'string')
12
+ return <span className="text-green-400">"{value}"</span>
13
+
14
+ if (Array.isArray(value)) {
15
+ if (value.length === 0) return <span className="text-zinc-400">[]</span>
16
+ return (
17
+ <span>
18
+ <span className="text-zinc-400">[</span>
19
+ <div className="ml-4">
20
+ {value.map((item, i) => (
21
+ <div key={i}>
22
+ <ValueNode value={item} depth={depth + 1} />
23
+ {i < value.length - 1 && <span className="text-zinc-600">,</span>}
24
+ </div>
25
+ ))}
26
+ </div>
27
+ <span className="text-zinc-400">]</span>
28
+ </span>
29
+ )
30
+ }
31
+
32
+ if (typeof value === 'object') {
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ if (entries.length === 0) return <span className="text-zinc-400">{'{}'}</span>
35
+ return (
36
+ <span>
37
+ <span className="text-zinc-400">{'{'}</span>
38
+ <div className="ml-4">
39
+ {entries.map(([k, v], i) => (
40
+ <div key={k}>
41
+ <span className="text-sky-300">"{k}"</span>
42
+ <span className="text-zinc-400">: </span>
43
+ <ValueNode value={v} depth={depth + 1} />
44
+ {i < entries.length - 1 && <span className="text-zinc-600">,</span>}
45
+ </div>
46
+ ))}
47
+ </div>
48
+ <span className="text-zinc-400">{'}'}</span>
49
+ </span>
50
+ )
51
+ }
52
+
53
+ return <span className="text-zinc-300">{String(value)}</span>
54
+ }
55
+
56
+ export function SchemaViewer({ data }: { data: unknown }) {
57
+ return (
58
+ <pre className="text-xs leading-5 font-mono overflow-x-auto">
59
+ <ValueNode value={data} />
60
+ </pre>
61
+ )
62
+ }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { Endpoint } from '@easydocs/core/schema'
5
+ import type { Operation } from '@easydocs/core'
6
+
7
+ interface Props {
8
+ endpoint: Endpoint
9
+ onSaved: (updated: Endpoint) => void
10
+ }
11
+
12
+ export function SpecEditor({ endpoint, onSaved }: Props) {
13
+ const activeSpec = endpoint.isManuallyEdited ? endpoint.manualSpec : endpoint.spec
14
+ const [value, setValue] = useState(() => JSON.stringify(activeSpec, null, 2))
15
+ const [error, setError] = useState<string | null>(null)
16
+ const [saving, setSaving] = useState(false)
17
+
18
+ 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
+ }
27
+ setSaving(true)
28
+ const res = await fetch(`/api/endpoints/${endpoint.id}/spec`, {
29
+ method: 'PUT',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({ spec: parsed }),
32
+ })
33
+ setSaving(false)
34
+ if (res.ok) {
35
+ onSaved({ ...endpoint, manualSpec: parsed, isManuallyEdited: true, hasConflict: false })
36
+ } else {
37
+ setError('Failed to save')
38
+ }
39
+ }
40
+
41
+ return (
42
+ <div className="flex flex-col h-full">
43
+ <div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
44
+ <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>}
47
+ <button
48
+ 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"
51
+ >
52
+ {saving ? 'Saving…' : 'Save'}
53
+ </button>
54
+ </div>
55
+ </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
+ />
62
+ </div>
63
+ )
64
+ }
package/src/lib/db.ts ADDED
@@ -0,0 +1,68 @@
1
+ import {
2
+ createDB,
3
+ getAllEndpoints,
4
+ getEndpointsByProject,
5
+ getAllProjects,
6
+ findOrCreateProject,
7
+ } from '@easydocs/core'
8
+ import type { Endpoint, Project } from '@easydocs/core/schema'
9
+
10
+ let db: ReturnType<typeof createDB> | null = null
11
+
12
+ function getDb() {
13
+ if (!db) db = createDB(process.env.EASYDOCS_DB_URL)
14
+ return db
15
+ }
16
+
17
+ export async function fetchAllProjects(): Promise<Project[]> {
18
+ return getAllProjects(getDb())
19
+ }
20
+
21
+ export async function fetchEndpoints(projectSlug?: string): Promise<Endpoint[]> {
22
+ const db = getDb()
23
+ if (!projectSlug) return getAllEndpoints(db)
24
+ const projectId = await findOrCreateProject(db, projectSlug)
25
+ return getEndpointsByProject(db, projectId)
26
+ }
27
+
28
+ const SECURITY_SCHEME_DEFS: Record<string, unknown> = {
29
+ bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
30
+ basicAuth: { type: 'http', scheme: 'basic' },
31
+ apiKeyHeader: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
32
+ apiKeyQuery: { type: 'apiKey', in: 'query', name: 'api_key' },
33
+ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'session' },
34
+ }
35
+
36
+ export function buildFullSpec(endpointList: Endpoint[], projectName?: string) {
37
+ const usedSchemes = new Set<string>()
38
+ const paths: Record<string, Record<string, unknown>> = {}
39
+
40
+ for (const e of endpointList) {
41
+ if (!e.path || !e.method) continue
42
+ const activeSpec = e.isManuallyEdited && e.manualSpec ? e.manualSpec : e.spec
43
+ if (!activeSpec) continue
44
+
45
+ if (!paths[e.path]) paths[e.path] = {}
46
+ paths[e.path][e.method.toLowerCase()] = activeSpec
47
+
48
+ if (activeSpec.security) {
49
+ for (const entry of activeSpec.security) {
50
+ for (const name of Object.keys(entry)) {
51
+ if (SECURITY_SCHEME_DEFS[name]) usedSchemes.add(name)
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ const securitySchemes =
58
+ usedSchemes.size > 0
59
+ ? Object.fromEntries([...usedSchemes].map((n) => [n, SECURITY_SCHEME_DEFS[n]]))
60
+ : undefined
61
+
62
+ return {
63
+ openapi: '3.0.3',
64
+ info: { title: projectName ?? 'API Documentation', version: '1.0.0' },
65
+ paths,
66
+ ...(securitySchemes ? { components: { securitySchemes } } : {}),
67
+ }
68
+ }
@@ -0,0 +1,14 @@
1
+ import type { Config } from 'tailwindcss'
2
+
3
+ const config: Config = {
4
+ content: ['./src/**/*.{ts,tsx}'],
5
+ theme: {
6
+ extend: {
7
+ fontFamily: {
8
+ mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
9
+ },
10
+ },
11
+ },
12
+ }
13
+
14
+ export default config
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }