@mg21st/dev-assist 1.0.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/.eslintrc.json +17 -0
- package/.github/workflows/ci.yml +42 -0
- package/.github/workflows/docs.yml +49 -0
- package/.github/workflows/publish.yml +49 -0
- package/README.md +117 -0
- package/bin/dev-assist.js +4 -0
- package/dev-assist.config.js +10 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +133 -0
- package/dist/cli/wizard.d.ts +5 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +66 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +62 -0
- package/dist/generators/docsGenerator.d.ts +15 -0
- package/dist/generators/docsGenerator.d.ts.map +1 -0
- package/dist/generators/docsGenerator.js +186 -0
- package/dist/generators/testGenerator.d.ts +12 -0
- package/dist/generators/testGenerator.d.ts.map +1 -0
- package/dist/generators/testGenerator.js +185 -0
- package/dist/parser/astParser.d.ts +7 -0
- package/dist/parser/astParser.d.ts.map +1 -0
- package/dist/parser/astParser.js +194 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +247 -0
- package/dist/shared/types.d.ts +77 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +3 -0
- package/docs/_config.yml +22 -0
- package/docs/api-reference.md +173 -0
- package/docs/architecture.md +90 -0
- package/docs/configuration.md +52 -0
- package/docs/contributing.md +101 -0
- package/docs/index.md +50 -0
- package/docs/installation.md +95 -0
- package/docs/usage.md +107 -0
- package/package.json +58 -0
- package/src/cli/index.ts +108 -0
- package/src/cli/wizard.ts +63 -0
- package/src/config.ts +29 -0
- package/src/generators/docsGenerator.ts +192 -0
- package/src/generators/testGenerator.ts +174 -0
- package/src/parser/astParser.ts +172 -0
- package/src/server/index.ts +238 -0
- package/src/shared/types.ts +83 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +19 -0
- package/ui/index.html +13 -0
- package/ui/package-lock.json +3086 -0
- package/ui/package.json +31 -0
- package/ui/postcss.config.js +6 -0
- package/ui/src/App.tsx +36 -0
- package/ui/src/components/ApiDocsTab.tsx +184 -0
- package/ui/src/components/ApiTestingTab.tsx +363 -0
- package/ui/src/components/Dashboard.tsx +128 -0
- package/ui/src/components/Layout.tsx +76 -0
- package/ui/src/components/TestsTab.tsx +149 -0
- package/ui/src/main.tsx +10 -0
- package/ui/src/styles/index.css +41 -0
- package/ui/tailwind.config.js +20 -0
- package/ui/tsconfig.json +19 -0
- package/ui/vite.config.ts +19 -0
package/ui/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dev-assist-ui",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"axios": "^1.6.2",
|
|
13
|
+
"lucide-react": "^0.294.0",
|
|
14
|
+
"react": "^18.2.0",
|
|
15
|
+
"react-dom": "^18.2.0",
|
|
16
|
+
"react-router-dom": "^6.20.1",
|
|
17
|
+
"socket.io-client": "^4.6.1",
|
|
18
|
+
"prismjs": "^1.29.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/prismjs": "^1.26.3",
|
|
22
|
+
"@types/react": "^18.2.41",
|
|
23
|
+
"@types/react-dom": "^18.2.17",
|
|
24
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
25
|
+
"autoprefixer": "^10.4.16",
|
|
26
|
+
"postcss": "^8.4.32",
|
|
27
|
+
"tailwindcss": "^3.3.6",
|
|
28
|
+
"typescript": "^5.3.2",
|
|
29
|
+
"vite": "^5.0.6"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/ui/src/App.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Layout } from './components/Layout';
|
|
3
|
+
import { Dashboard } from './components/Dashboard';
|
|
4
|
+
import { TestsTab } from './components/TestsTab';
|
|
5
|
+
import { ApiDocsTab } from './components/ApiDocsTab';
|
|
6
|
+
import { ApiTestingTab } from './components/ApiTestingTab';
|
|
7
|
+
|
|
8
|
+
export type TabId = 'dashboard' | 'tests' | 'docs' | 'testing';
|
|
9
|
+
|
|
10
|
+
export default function App() {
|
|
11
|
+
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
|
12
|
+
const [darkMode, setDarkMode] = useState(() => {
|
|
13
|
+
return localStorage.getItem('darkMode') === 'true';
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
document.documentElement.classList.toggle('dark', darkMode);
|
|
18
|
+
localStorage.setItem('darkMode', String(darkMode));
|
|
19
|
+
}, [darkMode]);
|
|
20
|
+
|
|
21
|
+
const renderTab = () => {
|
|
22
|
+
switch (activeTab) {
|
|
23
|
+
case 'dashboard': return <Dashboard />;
|
|
24
|
+
case 'tests': return <TestsTab />;
|
|
25
|
+
case 'docs': return <ApiDocsTab />;
|
|
26
|
+
case 'testing': return <ApiTestingTab />;
|
|
27
|
+
default: return <Dashboard />;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Layout activeTab={activeTab} onTabChange={setActiveTab} darkMode={darkMode} onToggleDark={() => setDarkMode(d => !d)}>
|
|
33
|
+
{renderTab()}
|
|
34
|
+
</Layout>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { BookOpen, ChevronDown, ChevronUp, RefreshCw, Loader, Tag } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface PathParam {
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
required: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ApiEndpoint {
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
params: PathParam[];
|
|
15
|
+
queryParams: PathParam[];
|
|
16
|
+
description?: string;
|
|
17
|
+
exampleRequest?: Record<string, unknown>;
|
|
18
|
+
exampleResponse?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ApiDoc {
|
|
22
|
+
title: string;
|
|
23
|
+
version: string;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
endpoints: ApiEndpoint[];
|
|
26
|
+
generatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const methodColors: Record<string, string> = {
|
|
30
|
+
GET: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
|
31
|
+
POST: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
|
32
|
+
PUT: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
|
33
|
+
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
|
34
|
+
PATCH: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function EndpointCard({ endpoint }: { endpoint: ApiEndpoint }) {
|
|
38
|
+
const [expanded, setExpanded] = useState(false);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
42
|
+
<button
|
|
43
|
+
onClick={() => setExpanded(!expanded)}
|
|
44
|
+
className="w-full flex items-center gap-3 p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
|
45
|
+
>
|
|
46
|
+
<span className={`px-2.5 py-0.5 rounded text-xs font-bold font-mono ${methodColors[endpoint.method] || 'bg-gray-100 text-gray-700'}`}>
|
|
47
|
+
{endpoint.method}
|
|
48
|
+
</span>
|
|
49
|
+
<code className="flex-1 text-sm font-mono">{endpoint.path}</code>
|
|
50
|
+
{expanded ? <ChevronUp size={16} className="text-gray-400" /> : <ChevronDown size={16} className="text-gray-400" />}
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
{expanded && (
|
|
54
|
+
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
|
55
|
+
{endpoint.description && (
|
|
56
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">{endpoint.description}</p>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{endpoint.params.length > 0 && (
|
|
60
|
+
<div>
|
|
61
|
+
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Path Parameters</h4>
|
|
62
|
+
<div className="space-y-1">
|
|
63
|
+
{endpoint.params.map(p => (
|
|
64
|
+
<div key={p.name} className="flex items-center gap-2 text-sm">
|
|
65
|
+
<Tag size={12} className="text-gray-400" />
|
|
66
|
+
<code className="font-mono text-sky-600 dark:text-sky-400">{p.name}</code>
|
|
67
|
+
<span className="text-gray-400 text-xs">{p.type}</span>
|
|
68
|
+
{p.required && <span className="text-red-500 text-xs">required</span>}
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{endpoint.exampleResponse && Object.keys(endpoint.exampleResponse).length > 0 && (
|
|
76
|
+
<div>
|
|
77
|
+
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Example Response</h4>
|
|
78
|
+
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 text-xs font-mono overflow-auto text-gray-800 dark:text-gray-200 scrollbar-thin">
|
|
79
|
+
{JSON.stringify(endpoint.exampleResponse, null, 2)}
|
|
80
|
+
</pre>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ApiDocsTab() {
|
|
90
|
+
const [docs, setDocs] = useState<ApiDoc | null>(null);
|
|
91
|
+
const [loading, setLoading] = useState(true);
|
|
92
|
+
const [generating, setGenerating] = useState(false);
|
|
93
|
+
const [error, setError] = useState<string | null>(null);
|
|
94
|
+
|
|
95
|
+
const fetchDocs = useCallback(async () => {
|
|
96
|
+
try {
|
|
97
|
+
setLoading(true);
|
|
98
|
+
const { data } = await axios.get<ApiDoc>('/api/docs');
|
|
99
|
+
setDocs(data);
|
|
100
|
+
} catch {
|
|
101
|
+
setError('Failed to fetch API docs');
|
|
102
|
+
} finally {
|
|
103
|
+
setLoading(false);
|
|
104
|
+
}
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const generateDocs = async () => {
|
|
108
|
+
try {
|
|
109
|
+
setGenerating(true);
|
|
110
|
+
setError(null);
|
|
111
|
+
await axios.post('/api/generate/docs', {});
|
|
112
|
+
await fetchDocs();
|
|
113
|
+
} catch {
|
|
114
|
+
setError('Failed to generate API docs');
|
|
115
|
+
} finally {
|
|
116
|
+
setGenerating(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
useEffect(() => { fetchDocs(); }, [fetchDocs]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="space-y-4">
|
|
124
|
+
<div className="flex items-center justify-between">
|
|
125
|
+
<div>
|
|
126
|
+
<h1 className="text-2xl font-bold">API Documentation</h1>
|
|
127
|
+
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
|
128
|
+
{docs?.endpoints.length || 0} endpoints discovered
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex gap-2">
|
|
132
|
+
<button
|
|
133
|
+
onClick={fetchDocs}
|
|
134
|
+
className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm"
|
|
135
|
+
>
|
|
136
|
+
<RefreshCw size={14} />
|
|
137
|
+
Refresh
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
onClick={generateDocs}
|
|
141
|
+
disabled={generating}
|
|
142
|
+
className="flex items-center gap-2 px-4 py-2 bg-sky-500 text-white rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors text-sm font-medium"
|
|
143
|
+
>
|
|
144
|
+
{generating ? <Loader size={14} className="animate-spin" /> : <BookOpen size={14} />}
|
|
145
|
+
{generating ? 'Generating...' : 'Generate Docs'}
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{error && (
|
|
151
|
+
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
|
|
152
|
+
{error}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{loading ? (
|
|
157
|
+
<div className="flex items-center justify-center h-64">
|
|
158
|
+
<Loader className="animate-spin text-sky-500" size={32} />
|
|
159
|
+
</div>
|
|
160
|
+
) : !docs || docs.endpoints.length === 0 ? (
|
|
161
|
+
<div className="flex flex-col items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
|
162
|
+
<BookOpen size={48} className="mb-4 opacity-30" />
|
|
163
|
+
<p className="text-lg font-medium">No API endpoints found</p>
|
|
164
|
+
<p className="text-sm mt-1">Click "Generate Docs" to scan for Express routes</p>
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
<div className="space-y-3">
|
|
168
|
+
{docs && (
|
|
169
|
+
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700 text-sm">
|
|
170
|
+
<span className="font-medium">{docs.title}</span>
|
|
171
|
+
<span className="mx-2 text-gray-400">•</span>
|
|
172
|
+
<span className="text-gray-500">v{docs.version}</span>
|
|
173
|
+
<span className="mx-2 text-gray-400">•</span>
|
|
174
|
+
<code className="text-sky-600 dark:text-sky-400">{docs.baseUrl}</code>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
{docs?.endpoints.map((endpoint, i) => (
|
|
178
|
+
<EndpointCard key={i} endpoint={endpoint} />
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { Send, Plus, Trash2, Clock, Save, History, ChevronDown, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface Header {
|
|
6
|
+
key: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SavedRequest {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
method: string;
|
|
14
|
+
url: string;
|
|
15
|
+
headers: Header[];
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ApiResponse {
|
|
20
|
+
status: number;
|
|
21
|
+
statusText: string;
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
data: unknown;
|
|
24
|
+
responseTime: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
28
|
+
|
|
29
|
+
const methodColors: Record<string, string> = {
|
|
30
|
+
GET: 'text-blue-500',
|
|
31
|
+
POST: 'text-green-500',
|
|
32
|
+
PUT: 'text-yellow-500',
|
|
33
|
+
PATCH: 'text-purple-500',
|
|
34
|
+
DELETE: 'text-red-500',
|
|
35
|
+
HEAD: 'text-gray-500',
|
|
36
|
+
OPTIONS: 'text-gray-500',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function SaveRequestModal({ onSave, onCancel }: { onSave: (name: string) => void; onCancel: () => void }) {
|
|
40
|
+
const [name, setName] = useState('');
|
|
41
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => { inputRef.current?.focus(); }, []);
|
|
44
|
+
|
|
45
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
if (name.trim()) onSave(name.trim());
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
|
|
52
|
+
<div
|
|
53
|
+
className="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-5 w-80 border border-gray-200 dark:border-gray-700"
|
|
54
|
+
onClick={e => e.stopPropagation()}
|
|
55
|
+
>
|
|
56
|
+
<div className="flex items-center justify-between mb-4">
|
|
57
|
+
<h3 className="font-semibold">Save Request</h3>
|
|
58
|
+
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600"><X size={16} /></button>
|
|
59
|
+
</div>
|
|
60
|
+
<form onSubmit={handleSubmit} className="space-y-3">
|
|
61
|
+
<input
|
|
62
|
+
ref={inputRef}
|
|
63
|
+
type="text"
|
|
64
|
+
value={name}
|
|
65
|
+
onChange={e => setName(e.target.value)}
|
|
66
|
+
placeholder="Request name"
|
|
67
|
+
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
68
|
+
/>
|
|
69
|
+
<div className="flex justify-end gap-2">
|
|
70
|
+
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">Cancel</button>
|
|
71
|
+
<button type="submit" disabled={!name.trim()} className="px-3 py-1.5 text-sm bg-sky-500 text-white rounded-lg hover:bg-sky-600 disabled:opacity-50">Save</button>
|
|
72
|
+
</div>
|
|
73
|
+
</form>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ApiTestingTab() {
|
|
80
|
+
const [method, setMethod] = useState('GET');
|
|
81
|
+
const [url, setUrl] = useState('');
|
|
82
|
+
const [headers, setHeaders] = useState<Header[]>([{ key: 'Content-Type', value: 'application/json' }]);
|
|
83
|
+
const [body, setBody] = useState('');
|
|
84
|
+
const [response, setResponse] = useState<ApiResponse | null>(null);
|
|
85
|
+
const [loading, setLoading] = useState(false);
|
|
86
|
+
const [error, setError] = useState<string | null>(null);
|
|
87
|
+
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
88
|
+
const [savedRequests, setSavedRequests] = useState<SavedRequest[]>(() => {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(localStorage.getItem('dev-assist-requests') || '[]');
|
|
91
|
+
} catch { return []; }
|
|
92
|
+
});
|
|
93
|
+
const [showHistory, setShowHistory] = useState(true);
|
|
94
|
+
const [activeBodyTab, setActiveBodyTab] = useState<'body' | 'headers'>('headers');
|
|
95
|
+
|
|
96
|
+
const sendRequest = async () => {
|
|
97
|
+
if (!url.trim()) {
|
|
98
|
+
setError('Please enter a URL');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setLoading(true);
|
|
103
|
+
setError(null);
|
|
104
|
+
setResponse(null);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const headerMap: Record<string, string> = {};
|
|
108
|
+
headers.filter(h => h.key && h.value).forEach(h => { headerMap[h.key] = h.value; });
|
|
109
|
+
|
|
110
|
+
let parsedBody: unknown = undefined;
|
|
111
|
+
if (body.trim() && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
112
|
+
try {
|
|
113
|
+
parsedBody = JSON.parse(body);
|
|
114
|
+
} catch {
|
|
115
|
+
parsedBody = body;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { data } = await axios.post<ApiResponse>('/api/proxy', {
|
|
120
|
+
url,
|
|
121
|
+
method,
|
|
122
|
+
headers: headerMap,
|
|
123
|
+
body: parsedBody,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
setResponse(data);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
setError('Request failed: ' + (err instanceof Error ? err.message : String(err)));
|
|
129
|
+
} finally {
|
|
130
|
+
setLoading(false);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const saveRequest = (name: string) => {
|
|
135
|
+
const request: SavedRequest = {
|
|
136
|
+
id: Date.now().toString(),
|
|
137
|
+
name,
|
|
138
|
+
method,
|
|
139
|
+
url,
|
|
140
|
+
headers,
|
|
141
|
+
body,
|
|
142
|
+
};
|
|
143
|
+
const updated = [...savedRequests, request];
|
|
144
|
+
setSavedRequests(updated);
|
|
145
|
+
localStorage.setItem('dev-assist-requests', JSON.stringify(updated));
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const loadRequest = (req: SavedRequest) => {
|
|
149
|
+
setMethod(req.method);
|
|
150
|
+
setUrl(req.url);
|
|
151
|
+
setHeaders(req.headers);
|
|
152
|
+
setBody(req.body);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const deleteRequest = (id: string) => {
|
|
156
|
+
const updated = savedRequests.filter(r => r.id !== id);
|
|
157
|
+
setSavedRequests(updated);
|
|
158
|
+
localStorage.setItem('dev-assist-requests', JSON.stringify(updated));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const addHeader = () => setHeaders([...headers, { key: '', value: '' }]);
|
|
162
|
+
const updateHeader = (i: number, field: 'key' | 'value', val: string) => {
|
|
163
|
+
const updated = [...headers];
|
|
164
|
+
updated[i][field] = val;
|
|
165
|
+
setHeaders(updated);
|
|
166
|
+
};
|
|
167
|
+
const removeHeader = (i: number) => setHeaders(headers.filter((_, idx) => idx !== i));
|
|
168
|
+
|
|
169
|
+
const getStatusColor = (status: number) => {
|
|
170
|
+
if (status >= 200 && status < 300) return 'text-green-500';
|
|
171
|
+
if (status >= 300 && status < 400) return 'text-yellow-500';
|
|
172
|
+
if (status >= 400 && status < 500) return 'text-red-500';
|
|
173
|
+
return 'text-red-600';
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="flex gap-4 h-[calc(100vh-120px)]">
|
|
178
|
+
{showSaveModal && (
|
|
179
|
+
<SaveRequestModal
|
|
180
|
+
onSave={(name) => { saveRequest(name); setShowSaveModal(false); }}
|
|
181
|
+
onCancel={() => setShowSaveModal(false)}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
<div className={`${showHistory ? 'w-56' : 'w-10'} flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex flex-col transition-all`}>
|
|
185
|
+
<button
|
|
186
|
+
onClick={() => setShowHistory(!showHistory)}
|
|
187
|
+
className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
188
|
+
>
|
|
189
|
+
<History size={14} />
|
|
190
|
+
{showHistory && <span>Saved</span>}
|
|
191
|
+
</button>
|
|
192
|
+
{showHistory && (
|
|
193
|
+
<div className="flex-1 overflow-auto divide-y divide-gray-100 dark:divide-gray-700">
|
|
194
|
+
{savedRequests.length === 0 ? (
|
|
195
|
+
<p className="p-3 text-xs text-gray-400 text-center">No saved requests</p>
|
|
196
|
+
) : (
|
|
197
|
+
savedRequests.map(req => (
|
|
198
|
+
<div key={req.id} className="group flex items-center gap-1 p-2 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
199
|
+
<button
|
|
200
|
+
onClick={() => loadRequest(req)}
|
|
201
|
+
className="flex-1 text-left"
|
|
202
|
+
>
|
|
203
|
+
<span className={`text-xs font-mono font-bold ${methodColors[req.method] || 'text-gray-500'}`}>{req.method}</span>
|
|
204
|
+
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{req.name}</p>
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => deleteRequest(req.id)}
|
|
208
|
+
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600"
|
|
209
|
+
>
|
|
210
|
+
<Trash2 size={12} />
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
))
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
|
220
|
+
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
|
221
|
+
<h2 className="font-semibold text-sm text-gray-500 dark:text-gray-400 uppercase tracking-wide">Request</h2>
|
|
222
|
+
|
|
223
|
+
<div className="flex gap-2">
|
|
224
|
+
<div className="relative">
|
|
225
|
+
<select
|
|
226
|
+
value={method}
|
|
227
|
+
onChange={e => setMethod(e.target.value)}
|
|
228
|
+
className={`appearance-none pl-3 pr-7 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-bold font-mono ${methodColors[method] || ''} focus:outline-none focus:ring-2 focus:ring-sky-500`}
|
|
229
|
+
>
|
|
230
|
+
{HTTP_METHODS.map(m => (
|
|
231
|
+
<option key={m} value={m}>{m}</option>
|
|
232
|
+
))}
|
|
233
|
+
</select>
|
|
234
|
+
<ChevronDown size={12} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400" />
|
|
235
|
+
</div>
|
|
236
|
+
<input
|
|
237
|
+
type="text"
|
|
238
|
+
value={url}
|
|
239
|
+
onChange={e => setUrl(e.target.value)}
|
|
240
|
+
onKeyDown={e => e.key === 'Enter' && sendRequest()}
|
|
241
|
+
placeholder="https://api.example.com/endpoint"
|
|
242
|
+
className="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
243
|
+
/>
|
|
244
|
+
<button
|
|
245
|
+
onClick={sendRequest}
|
|
246
|
+
disabled={loading}
|
|
247
|
+
className="flex items-center gap-2 px-4 py-2 bg-sky-500 text-white rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors text-sm font-medium"
|
|
248
|
+
>
|
|
249
|
+
{loading ? (
|
|
250
|
+
<Clock size={14} className="animate-spin" />
|
|
251
|
+
) : (
|
|
252
|
+
<Send size={14} />
|
|
253
|
+
)}
|
|
254
|
+
Send
|
|
255
|
+
</button>
|
|
256
|
+
<button
|
|
257
|
+
onClick={() => setShowSaveModal(true)}
|
|
258
|
+
className="flex items-center gap-1.5 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm"
|
|
259
|
+
title="Save request"
|
|
260
|
+
>
|
|
261
|
+
<Save size={14} />
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div className="flex border-b border-gray-200 dark:border-gray-700 text-sm">
|
|
266
|
+
{(['headers', 'body'] as const).map(tab => (
|
|
267
|
+
<button
|
|
268
|
+
key={tab}
|
|
269
|
+
onClick={() => setActiveBodyTab(tab)}
|
|
270
|
+
className={`px-4 py-2 font-medium capitalize transition-colors ${
|
|
271
|
+
activeBodyTab === tab
|
|
272
|
+
? 'border-b-2 border-sky-500 text-sky-600 dark:text-sky-400'
|
|
273
|
+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'
|
|
274
|
+
}`}
|
|
275
|
+
>
|
|
276
|
+
{tab} {tab === 'headers' && `(${headers.filter(h => h.key).length})`}
|
|
277
|
+
</button>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{activeBodyTab === 'headers' ? (
|
|
282
|
+
<div className="space-y-2">
|
|
283
|
+
{headers.map((header, i) => (
|
|
284
|
+
<div key={i} className="flex gap-2">
|
|
285
|
+
<input
|
|
286
|
+
value={header.key}
|
|
287
|
+
onChange={e => updateHeader(i, 'key', e.target.value)}
|
|
288
|
+
placeholder="Header name"
|
|
289
|
+
className="flex-1 px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
290
|
+
/>
|
|
291
|
+
<input
|
|
292
|
+
value={header.value}
|
|
293
|
+
onChange={e => updateHeader(i, 'value', e.target.value)}
|
|
294
|
+
placeholder="Value"
|
|
295
|
+
className="flex-1 px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
|
296
|
+
/>
|
|
297
|
+
<button onClick={() => removeHeader(i)} className="text-red-400 hover:text-red-600">
|
|
298
|
+
<Trash2 size={14} />
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
))}
|
|
302
|
+
<button
|
|
303
|
+
onClick={addHeader}
|
|
304
|
+
className="flex items-center gap-1 text-sm text-sky-500 hover:text-sky-600"
|
|
305
|
+
>
|
|
306
|
+
<Plus size={14} /> Add Header
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
) : (
|
|
310
|
+
<textarea
|
|
311
|
+
value={body}
|
|
312
|
+
onChange={e => setBody(e.target.value)}
|
|
313
|
+
placeholder='{"key": "value"}'
|
|
314
|
+
rows={5}
|
|
315
|
+
className="w-full px-3 py-2 text-sm font-mono rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-sky-500 resize-y"
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
|
|
321
|
+
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
|
322
|
+
<h2 className="font-semibold text-sm text-gray-500 dark:text-gray-400 uppercase tracking-wide">Response</h2>
|
|
323
|
+
{response && (
|
|
324
|
+
<>
|
|
325
|
+
<span className={`text-sm font-bold ${getStatusColor(response.status)}`}>
|
|
326
|
+
{response.status} {response.statusText}
|
|
327
|
+
</span>
|
|
328
|
+
<span className="text-xs text-gray-400 flex items-center gap-1">
|
|
329
|
+
<Clock size={11} />{response.responseTime}ms
|
|
330
|
+
</span>
|
|
331
|
+
</>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{error && (
|
|
336
|
+
<div className="p-3 m-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-700 dark:text-red-400 text-sm">
|
|
337
|
+
{error}
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
|
|
341
|
+
{response ? (
|
|
342
|
+
<pre className="flex-1 overflow-auto p-4 text-xs font-mono text-gray-800 dark:text-gray-200 scrollbar-thin">
|
|
343
|
+
{typeof response.data === 'object'
|
|
344
|
+
? JSON.stringify(response.data, null, 2)
|
|
345
|
+
: String(response.data)}
|
|
346
|
+
</pre>
|
|
347
|
+
) : (
|
|
348
|
+
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
|
|
349
|
+
{loading ? (
|
|
350
|
+
<div className="flex items-center gap-2">
|
|
351
|
+
<Clock className="animate-spin" size={20} />
|
|
352
|
+
Sending request...
|
|
353
|
+
</div>
|
|
354
|
+
) : (
|
|
355
|
+
'Send a request to see the response'
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|