@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.
Files changed (65) hide show
  1. package/.eslintrc.json +17 -0
  2. package/.github/workflows/ci.yml +42 -0
  3. package/.github/workflows/docs.yml +49 -0
  4. package/.github/workflows/publish.yml +49 -0
  5. package/README.md +117 -0
  6. package/bin/dev-assist.js +4 -0
  7. package/dev-assist.config.js +10 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.d.ts.map +1 -0
  10. package/dist/cli/index.js +133 -0
  11. package/dist/cli/wizard.d.ts +5 -0
  12. package/dist/cli/wizard.d.ts.map +1 -0
  13. package/dist/cli/wizard.js +66 -0
  14. package/dist/config.d.ts +3 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +62 -0
  17. package/dist/generators/docsGenerator.d.ts +15 -0
  18. package/dist/generators/docsGenerator.d.ts.map +1 -0
  19. package/dist/generators/docsGenerator.js +186 -0
  20. package/dist/generators/testGenerator.d.ts +12 -0
  21. package/dist/generators/testGenerator.d.ts.map +1 -0
  22. package/dist/generators/testGenerator.js +185 -0
  23. package/dist/parser/astParser.d.ts +7 -0
  24. package/dist/parser/astParser.d.ts.map +1 -0
  25. package/dist/parser/astParser.js +194 -0
  26. package/dist/server/index.d.ts +5 -0
  27. package/dist/server/index.d.ts.map +1 -0
  28. package/dist/server/index.js +247 -0
  29. package/dist/shared/types.d.ts +77 -0
  30. package/dist/shared/types.d.ts.map +1 -0
  31. package/dist/shared/types.js +3 -0
  32. package/docs/_config.yml +22 -0
  33. package/docs/api-reference.md +173 -0
  34. package/docs/architecture.md +90 -0
  35. package/docs/configuration.md +52 -0
  36. package/docs/contributing.md +101 -0
  37. package/docs/index.md +50 -0
  38. package/docs/installation.md +95 -0
  39. package/docs/usage.md +107 -0
  40. package/package.json +58 -0
  41. package/src/cli/index.ts +108 -0
  42. package/src/cli/wizard.ts +63 -0
  43. package/src/config.ts +29 -0
  44. package/src/generators/docsGenerator.ts +192 -0
  45. package/src/generators/testGenerator.ts +174 -0
  46. package/src/parser/astParser.ts +172 -0
  47. package/src/server/index.ts +238 -0
  48. package/src/shared/types.ts +83 -0
  49. package/tsconfig.build.json +8 -0
  50. package/tsconfig.json +19 -0
  51. package/ui/index.html +13 -0
  52. package/ui/package-lock.json +3086 -0
  53. package/ui/package.json +31 -0
  54. package/ui/postcss.config.js +6 -0
  55. package/ui/src/App.tsx +36 -0
  56. package/ui/src/components/ApiDocsTab.tsx +184 -0
  57. package/ui/src/components/ApiTestingTab.tsx +363 -0
  58. package/ui/src/components/Dashboard.tsx +128 -0
  59. package/ui/src/components/Layout.tsx +76 -0
  60. package/ui/src/components/TestsTab.tsx +149 -0
  61. package/ui/src/main.tsx +10 -0
  62. package/ui/src/styles/index.css +41 -0
  63. package/ui/tailwind.config.js +20 -0
  64. package/ui/tsconfig.json +19 -0
  65. package/ui/vite.config.ts +19 -0
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
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
+ }