4bnode 4.0.0 → 4.0.3

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 (39) hide show
  1. package/README.md +10 -0
  2. package/add-login.js +2 -2
  3. package/add-serialport.js +1 -1
  4. package/index.js +60 -6
  5. package/lib/indexFile.js +60 -13
  6. package/lib/ui.js +1 -0
  7. package/package.json +1 -1
  8. package/skeleton/.4bnode/assets/js/App.js +115 -0
  9. package/skeleton/.4bnode/assets/js/Root.js +44 -0
  10. package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
  11. package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
  12. package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
  13. package/skeleton/.4bnode/assets/js/context.js +7 -0
  14. package/skeleton/.4bnode/assets/js/lib.js +83 -0
  15. package/skeleton/.4bnode/assets/js/main.js +5 -0
  16. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
  17. package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  18. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
  19. package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
  20. package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  21. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
  22. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
  23. package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  24. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  25. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
  26. package/skeleton/{dev-api.js → .4bnode/dev-api.js} +259 -114
  27. package/skeleton/.4bnode/ui.html +1556 -0
  28. package/skeleton/index.js +62 -16
  29. package/skeleton/package.json +1 -1
  30. package/skeleton/public/index.html +290 -7586
  31. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Bold.woff2 +0 -0
  32. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Light.woff2 +0 -0
  33. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Medium.woff2 +0 -0
  34. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Regular.woff2 +0 -0
  35. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-SemiBold.woff2 +0 -0
  36. /package/skeleton/{public → .4bnode/assets}/images/mongo.png +0 -0
  37. /package/skeleton/{public → .4bnode/assets}/images/mysql.png +0 -0
  38. /package/skeleton/{public → .4bnode/assets}/images/postgresql.png +0 -0
  39. /package/skeleton/{public → .4bnode/assets}/images/sqlite.png +0 -0
@@ -0,0 +1,1594 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { motion } from "framer-motion";
3
+ import { io as socketIoConnect } from "socket.io-client";
4
+ import { LuChevronLeft, LuCircleDot, LuCopy, LuImport, LuLoader, LuPencil, LuPlugZap, LuPlus, LuRadio, LuSave, LuSend, LuShare2, LuTrash2, LuWifi, LuWifiOff } from "react-icons/lu";
5
+ import { html, api, Icons } from "../lib.js";
6
+ import { useApp } from "../context.js";
7
+ import { Modal } from "../components/Modal.js";
8
+
9
+ function SocketIOTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSaveLog }) {
10
+ const { toast } = useApp();
11
+ const [sioUrl, setSioUrl] = useState('http://localhost:3000');
12
+ const [sioConnected, setSioConnected] = useState(false);
13
+ const [sioSocket, setSioSocket] = useState(null);
14
+ const [sioEvent, setSioEvent] = useState('');
15
+ const [sioData, setSioData] = useState('');
16
+ const [sioListen, setSioListen] = useState('');
17
+ const [sioListeners, setSioListeners] = useState([]);
18
+ const [sioLog, setSioLog] = useState([]);
19
+ const logRef = useRef(null);
20
+
21
+ function addLog(dir, msg) {
22
+ const ts = new Date().toLocaleTimeString();
23
+ setSioLog(prev => [...prev, { dir, msg, ts, id: Date.now() + Math.random() }]);
24
+ }
25
+
26
+ useEffect(() => {
27
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
28
+ }, [sioLog]);
29
+
30
+ useEffect(() => {
31
+ return () => { if (sioSocket) sioSocket.disconnect(); };
32
+ }, []);
33
+
34
+ function connect() {
35
+ if (sioSocket) { sioSocket.disconnect(); setSioSocket(null); }
36
+ try {
37
+ const socket = socketIoConnect(sioUrl, { transports: ['websocket', 'polling'] });
38
+ socket.on('connect', () => {
39
+ setSioConnected(true);
40
+ addLog('system', 'Connected (id: ' + socket.id + ')');
41
+ });
42
+ socket.on('disconnect', (reason) => {
43
+ setSioConnected(false);
44
+ addLog('system', 'Disconnected: ' + reason);
45
+ });
46
+ socket.on('connect_error', (err) => {
47
+ addLog('system', 'Connection error: ' + err.message);
48
+ });
49
+ setSioSocket(socket);
50
+ } catch (err) {
51
+ addLog('system', 'Error: ' + err.message);
52
+ }
53
+ }
54
+
55
+ function disconnect() {
56
+ if (sioSocket) {
57
+ sioSocket.disconnect();
58
+ setSioSocket(null);
59
+ setSioConnected(false);
60
+ }
61
+ }
62
+
63
+ function emitEvent() {
64
+ if (!sioSocket || !sioEvent.trim()) return;
65
+ let parsed = sioData.trim();
66
+ try { parsed = JSON.parse(parsed); } catch {}
67
+ sioSocket.emit(sioEvent, parsed);
68
+ addLog('sent', sioEvent + ': ' + (typeof parsed === 'object' ? JSON.stringify(parsed) : parsed));
69
+ setSioData('');
70
+ }
71
+
72
+ function addListener() {
73
+ if (!sioSocket || !sioListen.trim() || sioListeners.includes(sioListen.trim())) return;
74
+ const ev = sioListen.trim();
75
+ sioSocket.on(ev, (data) => {
76
+ const display = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data);
77
+ addLog('received', ev + ': ' + display);
78
+ });
79
+ setSioListeners(prev => [...prev, ev]);
80
+ addLog('system', 'Listening on "' + ev + '"');
81
+ setSioListen('');
82
+ }
83
+
84
+ function removeListener(ev) {
85
+ if (sioSocket) sioSocket.off(ev);
86
+ setSioListeners(prev => prev.filter(e => e !== ev));
87
+ }
88
+
89
+ useEffect(() => {
90
+ if (loadedTest && loadedTest.type === 'socketio') {
91
+ setSioUrl(loadedTest.url || 'http://localhost:3000');
92
+ if (loadedTest.event) setSioEvent(loadedTest.event);
93
+ if (loadedTest.data) setSioData(loadedTest.data);
94
+ }
95
+ }, [loadedTest?.id]);
96
+
97
+ function saveSession() {
98
+ const name = prompt('Name this Socket.IO test:');
99
+ if (!name?.trim()) return;
100
+ onSaveTest({
101
+ name: name.trim(),
102
+ type: 'socketio',
103
+ url: sioUrl,
104
+ event: sioEvent,
105
+ data: sioData,
106
+ listeners: [...sioListeners],
107
+ });
108
+ }
109
+
110
+ function saveLogEntry(entry) {
111
+ if (!selectedSaved || !onSaveLog) return;
112
+ onSaveLog({
113
+ dir: entry.dir,
114
+ label: (entry.dir === 'sent' ? '↑ EMIT ' : entry.dir === 'received' ? '↓ EVENT ' : '● SYS ') + entry.msg.substring(0, 80),
115
+ body: entry.msg,
116
+ time: 0,
117
+ });
118
+ }
119
+
120
+ return html`<div>
121
+ <!-- Connection bar -->
122
+ <div className="api-tester-bar">
123
+ <input className="api-tester-url" placeholder="http://localhost:3000" value=${sioUrl}
124
+ onInput=${(e) => setSioUrl(e.target.value)}
125
+ onKeyDown=${(e) => { if (e.key === 'Enter') connect(); }} />
126
+ ${!sioConnected
127
+ ? html`<button className="api-tester-send" onClick=${connect}>
128
+ <${LuPlugZap} size=${14} /> Connect
129
+ </button>`
130
+ : html`<button className="api-tester-send" onClick=${disconnect} style=${{ background: 'var(--danger)' }}>
131
+ <${LuWifiOff} size=${14} /> Disconnect
132
+ </button>`}
133
+ ${!isSaved && onSaveTest && html`<button className="api-tester-send" onClick=${saveSession}
134
+ style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
135
+ <${LuSave} size=${14} /> Save
136
+ </button>`}
137
+ </div>
138
+
139
+ <div style=${{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
140
+ <!-- Emit -->
141
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
142
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
143
+ Emit Event
144
+ </div>
145
+ <div style=${{ padding: '12px' }}>
146
+ <div className="flex gap-2 mb-3">
147
+ <input className="form-input" style=${{ flex: 1, fontSize: '12px' }} placeholder="Event name"
148
+ value=${sioEvent} onInput=${(e) => setSioEvent(e.target.value)}
149
+ onKeyDown=${(e) => { if (e.key === 'Enter') emitEvent(); }} />
150
+ <button className="btn btn-sm btn-primary" onClick=${emitEvent}
151
+ disabled=${!sioConnected || !sioEvent.trim()}>
152
+ <${LuSend} size=${12} /> Emit
153
+ </button>
154
+ </div>
155
+ <textarea className="api-tester-body-editor" style=${{ minHeight: '80px', fontSize: '11px' }}
156
+ placeholder='{"key": "value"} or plain text'
157
+ value=${sioData} onInput=${(e) => setSioData(e.target.value)} spellcheck=${false} />
158
+ </div>
159
+ </div>
160
+
161
+ <!-- Listeners -->
162
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
163
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
164
+ Event Listeners
165
+ </div>
166
+ <div style=${{ padding: '12px' }}>
167
+ <div className="flex gap-2 mb-3">
168
+ <input className="form-input" style=${{ flex: 1, fontSize: '12px' }} placeholder="Event name to listen"
169
+ value=${sioListen} onInput=${(e) => setSioListen(e.target.value)}
170
+ onKeyDown=${(e) => { if (e.key === 'Enter') addListener(); }} />
171
+ <button className="btn btn-sm btn-ghost" onClick=${addListener}
172
+ disabled=${!sioConnected || !sioListen.trim()}>
173
+ <${LuPlus} size=${12} /> Listen
174
+ </button>
175
+ </div>
176
+ ${sioListeners.length === 0 && html`<div style=${{ fontSize: '12px', color: 'var(--text-muted)', padding: '8px 0' }}>No listeners yet</div>`}
177
+ <div className="flex gap-1" style=${{ flexWrap: 'wrap' }}>
178
+ ${sioListeners.map(ev => html`<span key=${ev} style=${{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '3px 8px', borderRadius: '4px', background: 'rgba(52,211,153,0.1)', fontSize: '11px', color: 'var(--success)' }}>
179
+ <${LuCircleDot} size=${10} /> ${ev}
180
+ <button style=${{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '0 2px', fontSize: '12px' }}
181
+ onClick=${() => removeListener(ev)}>×</button>
182
+ </span>`)}
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <!-- Log -->
189
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
190
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
191
+ <div className="flex items-center gap-3">
192
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
193
+ Event Log
194
+ </span>
195
+ <span className="ws-connected">
196
+ <span className=${`dot ${sioConnected ? 'on' : 'off'}`} />
197
+ ${sioConnected ? 'Connected' : 'Disconnected'}
198
+ </span>
199
+ </div>
200
+ <button className="btn btn-ghost btn-sm" onClick=${() => setSioLog([])} style=${{ fontSize: '11px' }}>Clear</button>
201
+ </div>
202
+ <div className="ws-log" ref=${logRef}>
203
+ ${sioLog.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>Connect to start logging events</div>`}
204
+ ${sioLog.map(e => html`<div key=${e.id} className="ws-log-entry">
205
+ <span className=${`dir ${e.dir}`}>${e.dir === 'sent' ? '↑ EMIT' : e.dir === 'received' ? '↓ EVENT' : '● SYS'}</span>
206
+ <span className="msg">${e.msg}</span>
207
+ <span className="ts">${e.ts}</span>
208
+ ${selectedSaved && onSaveLog && html`<button className="btn btn-ghost" style=${{ padding: '2px 4px', opacity: 0.4, marginLeft: '4px', flexShrink: 0 }}
209
+ onClick=${() => saveLogEntry(e)} title="Save log entry">
210
+ <${LuSave} size=${10} />
211
+ </button>`}
212
+ </div>`)}
213
+ </div>
214
+ </div>
215
+ </div>`;
216
+ }
217
+
218
+ // ── WebSocket Tester Component ──
219
+ function WebSocketTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSaveLog }) {
220
+ const { toast } = useApp();
221
+ const [wsUrl, setWsUrl] = useState('ws://localhost:3000');
222
+ const [wsConnected, setWsConnected] = useState(false);
223
+ const [wsRef, setWsRef] = useState(null);
224
+ const [wsMessage, setWsMessage] = useState('');
225
+ const [wsLog, setWsLog] = useState([]);
226
+ const logRef = useRef(null);
227
+
228
+ function addLog(dir, msg) {
229
+ const ts = new Date().toLocaleTimeString();
230
+ setWsLog(prev => [...prev, { dir, msg, ts, id: Date.now() + Math.random() }]);
231
+ }
232
+
233
+ useEffect(() => {
234
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
235
+ }, [wsLog]);
236
+
237
+ useEffect(() => {
238
+ return () => { if (wsRef) wsRef.close(); };
239
+ }, []);
240
+
241
+ function connect() {
242
+ if (wsRef) { wsRef.close(); setWsRef(null); }
243
+ try {
244
+ const ws = new WebSocket(wsUrl);
245
+ ws.onopen = () => {
246
+ setWsConnected(true);
247
+ addLog('system', 'Connected to ' + wsUrl);
248
+ };
249
+ ws.onclose = (e) => {
250
+ setWsConnected(false);
251
+ addLog('system', 'Disconnected (code: ' + e.code + ', reason: ' + (e.reason || 'none') + ')');
252
+ };
253
+ ws.onerror = () => {
254
+ addLog('system', 'Connection error');
255
+ };
256
+ ws.onmessage = (e) => {
257
+ let display = e.data;
258
+ try {
259
+ const parsed = JSON.parse(e.data);
260
+ display = JSON.stringify(parsed, null, 2);
261
+ } catch {}
262
+ addLog('received', display);
263
+ };
264
+ setWsRef(ws);
265
+ } catch (err) {
266
+ addLog('system', 'Error: ' + err.message);
267
+ }
268
+ }
269
+
270
+ function disconnect() {
271
+ if (wsRef) {
272
+ wsRef.close();
273
+ setWsRef(null);
274
+ setWsConnected(false);
275
+ }
276
+ }
277
+
278
+ function sendMessage() {
279
+ if (!wsRef || !wsMessage.trim()) return;
280
+ wsRef.send(wsMessage);
281
+ addLog('sent', wsMessage);
282
+ setWsMessage('');
283
+ }
284
+
285
+ useEffect(() => {
286
+ if (loadedTest && loadedTest.type === 'websocket') {
287
+ setWsUrl(loadedTest.url || 'ws://localhost:3000');
288
+ if (loadedTest.message) setWsMessage(loadedTest.message);
289
+ }
290
+ }, [loadedTest?.id]);
291
+
292
+ function saveSession() {
293
+ const name = prompt('Name this WebSocket test:');
294
+ if (!name?.trim()) return;
295
+ onSaveTest({
296
+ name: name.trim(),
297
+ type: 'websocket',
298
+ url: wsUrl,
299
+ message: wsMessage,
300
+ });
301
+ }
302
+
303
+ function saveLogEntry(entry) {
304
+ if (!selectedSaved || !onSaveLog) return;
305
+ onSaveLog({
306
+ dir: entry.dir,
307
+ label: (entry.dir === 'sent' ? '↑ SENT ' : entry.dir === 'received' ? '↓ RECV ' : '● SYS ') + entry.msg.substring(0, 80),
308
+ body: entry.msg,
309
+ time: 0,
310
+ });
311
+ }
312
+
313
+ return html`<div>
314
+ <!-- Connection bar -->
315
+ <div className="api-tester-bar">
316
+ <input className="api-tester-url" placeholder="ws://localhost:3000" value=${wsUrl}
317
+ onInput=${(e) => setWsUrl(e.target.value)}
318
+ onKeyDown=${(e) => { if (e.key === 'Enter') connect(); }} />
319
+ ${!wsConnected
320
+ ? html`<button className="api-tester-send" onClick=${connect}>
321
+ <${LuPlugZap} size=${14} /> Connect
322
+ </button>`
323
+ : html`<button className="api-tester-send" onClick=${disconnect} style=${{ background: 'var(--danger)' }}>
324
+ <${LuWifiOff} size=${14} /> Disconnect
325
+ </button>`}
326
+ ${!isSaved && onSaveTest && html`<button className="api-tester-send" onClick=${saveSession}
327
+ style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
328
+ <${LuSave} size=${14} /> Save
329
+ </button>`}
330
+ </div>
331
+
332
+ <!-- Send message -->
333
+ <div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
334
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
335
+ Send Message
336
+ </div>
337
+ <div style=${{ padding: '12px', display: 'flex', gap: '8px' }}>
338
+ <textarea className="api-tester-body-editor" style=${{ minHeight: '60px', fontSize: '12px', flex: 1 }}
339
+ placeholder='{"type": "ping"} or plain text'
340
+ value=${wsMessage} onInput=${(e) => setWsMessage(e.target.value)}
341
+ onKeyDown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
342
+ spellcheck=${false} />
343
+ </div>
344
+ <div style=${{ padding: '0 12px 12px', display: 'flex', justifyContent: 'flex-end' }}>
345
+ <button className="btn btn-sm btn-primary" onClick=${sendMessage}
346
+ disabled=${!wsConnected || !wsMessage.trim()}>
347
+ <${LuSend} size=${12} /> Send
348
+ </button>
349
+ </div>
350
+ </div>
351
+
352
+ <!-- Log -->
353
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
354
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
355
+ <div className="flex items-center gap-3">
356
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
357
+ Message Log
358
+ </span>
359
+ <span className="ws-connected">
360
+ <span className=${`dot ${wsConnected ? 'on' : 'off'}`} />
361
+ ${wsConnected ? 'Connected' : 'Disconnected'}
362
+ </span>
363
+ </div>
364
+ <button className="btn btn-ghost btn-sm" onClick=${() => setWsLog([])} style=${{ fontSize: '11px' }}>Clear</button>
365
+ </div>
366
+ <div className="ws-log" ref=${logRef}>
367
+ ${wsLog.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>Connect to start logging messages</div>`}
368
+ ${wsLog.map(e => html`<div key=${e.id} className="ws-log-entry">
369
+ <span className=${`dir ${e.dir}`}>${e.dir === 'sent' ? '↑ SENT' : e.dir === 'received' ? '↓ RECV' : '● SYS'}</span>
370
+ <span className="msg">${e.msg}</span>
371
+ <span className="ts">${e.ts}</span>
372
+ ${selectedSaved && onSaveLog && html`<button className="btn btn-ghost" style=${{ padding: '2px 4px', opacity: 0.4, marginLeft: '4px', flexShrink: 0 }}
373
+ onClick=${() => saveLogEntry(e)} title="Save log entry">
374
+ <${LuSave} size=${10} />
375
+ </button>`}
376
+ </div>`)}
377
+ </div>
378
+ </div>
379
+ </div>`;
380
+ }
381
+
382
+ export function SavedResponseDetail({ resp, parentTest, onBack, onUpdate }) {
383
+ const isSocketResp = resp.dir;
384
+ const [editLabel, setEditLabel] = useState(resp.label);
385
+ const [editReqBody, setEditReqBody] = useState(() => {
386
+ if (!resp.requestBody?.trim()) return '';
387
+ try { return JSON.stringify(JSON.parse(resp.requestBody), null, 2); } catch { return resp.requestBody; }
388
+ });
389
+ const [editRespBody, setEditRespBody] = useState(() => {
390
+ if (!resp.body) return '';
391
+ try { return JSON.stringify(JSON.parse(resp.body), null, 2); } catch { return resp.body; }
392
+ });
393
+ const [editing, setEditing] = useState(false);
394
+
395
+ const rStatusClass = 's' + Math.floor((resp.status || 0) / 100) + 'xx';
396
+ const hasChanges = editLabel !== resp.label ||
397
+ editReqBody !== ((() => { try { return JSON.stringify(JSON.parse(resp.requestBody || ''), null, 2); } catch { return resp.requestBody || ''; } })()) ||
398
+ editRespBody !== ((() => { try { return JSON.stringify(JSON.parse(resp.body || ''), null, 2); } catch { return resp.body || ''; } })());
399
+
400
+ function save() {
401
+ onUpdate({
402
+ label: editLabel,
403
+ requestBody: editReqBody,
404
+ body: editRespBody,
405
+ });
406
+ setEditing(false);
407
+ }
408
+
409
+ return html`<div>
410
+ <div className="flex items-center gap-2 mb-3">
411
+ <button className="btn btn-ghost btn-sm" onClick=${onBack} style=${{ fontSize: '11px' }}>
412
+ <${LuChevronLeft} size=${14} /> Back
413
+ </button>
414
+ ${!editing
415
+ ? html`<span style=${{ fontSize: '13px', fontWeight: 600, flex: 1 }}>${resp.label}</span>`
416
+ : html`<input className="form-input" value=${editLabel} onInput=${(e) => setEditLabel(e.target.value)}
417
+ style=${{ fontSize: '13px', fontWeight: 600, flex: 1, padding: '4px 8px' }} />`}
418
+ ${!editing && html`<button className="btn btn-ghost btn-sm" onClick=${() => setEditing(true)} style=${{ fontSize: '11px' }}>
419
+ <${LuPencil} size=${12} /> Edit
420
+ </button>`}
421
+ ${editing && html`<div className="flex gap-2">
422
+ <button className="btn btn-ghost btn-sm" onClick=${() => setEditing(false)} style=${{ fontSize: '11px' }}>Cancel</button>
423
+ <button className="btn btn-sm btn-primary" onClick=${save} disabled=${!hasChanges} style=${{ fontSize: '11px' }}>
424
+ <${LuSave} size=${12} /> Update
425
+ </button>
426
+ </div>`}
427
+ </div>
428
+
429
+ ${!isSocketResp && html`<div>
430
+ <div className="card mb-4" style=${{ padding: 0, overflow: 'hidden' }}>
431
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
432
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Request</span>
433
+ <span className=${`route-method ${(parentTest.method || 'GET').toLowerCase()}`} style=${{ fontSize: '10px', padding: '1px 6px' }}>${parentTest.method || 'GET'}</span>
434
+ <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)' }}>${parentTest.url}</span>
435
+ </div>
436
+ ${(editReqBody?.trim() || editing) && html`<div style=${{ padding: editing ? '0' : '10px 16px' }}>
437
+ ${!editing && html`<div>
438
+ <div style=${{ padding: '0 0 6px', fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px', color: 'var(--text-muted)' }}>Body Sent</div>
439
+ <pre style=${{ margin: 0, fontFamily: "'SF Mono', monospace", fontSize: '12px', lineHeight: '1.5', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>${editReqBody}</pre>
440
+ </div>`}
441
+ ${editing && html`<textarea className="api-tester-body-editor" value=${editReqBody}
442
+ onInput=${(e) => setEditReqBody(e.target.value)} spellcheck=${false}
443
+ style=${{ borderRadius: 0, border: 'none', borderTop: '1px solid var(--border)', minHeight: '100px' }} />`}
444
+ </div>`}
445
+ </div>
446
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
447
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
448
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Response</span>
449
+ <span className=${`api-tester-status ${rStatusClass}`}>${resp.status} ${resp.statusText}</span>
450
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>${resp.time}ms</span>
451
+ </div>
452
+ ${!editing && html`<div className="api-tester-response-body">${editRespBody || 'No response body'}</div>`}
453
+ ${editing && html`<textarea className="api-tester-body-editor" value=${editRespBody}
454
+ onInput=${(e) => setEditRespBody(e.target.value)} spellcheck=${false}
455
+ style=${{ borderRadius: 0, border: 'none', minHeight: '140px' }} />`}
456
+ </div>
457
+ </div>`}
458
+
459
+ ${isSocketResp && html`<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
460
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
461
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
462
+ ${parentTest.type === 'socketio' ? 'Socket.IO' : 'WebSocket'} Log Entry
463
+ </span>
464
+ <span style=${{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', fontWeight: 700,
465
+ background: resp.dir === 'received' ? 'rgba(52,211,153,0.15)' : resp.dir === 'sent' ? 'rgba(245,158,11,0.15)' : 'rgba(148,163,184,0.15)',
466
+ color: resp.dir === 'received' ? '#34d399' : resp.dir === 'sent' ? '#f59e0b' : '#94a3b8' }}>
467
+ ${resp.dir === 'received' ? '↓ Received' : resp.dir === 'sent' ? '↑ Sent' : '● System'}
468
+ </span>
469
+ </div>
470
+ ${!editing && html`<div className="api-tester-response-body">${editRespBody}</div>`}
471
+ ${editing && html`<textarea className="api-tester-body-editor" value=${editRespBody}
472
+ onInput=${(e) => setEditRespBody(e.target.value)} spellcheck=${false}
473
+ style=${{ borderRadius: 0, border: 'none', minHeight: '140px' }} />`}
474
+ </div>`}
475
+ </div>`;
476
+ }
477
+
478
+ export function ApiTesterPage() {
479
+ const { data, toast, showConfirm } = useApp();
480
+ const [testerMode, setTesterMode] = useState('rest');
481
+ const [method, setMethod] = useState('GET');
482
+ const [url, setUrl] = useState('');
483
+ const [headers, setHeaders] = useState([
484
+ { key: 'Content-Type', value: 'application/json', id: 1 },
485
+ ]);
486
+ const [body, setBody] = useState('');
487
+ const [response, setResponse] = useState(null);
488
+ const [loading, setLoading] = useState(false);
489
+ const [activeTab, setActiveTab] = useState('body');
490
+ const [params, setParams] = useState([]);
491
+ const [savedTests, setSavedTests] = useState([]);
492
+ const [selectedSaved, setSelectedSaved] = useState(null);
493
+ const [showImport, setShowImport] = useState(false);
494
+ const [importRoutes, setImportRoutes] = useState([]);
495
+ const [importLoading, setImportLoading] = useState(false);
496
+ const [bodyError, setBodyError] = useState(null);
497
+ const [bodyType, setBodyType] = useState('json');
498
+ const [formFields, setFormFields] = useState([]);
499
+ const [responseTab, setResponseTab] = useState('body');
500
+ const [urlParams, setUrlParams] = useState([]);
501
+ const [savedResponses, setSavedResponses] = useState({}); // { testId: [{status, label, body, requestBody}] }
502
+ const [selectedResponse, setSelectedResponse] = useState(null); // { testId, respId }
503
+ const [expandedTests, setExpandedTests] = useState({});
504
+ const [routeEndpoints, setRouteEndpoints] = useState([]);
505
+ const [lastSharedAt, setLastSharedAt] = useState(null);
506
+ const [sharedSnapshot, setSharedSnapshot] = useState(null);
507
+
508
+ // Load saved tests and responses from server
509
+ useEffect(() => {
510
+ api('/api-tester/data').then(data => {
511
+ if (data.tests) setSavedTests(data.tests);
512
+ if (data.responses) setSavedResponses(data.responses);
513
+ if (data.lastSharedAt) { setLastSharedAt(data.lastSharedAt); setSharedSnapshot(data.sharedSnapshot || null); }
514
+ }).catch(() => {});
515
+ }, []);
516
+
517
+ // Load all route endpoints on mount for auto-matching
518
+ useEffect(() => {
519
+ api('/api-tester/routes').then(routes => {
520
+ const eps = [];
521
+ (routes || []).forEach(r => {
522
+ (r.endpoints || []).forEach(ep => {
523
+ eps.push({ ...ep, routeName: r.name, hasAuth: r.hasAuth });
524
+ });
525
+ });
526
+ setRouteEndpoints(eps);
527
+ }).catch(() => {});
528
+ }, []);
529
+
530
+ // Find matching endpoint based on current URL and method
531
+ function findMatchingEndpoint() {
532
+ if (!url.trim() || routeEndpoints.length === 0) return null;
533
+ try {
534
+ const urlObj = new URL(url.trim());
535
+ const pathname = urlObj.pathname;
536
+ return routeEndpoints.find(ep => {
537
+ // Match exact path or path with :id param
538
+ const epPath = ep.fullPath;
539
+ if (pathname === epPath) return ep.method === method;
540
+ // Check :id style params - /api/users/:id matches /api/users/123
541
+ const epParts = epPath.split('/');
542
+ const urlParts = pathname.split('/');
543
+ if (epParts.length !== urlParts.length) return false;
544
+ const matches = epParts.every((p, i) => p.startsWith(':') || p === urlParts[i]);
545
+ return matches && ep.method === method;
546
+ });
547
+ } catch { return null; }
548
+ }
549
+
550
+ const matchedEndpoint = findMatchingEndpoint();
551
+
552
+ // Load fields from server when route matches
553
+ const lastMatchRef = useRef(null);
554
+ const fieldsLoadedRef = useRef(false);
555
+ useEffect(() => {
556
+ if (!matchedEndpoint) { lastMatchRef.current = null; return; }
557
+ const matchKey = matchedEndpoint.fullPath + ':' + matchedEndpoint.method;
558
+ if (lastMatchRef.current === matchKey) return;
559
+ lastMatchRef.current = matchKey;
560
+ fieldsLoadedRef.current = false;
561
+
562
+ // Set URL params
563
+ if (matchedEndpoint.params?.length > 0) {
564
+ setUrlParams(matchedEndpoint.params.map((p, i) => ({ key: p, value: '', id: Date.now() + 200 + i })));
565
+ } else {
566
+ setUrlParams([]);
567
+ }
568
+
569
+ // Set query params
570
+ if (matchedEndpoint.queryFields?.length > 0) {
571
+ setParams(matchedEndpoint.queryFields.map((f, i) => ({ key: f.name, value: '', id: Date.now() + i })));
572
+ }
573
+
574
+ // Build fields: start from route-detected, then merge any saved custom fields
575
+ const routeKey = encodeURIComponent(matchKey);
576
+ const hasFiles = matchedEndpoint.fileFields?.length > 0;
577
+ const hasBodyFields = matchedEndpoint.bodyFields?.length > 0;
578
+
579
+ // Build route-detected fields
580
+ const routeFields = [
581
+ ...(matchedEndpoint.bodyFields || []).map((f, i) => ({ key: f.name, value: '', type: 'text', id: Date.now() + i, fromRoute: true })),
582
+ ...(matchedEndpoint.fileFields || []).map((f, i) => ({ key: f.name, value: '', type: 'file', id: Date.now() + 100 + i, fromRoute: true })),
583
+ ];
584
+
585
+ if (hasFiles) setBodyType('form-data');
586
+
587
+ // Load saved custom fields from server and merge
588
+ api('/api-tester/fields/' + routeKey).then(res => {
589
+ if (fieldsLoadedRef.current) return;
590
+ fieldsLoadedRef.current = true;
591
+
592
+ if (res.fields && res.fields.length > 0) {
593
+ // Get custom fields (not from route) that user added
594
+ const routeFieldKeys = routeFields.map(f => f.key);
595
+ const customFields = res.fields.filter(f => !routeFieldKeys.includes(f.key));
596
+ const merged = [...routeFields, ...customFields];
597
+ setFormFields(merged);
598
+ if (merged.some(f => f.type === 'file')) setBodyType('form-data');
599
+ } else if (hasFiles || hasBodyFields) {
600
+ setFormFields(hasFiles ? routeFields : []);
601
+ if (!hasFiles && hasBodyFields) {
602
+ const obj = {};
603
+ matchedEndpoint.bodyFields.forEach(f => {
604
+ obj[f.name] = f.type === 'Number' ? 0 : f.type === 'Boolean' ? false : f.type === 'Date' ? '2024-01-01' : '';
605
+ });
606
+ setBody(JSON.stringify(obj, null, 2));
607
+ setBodyError(null);
608
+ }
609
+ }
610
+ if (matchedEndpoint.bodyFields?.length > 0 || matchedEndpoint.fileFields?.length > 0) setActiveTab('body');
611
+ }).catch(() => {});
612
+ }, [url, method]);
613
+
614
+ // Check if collection changed since last share
615
+ const shareNeeded = lastSharedAt && sharedSnapshot !== null && sharedSnapshot !== JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
616
+
617
+ function persistTests(tests) {
618
+ setSavedTests(tests);
619
+ api('/api-tester/data', { method: 'PUT', body: { tests, responses: savedResponses } }).catch(() => {});
620
+ }
621
+
622
+ function persistResponses(resp) {
623
+ setSavedResponses(resp);
624
+ api('/api-tester/data', { method: 'PUT', body: { tests: savedTests, responses: resp } }).catch(() => {});
625
+ }
626
+
627
+ function saveResponse() {
628
+ if (!response || !selectedSaved) {
629
+ toast('Save the request first, then save responses', 'error');
630
+ return;
631
+ }
632
+ const label = prompt('Label this response (e.g., "Success", "Missing email"):', response.status + ' ' + response.statusText);
633
+ if (!label?.trim()) return;
634
+ const entry = {
635
+ id: Date.now(),
636
+ label: label.trim(),
637
+ status: response.status,
638
+ statusText: response.statusText,
639
+ body: response.body,
640
+ requestBody: body || '',
641
+ time: response.time,
642
+ };
643
+ const updated = { ...savedResponses };
644
+ if (!updated[selectedSaved]) updated[selectedSaved] = [];
645
+ updated[selectedSaved].push(entry);
646
+ persistResponses(updated);
647
+ setExpandedTests(prev => ({ ...prev, [selectedSaved]: true }));
648
+ toast('Response saved', 'success');
649
+ }
650
+
651
+ function deleteResponse(testId, respId) {
652
+ const updated = { ...savedResponses };
653
+ updated[testId] = (updated[testId] || []).filter(r => r.id !== respId);
654
+ if (updated[testId].length === 0) delete updated[testId];
655
+ persistResponses(updated);
656
+ }
657
+
658
+ function updateResponse(testId, respId, changes) {
659
+ const updated = { ...savedResponses };
660
+ updated[testId] = (updated[testId] || []).map(r =>
661
+ r.id === respId ? { ...r, ...changes } : r
662
+ );
663
+ persistResponses(updated);
664
+ toast('Response updated', 'success');
665
+ }
666
+
667
+ async function shareCollection() {
668
+ if (savedTests.length === 0) { toast('No saved requests to share', 'error'); return; }
669
+ try {
670
+ const result = await api('/api-tester/share-collection', {
671
+ method: 'POST',
672
+ body: { tests: savedTests, responses: savedResponses },
673
+ });
674
+ const shareUrl = window.location.origin + '/_dev/shared/' + result.id;
675
+ navigator.clipboard.writeText(shareUrl);
676
+ const now = Date.now();
677
+ setLastSharedAt(now);
678
+ const snap = JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
679
+ setSharedSnapshot(snap);
680
+ api('/api-tester/data', { method: 'PUT', body: { tests: savedTests, responses: savedResponses, lastSharedAt: now, sharedSnapshot: snap } }).catch(() => {});
681
+ toast('Share URL copied: ' + shareUrl, 'success');
682
+ } catch (err) {
683
+ toast(err.message, 'error');
684
+ }
685
+ }
686
+
687
+ async function sendRequest() {
688
+ if (!url.trim()) { toast('Enter a URL', 'error'); return; }
689
+
690
+ // Validate file fields before sending
691
+ if (['POST','PUT','PATCH'].includes(method) && (bodyType === 'form-data' || bodyType === 'x-www-form-urlencoded')) {
692
+ const missingFiles = formFields.filter(f => f.key?.trim() && f.type === 'file' && !f.fileData && !f.value);
693
+ if (missingFiles.length > 0) {
694
+ const names = missingFiles.map(f => f.key).join(', ');
695
+ const ok = await showConfirm('File field' + (missingFiles.length > 1 ? 's' : '') + ' "' + names + '" ' + (missingFiles.length > 1 ? 'are' : 'is') + ' empty. Send without ' + (missingFiles.length > 1 ? 'them' : 'it') + '?', { confirmLabel: 'Send Anyway' });
696
+ if (!ok) return;
697
+ }
698
+ }
699
+
700
+ // Validate URL params
701
+ const emptyParams = urlParams.filter(p => p.key && !p.value);
702
+ if (emptyParams.length > 0) {
703
+ const names = emptyParams.map(p => p.key).join(', ');
704
+ toast('Missing value for path variable: ' + names, 'error');
705
+ return;
706
+ }
707
+
708
+ setLoading(true);
709
+ setResponse(null);
710
+ setSelectedResponse(null);
711
+ try {
712
+ const hdrs = {};
713
+ headers.forEach(h => { if (h.key?.trim() && h.value?.trim()) hdrs[h.key] = h.value; });
714
+ const finalUrl = buildDisplayUrl();
715
+ // For base64 send-as, convert form field value into body JSON
716
+ let sendBody = undefined;
717
+ let sendBodyType = bodyType;
718
+ let sendFormFields = undefined;
719
+
720
+ if (['POST','PUT','PATCH'].includes(method)) {
721
+ if (bodyType === 'form-data' || bodyType === 'x-www-form-urlencoded') {
722
+ sendFormFields = formFields.filter(f => f.key?.trim()).map(f => {
723
+ if (f.sendAs === 'base64') {
724
+ return { key: f.key, value: f.value }; // base64 string as text value
725
+ }
726
+ if (f.sendAs === 'file' && f.fileData) {
727
+ return { key: f.key, fileData: f.fileData, fileName: f.fileName, fileType: f.fileType };
728
+ }
729
+ if (f.type === 'file' && !f.fileData && !f.value) {
730
+ return null; // Skip empty file fields
731
+ }
732
+ return { key: f.key, value: f.value || '' };
733
+ }).filter(Boolean);
734
+ } else if (bodyType === 'binary') {
735
+ sendBody = body; // base64 encoded
736
+ } else {
737
+ sendBody = body;
738
+ }
739
+ }
740
+
741
+ const result = await api('/api-tester/send', {
742
+ method: 'POST',
743
+ body: {
744
+ method,
745
+ url: finalUrl,
746
+ headers: hdrs,
747
+ body: sendBody,
748
+ bodyType: sendBodyType,
749
+ formFields: sendFormFields,
750
+ },
751
+ });
752
+ let parsed = null;
753
+ try { parsed = JSON.parse(result.body); } catch {}
754
+ setResponse({
755
+ status: result.status,
756
+ statusText: result.statusText,
757
+ headers: result.headers || {},
758
+ body: parsed ? JSON.stringify(parsed, null, 2) : result.body,
759
+ isJson: !!parsed,
760
+ time: result.time,
761
+ size: result.size,
762
+ });
763
+ } catch (err) {
764
+ setResponse({
765
+ status: 0, statusText: 'Error',
766
+ body: err.message, isJson: false,
767
+ time: 0, size: 0, headers: {},
768
+ });
769
+ }
770
+ setLoading(false);
771
+ }
772
+
773
+ function saveTest() {
774
+ const name = prompt('Name this request:');
775
+ if (!name?.trim()) return;
776
+ const test = {
777
+ id: Date.now(),
778
+ name: name.trim(),
779
+ type: 'rest',
780
+ method, url,
781
+ headers: headers.filter(h => h.key?.trim()),
782
+ params: params.filter(p => p.key?.trim()),
783
+ urlParams: urlParams.filter(p => p.key?.trim()),
784
+ body, bodyType,
785
+ formFields: formFields.filter(f => f.key?.trim()),
786
+ };
787
+ const updated = [...savedTests, test];
788
+ persistTests(updated);
789
+ setSelectedSaved(test.id);
790
+ toast('Request saved', 'success');
791
+ }
792
+
793
+ function handleSaveSocketTest(config) {
794
+ const test = { id: Date.now(), ...config };
795
+ const updated = [...savedTests, test];
796
+ persistTests(updated);
797
+ setSelectedSaved(test.id);
798
+ toast('Test saved', 'success');
799
+ }
800
+
801
+ function handleSaveLog(entry) {
802
+ if (!selectedSaved) return;
803
+ const respEntry = {
804
+ id: Date.now(),
805
+ label: entry.label,
806
+ status: entry.dir === 'received' ? 200 : entry.dir === 'sent' ? 201 : 100,
807
+ statusText: entry.dir === 'received' ? 'Received' : entry.dir === 'sent' ? 'Sent' : 'System',
808
+ body: entry.body,
809
+ requestBody: '',
810
+ time: entry.time || 0,
811
+ dir: entry.dir,
812
+ };
813
+ const updated = { ...savedResponses };
814
+ if (!updated[selectedSaved]) updated[selectedSaved] = [];
815
+ updated[selectedSaved].push(respEntry);
816
+ persistResponses(updated);
817
+ setExpandedTests(prev => ({ ...prev, [selectedSaved]: true }));
818
+ toast('Log entry saved', 'success');
819
+ }
820
+
821
+ function loadTest(test) {
822
+ if (test.type === 'socketio') {
823
+ setTesterMode('socketio');
824
+ } else if (test.type === 'websocket') {
825
+ setTesterMode('websocket');
826
+ } else {
827
+ setTesterMode('rest');
828
+ setMethod(test.method || 'GET');
829
+ setUrl(test.url || '');
830
+ setHeaders(test.headers?.length ? test.headers : [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
831
+ setParams(test.params?.length ? test.params : []);
832
+ setUrlParams(test.urlParams?.length ? test.urlParams : []);
833
+ setBody(test.body || '');
834
+ setBodyType(test.bodyType || 'json');
835
+ setFormFields(test.formFields?.length ? test.formFields : []);
836
+ if (['POST','PUT','PATCH'].includes(test.method)) setActiveTab('body');
837
+ else setActiveTab('params');
838
+ }
839
+ setSelectedSaved(test.id);
840
+ setSelectedResponse(null);
841
+ setResponse(null);
842
+ }
843
+
844
+ function toggleExpand(testId) {
845
+ setExpandedTests(prev => ({ ...prev, [testId]: !prev[testId] }));
846
+ }
847
+
848
+ function viewSavedResponse(testId, respId) {
849
+ setSelectedSaved(testId);
850
+ setSelectedResponse({ testId, respId });
851
+ setResponse(null);
852
+ const test = savedTests.find(t => t.id === testId);
853
+ if (test) {
854
+ if (test.type === 'socketio') { setTesterMode('socketio'); }
855
+ else if (test.type === 'websocket') { setTesterMode('websocket'); }
856
+ else {
857
+ setTesterMode('rest');
858
+ setMethod(test.method || 'GET');
859
+ setUrl(test.url || '');
860
+ setHeaders(test.headers?.length ? test.headers : [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
861
+ setParams(test.params?.length ? test.params : []);
862
+ // Load saved response's request body into body editor
863
+ const r = (savedResponses[testId] || []).find(x => x.id === respId);
864
+ if (r?.requestBody?.trim()) {
865
+ try { setBody(JSON.stringify(JSON.parse(r.requestBody), null, 2)); } catch { setBody(r.requestBody); }
866
+ } else {
867
+ setBody(test.body || '');
868
+ }
869
+ if (['POST','PUT','PATCH'].includes(test.method)) setActiveTab('body');
870
+ }
871
+ }
872
+ }
873
+
874
+ async function deleteTest(id) {
875
+ const test = savedTests.find(t => t.id === id);
876
+ const ok = await showConfirm('Delete "' + (test?.name || 'this request') + '"? This will also remove all saved responses.', { danger: true, confirmLabel: 'Delete' });
877
+ if (!ok) return;
878
+ const updated = savedTests.filter(t => t.id !== id);
879
+ persistTests(updated);
880
+ if (savedResponses[id]) {
881
+ const r = { ...savedResponses };
882
+ delete r[id];
883
+ persistResponses(r);
884
+ }
885
+ // Clear saved field config for this route
886
+ if (test?.url && test?.method) {
887
+ try {
888
+ const ep = matchedEndpoint || routeEndpoints.find(e => test.url.includes(e.fullPath) && e.method === test.method);
889
+ if (ep) {
890
+ const mk = encodeURIComponent(ep.fullPath + ':' + ep.method);
891
+ api('/api-tester/fields/' + mk, { method: 'PUT', body: { fields: [] } }).catch(() => {});
892
+ }
893
+ } catch {}
894
+ // Reset lastMatchRef so next time route is entered, it re-detects fresh
895
+ lastMatchRef.current = null;
896
+ fieldsLoadedRef.current = false;
897
+ }
898
+ if (selectedSaved === id) setSelectedSaved(null);
899
+ }
900
+
901
+ async function loadImportRoutes() {
902
+ setShowImport(true);
903
+ setImportLoading(true);
904
+ try {
905
+ const routes = await api('/api-tester/routes');
906
+ setImportRoutes(routes);
907
+ } catch (err) { toast(err.message, 'error'); }
908
+ setImportLoading(false);
909
+ }
910
+
911
+ function importEndpoint(route, ep) {
912
+ const port = window.location.port || '3000';
913
+ const origin = window.location.protocol + '//' + window.location.hostname + ':' + port;
914
+ setMethod(ep.method);
915
+ setUrl(origin + ep.fullPath);
916
+ setResponse(null);
917
+ setSelectedSaved(null);
918
+ setSelectedResponse(null);
919
+
920
+ // Set URL params (:id, etc.)
921
+ if (ep.params?.length > 0) {
922
+ setUrlParams(ep.params.map((p, i) => ({ key: p, value: '', id: Date.now() + 200 + i })));
923
+ } else {
924
+ setUrlParams([]);
925
+ }
926
+
927
+ // Set body fields for POST/PUT/PATCH
928
+ if (ep.bodyFields?.length > 0 && ['POST','PUT','PATCH'].includes(ep.method)) {
929
+ const bodyObj = {};
930
+ ep.bodyFields.forEach(f => {
931
+ bodyObj[f.name] = f.type === 'Number' ? 0 : f.type === 'Boolean' ? false : f.type === 'Date' ? '2024-01-01' : '';
932
+ });
933
+ setBody(JSON.stringify(bodyObj, null, 2));
934
+ setHeaders([{ key: 'Content-Type', value: 'application/json', id: 1 }]);
935
+ setActiveTab('body');
936
+ } else {
937
+ setBody('');
938
+ }
939
+
940
+ // Set query params for GET/DELETE
941
+ if (ep.queryFields?.length > 0 && ['GET','DELETE'].includes(ep.method)) {
942
+ setParams(ep.queryFields.map((f, i) => ({ key: f.name, value: '', id: Date.now() + i })));
943
+ setActiveTab('params');
944
+ } else if (['GET','DELETE'].includes(ep.method)) {
945
+ setParams([]);
946
+ setActiveTab('params');
947
+ } else {
948
+ setParams([]);
949
+ }
950
+
951
+ // Set auth header if route has auth
952
+ if (route.hasAuth) {
953
+ const hasAuthHeader = headers.some(h => h.key === 'Authorization');
954
+ if (!hasAuthHeader) {
955
+ setHeaders(prev => [...prev, { key: 'Authorization', value: 'Bearer ', id: Date.now() }]);
956
+ }
957
+ }
958
+
959
+ setShowImport(false);
960
+ toast(ep.method + ' ' + ep.fullPath + ' loaded', 'success');
961
+ }
962
+
963
+
964
+ function updateHeader(id, field, value) {
965
+ setHeaders(prev => prev.map(h => h.id === id ? { ...h, [field]: value } : h));
966
+ }
967
+
968
+ function addHeader() {
969
+ setHeaders(prev => [...prev, { key: '', value: '', id: Date.now() }]);
970
+ }
971
+
972
+ function removeHeader(id) {
973
+ setHeaders(prev => prev.filter(h => h.id !== id));
974
+ }
975
+
976
+ function updateParam(id, field, value) {
977
+ setParams(prev => prev.map(p => p.id === id ? { ...p, [field]: value } : p));
978
+ }
979
+
980
+ function addParam() {
981
+ setParams(prev => [...prev, { key: '', value: '', id: Date.now() }]);
982
+ }
983
+
984
+ function removeParam(id) {
985
+ setParams(prev => prev.filter(p => p.id !== id));
986
+ }
987
+
988
+ function addHeaderPreset(type) {
989
+ const presets = {
990
+ 'bearer': { key: 'Authorization', value: 'Bearer ' },
991
+ 'json': { key: 'Content-Type', value: 'application/json' },
992
+ 'form': { key: 'Content-Type', value: 'multipart/form-data' },
993
+ 'urlencoded': { key: 'Content-Type', value: 'application/x-www-form-urlencoded' },
994
+ 'accept': { key: 'Accept', value: 'application/json' },
995
+ };
996
+ const p = presets[type];
997
+ if (p) {
998
+ const exists = headers.find(h => h.key === p.key);
999
+ if (exists) {
1000
+ setHeaders(prev => prev.map(h => h.key === p.key ? { ...h, value: p.value } : h));
1001
+ } else {
1002
+ setHeaders(prev => [...prev, { ...p, id: Date.now() }]);
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ function saveFieldsToServer(fields) {
1008
+ if (!matchedEndpoint) return;
1009
+ const matchKey = matchedEndpoint.fullPath + ':' + matchedEndpoint.method;
1010
+ const routeKey = encodeURIComponent(matchKey);
1011
+ api('/api-tester/fields/' + routeKey, {
1012
+ method: 'PUT',
1013
+ body: { fields: fields.map(f => ({ key: f.key, value: f.value || '', type: f.type || 'text', id: f.id })) },
1014
+ }).catch(() => {});
1015
+ }
1016
+
1017
+ function addFormField() {
1018
+ setFormFields(prev => {
1019
+ const updated = [...prev, { key: '', value: '', type: 'text', id: Date.now() }];
1020
+ saveFieldsToServer(updated);
1021
+ return updated;
1022
+ });
1023
+ }
1024
+
1025
+ function updateFormField(id, field, value) {
1026
+ setFormFields(prev => {
1027
+ const updated = prev.map(f => f.id === id ? { ...f, [field]: value } : f);
1028
+ saveFieldsToServer(updated);
1029
+ return updated;
1030
+ });
1031
+ }
1032
+
1033
+ function removeFormField(id) {
1034
+ setFormFields(prev => {
1035
+ const updated = prev.filter(f => f.id !== id);
1036
+ saveFieldsToServer(updated);
1037
+ return updated;
1038
+ });
1039
+ }
1040
+
1041
+ function handleFileSelect(fieldId, file, sendAs) {
1042
+ if (!file) return;
1043
+ const reader = new FileReader();
1044
+ reader.onload = () => {
1045
+ const base64 = reader.result.split(',')[1];
1046
+ setFormFields(prev => prev.map(f => {
1047
+ if (f.id !== fieldId) return f;
1048
+ if (sendAs === 'base64') {
1049
+ return { ...f, value: base64, fileName: file.name, fileType: file.type, sendAs: 'base64', fileSize: file.size };
1050
+ }
1051
+ return { ...f, fileData: base64, fileName: file.name, fileType: file.type, sendAs: 'file', fileSize: file.size };
1052
+ }));
1053
+ };
1054
+ reader.readAsDataURL(file);
1055
+ }
1056
+
1057
+ function validateBody(val) {
1058
+ setBody(val);
1059
+ if (!val.trim()) { setBodyError(null); return; }
1060
+ try { JSON.parse(val); setBodyError(null); }
1061
+ catch (e) { setBodyError(e.message); }
1062
+ }
1063
+
1064
+ function formatBody() {
1065
+ try {
1066
+ const parsed = JSON.parse(body);
1067
+ setBody(JSON.stringify(parsed, null, 2));
1068
+ setBodyError(null);
1069
+ } catch {}
1070
+ }
1071
+
1072
+ const statusClass = response ? 's' + Math.floor(response.status / 100) + 'xx' : '';
1073
+ const hasBody = ['POST','PUT','PATCH'].includes(method);
1074
+
1075
+ // Build display URL with param values and query params filled in
1076
+ function buildDisplayUrl() {
1077
+ let u = url;
1078
+ urlParams.forEach(p => {
1079
+ if (p.key && p.value) u = u.replace(':' + p.key, p.value);
1080
+ });
1081
+ const qp = params.filter(p => p.key?.trim() && p.value?.trim());
1082
+ if (qp.length > 0) {
1083
+ const sep = u.includes('?') ? '&' : '?';
1084
+ u += sep + qp.map(p => encodeURIComponent(p.key) + '=' + encodeURIComponent(p.value)).join('&');
1085
+ }
1086
+ return u;
1087
+ }
1088
+ const displayUrl = buildDisplayUrl();
1089
+
1090
+ const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
1091
+ const methodColors = { GET: 'var(--success)', POST: '#f59e0b', PUT: '#3b82f6', PATCH: '#8b5cf6', DELETE: 'var(--danger)' };
1092
+
1093
+ const modeTabs = [
1094
+ { id: 'rest', label: 'REST API', icon: LuSend },
1095
+ { id: 'socketio', label: 'Socket.IO', icon: LuRadio },
1096
+ { id: 'websocket', label: 'WebSocket', icon: LuWifi },
1097
+ ];
1098
+
1099
+ const loadedSocketTest = savedTests.find(t => t.id === selectedSaved);
1100
+ const typeBadgeStyles = {
1101
+ socketio: { background: 'rgba(52,211,153,0.15)', color: '#34d399' },
1102
+ websocket: { background: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
1103
+ };
1104
+
1105
+ return html`<div>
1106
+ <div className="flex items-center justify-between mb-4">
1107
+ <div>
1108
+ <div className="card-title">API Tester</div>
1109
+ <div className="card-subtitle">Test REST APIs, Socket.IO, and WebSocket connections</div>
1110
+ </div>
1111
+ <div className="flex gap-2">
1112
+ ${testerMode === 'rest' && html`<button className="btn btn-ghost btn-sm" onClick=${loadImportRoutes}>
1113
+ <${LuImport} size=${14} /> Import Route
1114
+ </button>`}
1115
+ <button className=${`btn btn-sm ${shareNeeded ? 'btn-warning' : 'btn-primary'}`}
1116
+ onClick=${shareCollection} disabled=${savedTests.length === 0}
1117
+ style=${shareNeeded ? { animation: 'pulse 2s infinite', background: 'var(--warning)', color: '#000' } : {}}>
1118
+ <${LuShare2} size=${14} /> ${shareNeeded ? 'Update & Share' : lastSharedAt ? 'Re-share' : 'Share'}
1119
+ </button>
1120
+ </div>
1121
+ </div>
1122
+
1123
+ <div className="routes-layout">
1124
+ <!-- Left: Saved collection tree (always visible) -->
1125
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
1126
+ <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
1127
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Collection</span>
1128
+ </div>
1129
+ <div style=${{ maxHeight: '600px', overflowY: 'auto' }}>
1130
+ ${savedTests.length === 0 && html`<div style=${{ padding: '30px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>
1131
+ No saved requests yet
1132
+ </div>`}
1133
+ ${savedTests.map(test => {
1134
+ const resps = savedResponses[test.id] || [];
1135
+ const isExpanded = expandedTests[test.id];
1136
+ const isParentActive = selectedSaved === test.id && !selectedResponse;
1137
+ const testType = test.type || 'rest';
1138
+ return html`<div key=${test.id}>
1139
+ <div style=${{ padding: '8px 10px', cursor: 'pointer', borderBottom: '1px solid var(--border)',
1140
+ background: isParentActive ? 'var(--accent-glow)' : 'transparent',
1141
+ borderLeft: isParentActive ? '2px solid var(--accent)' : '2px solid transparent',
1142
+ transition: 'background 0.1s' }}
1143
+ onClick=${() => { loadTest(test); setSelectedResponse(null); if (resps.length > 0) setExpandedTests(prev => ({ ...prev, [test.id]: true })); }}>
1144
+ <div className="flex items-center gap-2">
1145
+ ${resps.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', width: '14px', textAlign: 'center', flexShrink: 0 }}>
1146
+ ${isExpanded ? '▼' : '▶'}
1147
+ </span>`}
1148
+ ${resps.length === 0 && html`<span style=${{ width: '14px', flexShrink: 0 }} />`}
1149
+ ${testType === 'rest' && html`<span className=${`route-method ${(test.method || 'GET').toLowerCase()}`} style=${{ fontSize: '9px', padding: '1px 5px' }}>${test.method || 'GET'}</span>`}
1150
+ ${testType === 'socketio' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace", ...typeBadgeStyles.socketio }}>SIO</span>`}
1151
+ ${testType === 'websocket' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace", ...typeBadgeStyles.websocket }}>WS</span>`}
1152
+ <span style=${{ fontSize: '12px', fontWeight: 500, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${test.name}</span>
1153
+ ${resps.length > 0 && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '8px', background: 'var(--accent-glow)', color: 'var(--accent-light)', fontWeight: 600 }}>${resps.length}</span>`}
1154
+ <button className="btn btn-ghost" style=${{ padding: '2px', opacity: 0.4 }}
1155
+ onClick=${(e) => { e.stopPropagation(); deleteTest(test.id); }}>
1156
+ <${LuTrash2} size=${11} />
1157
+ </button>
1158
+ </div>
1159
+ </div>
1160
+ ${isExpanded && resps.map(r => {
1161
+ const isRespActive = selectedResponse?.testId === test.id && selectedResponse?.respId === r.id;
1162
+ const isSocketChild = r.dir;
1163
+ return html`<div key=${r.id}
1164
+ style=${{ padding: '6px 10px 6px 40px', cursor: 'pointer', borderBottom: '1px solid var(--border)',
1165
+ background: isRespActive ? 'var(--accent-glow)' : 'transparent',
1166
+ borderLeft: isRespActive ? '2px solid var(--accent)' : '2px solid transparent',
1167
+ transition: 'background 0.1s' }}
1168
+ onClick=${() => viewSavedResponse(test.id, r.id)}>
1169
+ <div className="flex items-center gap-2">
1170
+ ${isSocketChild
1171
+ ? html`<span style=${{ fontSize: '10px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace",
1172
+ background: r.dir === 'received' ? 'rgba(52,211,153,0.15)' : r.dir === 'sent' ? 'rgba(245,158,11,0.15)' : 'rgba(148,163,184,0.15)',
1173
+ color: r.dir === 'received' ? '#34d399' : r.dir === 'sent' ? '#f59e0b' : '#94a3b8' }}>
1174
+ ${r.dir === 'received' ? '↓' : r.dir === 'sent' ? '↑' : '●'}
1175
+ </span>`
1176
+ : html`<span className=${`api-tester-status s${Math.floor(r.status / 100)}xx`} style=${{ fontSize: '10px', padding: '1px 5px' }}>
1177
+ ${r.status}
1178
+ </span>`}
1179
+ <span style=${{ fontSize: '11px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>${r.label}</span>
1180
+ <button className="btn btn-ghost" style=${{ padding: '2px', opacity: 0.3 }}
1181
+ onClick=${(e) => { e.stopPropagation(); deleteResponse(test.id, r.id); }}>
1182
+ <${LuTrash2} size=${10} />
1183
+ </button>
1184
+ </div>
1185
+ </div>`;
1186
+ })}
1187
+ </div>`;
1188
+ })}
1189
+ </div>
1190
+ </div>
1191
+
1192
+ <!-- Right: Mode tabs + tester content -->
1193
+ <div>
1194
+ <div className="api-tester-mode-tabs">
1195
+ ${modeTabs.map(t => html`<button key=${t.id}
1196
+ className=${`api-tester-mode-tab${testerMode === t.id ? ' active' : ''}`}
1197
+ onClick=${() => { setTesterMode(t.id); setSelectedResponse(null); }}>
1198
+ <${t.icon} size=${14} /> ${t.label}
1199
+ </button>`)}
1200
+ </div>
1201
+
1202
+ ${testerMode === 'socketio' && html`<${SocketIOTester}
1203
+ selectedSaved=${selectedSaved}
1204
+ isSaved=${!!(selectedSaved && savedTests.find(t => t.id === selectedSaved)?.type === 'socketio')}
1205
+ loadedTest=${loadedSocketTest}
1206
+ onSaveTest=${handleSaveSocketTest}
1207
+ onSaveLog=${handleSaveLog} />`}
1208
+
1209
+ ${testerMode === 'websocket' && html`<${WebSocketTester}
1210
+ selectedSaved=${selectedSaved}
1211
+ isSaved=${!!(selectedSaved && savedTests.find(t => t.id === selectedSaved)?.type === 'websocket')}
1212
+ loadedTest=${loadedSocketTest}
1213
+ onSaveTest=${handleSaveSocketTest}
1214
+ onSaveLog=${handleSaveLog} />`}
1215
+
1216
+ ${testerMode === 'rest' && html`<div>
1217
+ <!-- Request bar -->
1218
+ <div className="api-tester-bar">
1219
+ <select className="api-tester-method"
1220
+ value=${method}
1221
+ onChange=${(e) => setMethod(e.target.value)}
1222
+ style=${{ color: methodColors[method] || 'var(--text-primary)' }}>
1223
+ ${methods.map(m => html`<option key=${m} value=${m} style=${{ color: 'var(--text-primary)' }}>${m}</option>`)}
1224
+ </select>
1225
+ <input
1226
+ className="api-tester-url"
1227
+ type="text"
1228
+ placeholder="http://localhost:3000/api/users"
1229
+ value=${url}
1230
+ onInput=${(e) => setUrl(e.target.value)}
1231
+ onKeyDown=${(e) => { if (e.key === 'Enter') sendRequest(); }}
1232
+ />
1233
+ <button className="api-tester-send" onClick=${sendRequest} disabled=${loading || !url.trim()}>
1234
+ ${loading ? html`<${LuLoader} size=${14} className="spin" />` : html`<${LuSend} size=${14} />`}
1235
+ Send
1236
+ </button>
1237
+ ${!savedTests.some(t => t.type !== 'socketio' && t.type !== 'websocket' && t.method === method && t.url === url) && html`<button className="api-tester-send" onClick=${saveTest} disabled=${!url.trim()}
1238
+ style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
1239
+ <${LuSave} size=${14} /> Save
1240
+ </button>`}
1241
+ ${selectedResponse && (() => {
1242
+ const sr = (savedResponses[selectedResponse.testId] || []).find(x => x.id === selectedResponse.respId);
1243
+ if (!sr) return null;
1244
+ return html`<button className="api-tester-send" onClick=${() => {
1245
+ updateResponse(selectedResponse.testId, selectedResponse.respId, { requestBody: body });
1246
+ }} style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
1247
+ <${LuSave} size=${14} /> Update
1248
+ </button>`;
1249
+ })()}
1250
+ </div>
1251
+
1252
+ ${displayUrl !== url && html`<div style=${{
1253
+ padding: '6px 14px', fontSize: '11px', color: 'var(--text-muted)',
1254
+ fontFamily: "'SF Mono', monospace", background: 'rgba(124,58,237,0.04)',
1255
+ borderRadius: '6px', marginBottom: '8px', wordBreak: 'break-all',
1256
+ }}>${displayUrl}</div>`}
1257
+
1258
+ <!-- URL Params (:id, etc.) -->
1259
+ ${urlParams.length > 0 && html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1260
+ <div style=${{ padding: '8px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
1261
+ <span style=${{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Path Variables</span>
1262
+ </div>
1263
+ ${urlParams.map(p => html`<div key=${p.id} className="api-tester-kv-row">
1264
+ <input value=${p.key} disabled style=${{
1265
+ flex: '0 0 120px', opacity: 0.6, fontWeight: 600, fontFamily: "'SF Mono', monospace",
1266
+ color: 'var(--accent-light)', background: 'var(--bg-input)', fontSize: '12px',
1267
+ }} />
1268
+ <input placeholder=${'Enter ' + p.key + ' value'} value=${p.value}
1269
+ onInput=${(e) => setUrlParams(prev => prev.map(up => up.id === p.id ? { ...up, value: e.target.value } : up))}
1270
+ style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px' }} />
1271
+ </div>`)}
1272
+ </div>`}
1273
+
1274
+ <!-- Tabs: Params / Headers / Body -->
1275
+ <div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1276
+ <div className="api-tester-tabs">
1277
+ <button className=${`api-tester-tab${activeTab === 'params' ? ' active' : ''}`}
1278
+ onClick=${() => setActiveTab('params')}>
1279
+ Params <span style=${{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: '4px' }}>(${params.filter(p => p.key?.trim()).length})</span>
1280
+ </button>
1281
+ <button className=${`api-tester-tab${activeTab === 'headers' ? ' active' : ''}`}
1282
+ onClick=${() => setActiveTab('headers')}>
1283
+ Headers <span style=${{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: '4px' }}>(${headers.filter(h => h.key?.trim()).length})</span>
1284
+ </button>
1285
+ ${hasBody && html`<button className=${`api-tester-tab${activeTab === 'body' ? ' active' : ''}`}
1286
+ onClick=${() => setActiveTab('body')}>
1287
+ Body
1288
+ </button>`}
1289
+ </div>
1290
+
1291
+ ${activeTab === 'params' && html`<div>
1292
+ ${params.map(p => html`<div key=${p.id} className="api-tester-kv-row">
1293
+ <input placeholder="Param name" value=${p.key}
1294
+ onInput=${(e) => updateParam(p.id, 'key', e.target.value)} />
1295
+ <input placeholder="Value" value=${p.value}
1296
+ onInput=${(e) => updateParam(p.id, 'value', e.target.value)} />
1297
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1298
+ onClick=${() => removeParam(p.id)}>
1299
+ <${LuTrash2} size=${12} />
1300
+ </button>
1301
+ </div>`)}
1302
+ <div style=${{ padding: '8px 14px' }}>
1303
+ <button className="btn btn-ghost btn-sm" onClick=${addParam}>
1304
+ <${LuPlus} size=${12} /> Add Param
1305
+ </button>
1306
+ </div>
1307
+ </div>`}
1308
+
1309
+ ${activeTab === 'headers' && html`<div>
1310
+ ${headers.map(h => html`<div key=${h.id} className="api-tester-kv-row">
1311
+ <input placeholder="Header name" value=${h.key}
1312
+ onInput=${(e) => updateHeader(h.id, 'key', e.target.value)} />
1313
+ <input placeholder="Value" value=${h.value}
1314
+ onInput=${(e) => updateHeader(h.id, 'value', e.target.value)} />
1315
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1316
+ onClick=${() => removeHeader(h.id)}>
1317
+ <${LuTrash2} size=${12} />
1318
+ </button>
1319
+ </div>`)}
1320
+ <div style=${{ padding: '8px 14px', display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' }}>
1321
+ <button className="btn btn-ghost btn-sm" onClick=${addHeader}>
1322
+ <${LuPlus} size=${12} /> Add Header
1323
+ </button>
1324
+ <span style=${{ fontSize: '10px', color: 'var(--text-muted)', margin: '0 4px' }}>|</span>
1325
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('bearer')}>+ Auth Bearer</button>
1326
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('json')}>+ JSON</button>
1327
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('form')}>+ Form Data</button>
1328
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('urlencoded')}>+ URL Encoded</button>
1329
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('accept')}>+ Accept JSON</button>
1330
+ </div>
1331
+ </div>`}
1332
+
1333
+ ${activeTab === 'body' && hasBody && html`<div>
1334
+ <div style=${{ display: 'flex', gap: '0', borderBottom: '1px solid var(--border)' }}>
1335
+ ${[
1336
+ { id: 'json', label: 'JSON' },
1337
+ { id: 'form-data', label: 'Form Data' },
1338
+ { id: 'x-www-form-urlencoded', label: 'URL Encoded' },
1339
+ { id: 'raw', label: 'Raw' },
1340
+ { id: 'binary', label: 'Binary' },
1341
+ ].map(bt => html`<button key=${bt.id}
1342
+ style=${{
1343
+ padding: '6px 14px', fontSize: '11px', fontWeight: bodyType === bt.id ? 600 : 400,
1344
+ color: bodyType === bt.id ? 'var(--accent-light)' : 'var(--text-muted)',
1345
+ background: 'none', border: 'none', cursor: 'pointer',
1346
+ borderBottom: bodyType === bt.id ? '2px solid var(--accent)' : '2px solid transparent',
1347
+ transition: 'all 0.15s',
1348
+ }}
1349
+ onClick=${() => setBodyType(bt.id)}
1350
+ >${bt.label}</button>`)}
1351
+ </div>
1352
+
1353
+ ${(bodyType === 'json' || bodyType === 'raw') && html`<div>
1354
+ <textarea
1355
+ className="api-tester-body-editor"
1356
+ placeholder=${bodyType === 'json' ? '{\n "key": "value"\n}' : 'Enter raw body...'}
1357
+ value=${body}
1358
+ onInput=${(e) => bodyType === 'json' ? validateBody(e.target.value) : setBody(e.target.value)}
1359
+ onBlur=${bodyType === 'json' ? formatBody : undefined}
1360
+ spellcheck=${false}
1361
+ style=${{ borderTop: bodyError && bodyType === 'json' ? '2px solid var(--danger)' : 'none' }}
1362
+ />
1363
+ ${bodyError && bodyType === 'json' && html`<div style=${{ padding: '6px 14px', fontSize: '11px', color: 'var(--danger)', background: 'rgba(248,113,113,0.06)' }}>
1364
+ Invalid JSON: ${bodyError}
1365
+ </div>`}
1366
+ </div>`}
1367
+
1368
+ ${(bodyType === 'form-data' || bodyType === 'x-www-form-urlencoded') && html`<div>
1369
+ ${formFields.map(f => html`<div key=${f.id} style=${{
1370
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '8px 14px',
1371
+ borderBottom: '1px solid var(--border)',
1372
+ }}>
1373
+ <input placeholder="Key" value=${f.key} style=${{
1374
+ flex: '0 0 140px', padding: '6px 10px', fontSize: '12px',
1375
+ background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: '6px',
1376
+ color: 'var(--text-primary)', outline: 'none',
1377
+ }} onInput=${(e) => updateFormField(f.id, 'key', e.target.value)} />
1378
+
1379
+ ${bodyType === 'form-data' && html`<select style=${{
1380
+ padding: '6px 8px', fontSize: '11px', background: 'var(--bg-input)',
1381
+ border: '1px solid var(--border)', borderRadius: '6px', color: 'var(--text-secondary)',
1382
+ cursor: 'pointer', minWidth: '70px',
1383
+ }} value=${f.type || 'text'} onChange=${(e) => updateFormField(f.id, 'type', e.target.value)}>
1384
+ <option value="text">Text</option>
1385
+ <option value="file">File</option>
1386
+ </select>`}
1387
+
1388
+ ${(f.type !== 'file' || bodyType === 'x-www-form-urlencoded') && html`<input
1389
+ placeholder="Value" value=${f.value || ''} style=${{
1390
+ flex: 1, padding: '6px 10px', fontSize: '12px',
1391
+ background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: '6px',
1392
+ color: 'var(--text-primary)', outline: 'none',
1393
+ }} onInput=${(e) => updateFormField(f.id, 'value', e.target.value)} />`}
1394
+
1395
+ ${f.type === 'file' && bodyType === 'form-data' && html`<div style=${{ flex: 1, display: 'flex', gap: '6px', alignItems: 'center' }}>
1396
+ ${f.fileName ? html`<span style=${{
1397
+ fontSize: '11px', color: 'var(--text-secondary)', padding: '4px 8px',
1398
+ background: 'var(--bg-input)', borderRadius: '4px', border: '1px solid var(--border)',
1399
+ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
1400
+ }}>${f.fileName} <span style=${{ color: 'var(--text-muted)', fontSize: '10px' }}>(${f.fileSize > 1024 ? (f.fileSize / 1024).toFixed(1) + 'KB' : f.fileSize + 'B'})</span></span>` : null}
1401
+ <label style=${{
1402
+ padding: '5px 12px', fontSize: '11px', borderRadius: '6px', cursor: 'pointer',
1403
+ background: 'var(--bg-input)', border: '1px solid var(--border)',
1404
+ color: 'var(--accent-light)', fontWeight: 500,
1405
+ }}>
1406
+ ${f.fileName ? 'Change' : 'Choose File'}
1407
+ <input type="file" style=${{ display: 'none' }} onChange=${(e) => {
1408
+ const file = e.target.files[0];
1409
+ if (file) {
1410
+ const sendAs = f.sendAs || 'file';
1411
+ handleFileSelect(f.id, file, sendAs);
1412
+ }
1413
+ }} />
1414
+ </label>
1415
+ ${f.fileName && html`<select style=${{
1416
+ padding: '5px 8px', fontSize: '10px', background: 'var(--bg-input)',
1417
+ border: '1px solid var(--border)', borderRadius: '6px', color: 'var(--text-secondary)',
1418
+ cursor: 'pointer',
1419
+ }} value=${f.sendAs || 'file'} onChange=${(e) => {
1420
+ const newSendAs = e.target.value;
1421
+ setFormFields(prev => prev.map(ff => {
1422
+ if (ff.id !== f.id) return ff;
1423
+ var updated = { ...ff, sendAs: newSendAs };
1424
+ if (ff.fileData && newSendAs === 'base64') updated.value = ff.fileData;
1425
+ else if (ff.value && newSendAs === 'file') updated.fileData = ff.value;
1426
+ return updated;
1427
+ }));
1428
+ }}>
1429
+ <option value="file">Send as File</option>
1430
+ <option value="base64">Send as Base64</option>
1431
+ </select>`}
1432
+ </div>`}
1433
+
1434
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1435
+ onClick=${() => removeFormField(f.id)}>
1436
+ <${LuTrash2} size=${12} />
1437
+ </button>
1438
+ </div>`)}
1439
+ <div style=${{ padding: '8px 14px' }}>
1440
+ <button className="btn btn-ghost btn-sm" onClick=${addFormField}>
1441
+ <${LuPlus} size=${12} /> Add Field
1442
+ </button>
1443
+ </div>
1444
+ </div>`}
1445
+
1446
+ ${bodyType === 'binary' && html`<div style=${{ padding: '20px 14px', textAlign: 'center' }}>
1447
+ <label style=${{
1448
+ padding: '12px 24px', fontSize: '13px', borderRadius: '8px', cursor: 'pointer',
1449
+ background: 'var(--bg-input)', border: '1.5px dashed var(--border)',
1450
+ color: 'var(--accent-light)', fontWeight: 500, display: 'inline-block',
1451
+ }}>
1452
+ ${body ? 'File selected (' + (body.length * 0.75 / 1024).toFixed(1) + ' KB)' : 'Choose File'}
1453
+ <input type="file" style=${{ display: 'none' }} onChange=${(e) => {
1454
+ const file = e.target.files[0];
1455
+ if (file) {
1456
+ const reader = new FileReader();
1457
+ reader.onload = () => setBody(reader.result.split(',')[1]);
1458
+ reader.readAsDataURL(file);
1459
+ }
1460
+ }} />
1461
+ </label>
1462
+ ${body && html`<button className="btn btn-ghost btn-sm" style=${{ marginLeft: '8px' }}
1463
+ onClick=${() => setBody('')}>Clear</button>`}
1464
+ </div>`}
1465
+ </div>`}
1466
+ </div>
1467
+
1468
+ <!-- Saved response detail (inline) -->
1469
+ ${selectedResponse && (() => {
1470
+ const r = (savedResponses[selectedResponse.testId] || []).find(x => x.id === selectedResponse.respId);
1471
+ if (!r) return null;
1472
+ const rStatusClass = 's' + Math.floor((r.status || 0) / 100) + 'xx';
1473
+ return html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1474
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
1475
+ <span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>Saved Response</span>
1476
+ <span className=${`api-tester-status ${rStatusClass}`}>${r.status} ${r.statusText}</span>
1477
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>${r.time}ms</span>
1478
+ <span style=${{ fontSize: '13px', fontWeight: 500, marginLeft: 'auto', color: 'var(--text-secondary)' }}>${r.label}</span>
1479
+ </div>
1480
+ <div className="api-tester-response-body">${(() => {
1481
+ try { return JSON.stringify(JSON.parse(r.body), null, 2); } catch { return r.body || 'No response body'; }
1482
+ })()}</div>
1483
+ </div>`;
1484
+ })()}
1485
+
1486
+ <!-- Response (live) -->
1487
+ ${response && !selectedResponse && html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1488
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
1489
+ <span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>Response</span>
1490
+ <span className=${`api-tester-status ${statusClass}`}>
1491
+ ${response.status} ${response.statusText}
1492
+ </span>
1493
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>${response.time}ms</span>
1494
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>
1495
+ ${response.size > 1024 ? (response.size / 1024).toFixed(1) + ' KB' : response.size + ' B'}
1496
+ </span>
1497
+ <div style=${{ marginLeft: 'auto', display: 'flex', gap: '6px' }}>
1498
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '11px' }}
1499
+ onClick=${() => { navigator.clipboard.writeText(response.body || ''); toast('Copied', 'success'); }}>
1500
+ <${LuCopy} size=${12} /> Copy
1501
+ </button>
1502
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '11px' }}
1503
+ onClick=${saveResponse}>
1504
+ <${LuSave} size=${12} /> Save
1505
+ </button>
1506
+ </div>
1507
+ </div>
1508
+ <div style=${{ display: 'flex', gap: '0', borderBottom: '1px solid var(--border)' }}>
1509
+ <button style=${{
1510
+ padding: '5px 14px', fontSize: '11px', background: 'none', border: 'none', cursor: 'pointer',
1511
+ color: responseTab === 'body' ? 'var(--accent-light)' : 'var(--text-muted)',
1512
+ borderBottom: responseTab === 'body' ? '2px solid var(--accent)' : '2px solid transparent',
1513
+ fontWeight: responseTab === 'body' ? 600 : 400,
1514
+ }} onClick=${() => setResponseTab('body')}>Body</button>
1515
+ <button style=${{
1516
+ padding: '5px 14px', fontSize: '11px', background: 'none', border: 'none', cursor: 'pointer',
1517
+ color: responseTab === 'headers' ? 'var(--accent-light)' : 'var(--text-muted)',
1518
+ borderBottom: responseTab === 'headers' ? '2px solid var(--accent)' : '2px solid transparent',
1519
+ fontWeight: responseTab === 'headers' ? 600 : 400,
1520
+ }} onClick=${() => setResponseTab('headers')}>Headers <span style=${{ fontSize: '10px', color: 'var(--text-muted)' }}>(${Object.keys(response.headers || {}).length})</span></button>
1521
+ </div>
1522
+ ${responseTab === 'body' && html`<div className="api-tester-response-body">
1523
+ ${response.body || 'No response body'}
1524
+ </div>`}
1525
+ ${responseTab === 'headers' && html`<div style=${{ padding: '8px 0' }}>
1526
+ ${Object.entries(response.headers || {}).map(([k, v]) => html`<div key=${k} style=${{
1527
+ display: 'flex', padding: '4px 14px', fontSize: '12px', gap: '12px',
1528
+ borderBottom: '1px solid rgba(255,255,255,0.03)',
1529
+ }}>
1530
+ <span style=${{ fontWeight: 600, color: 'var(--accent-light)', minWidth: '180px', fontFamily: "'SF Mono', monospace", fontSize: '11px' }}>${k}</span>
1531
+ <span style=${{ color: 'var(--text-secondary)', fontFamily: "'SF Mono', monospace", fontSize: '11px', wordBreak: 'break-all' }}>${v}</span>
1532
+ </div>`)}
1533
+ </div>`}
1534
+ </div>`}
1535
+
1536
+ ${!response && !loading && !selectedResponse && html`<div className="card">
1537
+ <div className="empty-state" style=${{ padding: '40px' }}>
1538
+ <div className="empty-icon"><${LuSend} size=${32} /></div>
1539
+ <h3>Send a Request</h3>
1540
+ <p>Enter a URL and click Send, or import from your existing routes</p>
1541
+ </div>
1542
+ </div>`}
1543
+ </div>`}
1544
+
1545
+ <!-- Viewing a saved socket response detail -->
1546
+ ${selectedResponse && testerMode !== 'rest' && (() => {
1547
+ const r = (savedResponses[selectedResponse.testId] || []).find(x => x.id === selectedResponse.respId);
1548
+ const parentTest = savedTests.find(t => t.id === selectedResponse.testId);
1549
+ if (!r || !parentTest) return null;
1550
+ return html`<${SavedResponseDetail}
1551
+ key=${selectedResponse.respId}
1552
+ resp=${r}
1553
+ parentTest=${parentTest}
1554
+ onBack=${() => { setSelectedResponse(null); loadTest(parentTest); }}
1555
+ onUpdate=${(changes) => updateResponse(selectedResponse.testId, selectedResponse.respId, changes)} />`;
1556
+ })()}
1557
+ </div>
1558
+ </div>
1559
+
1560
+ <!-- Import Route Modal -->
1561
+ ${showImport && html`<div style=${{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
1562
+ onClick=${(e) => { if (e.target === e.currentTarget) setShowImport(false); }}>
1563
+ <${motion.div}
1564
+ initial=${{ opacity: 0, scale: 0.95 }}
1565
+ animate=${{ opacity: 1, scale: 1 }}
1566
+ transition=${{ duration: 0.15 }}
1567
+ style=${{ background: 'var(--bg-card)', borderRadius: '12px', border: '1px solid var(--border)', width: '500px', maxHeight: '70vh', overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
1568
+ <div style=${{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1569
+ <div style=${{ fontWeight: 600, fontSize: '15px' }}>Import from Routes</div>
1570
+ <button className="btn btn-ghost btn-sm" onClick=${() => setShowImport(false)}>✕</button>
1571
+ </div>
1572
+ <div style=${{ maxHeight: '55vh', overflowY: 'auto', padding: '8px 0' }}>
1573
+ ${importLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
1574
+ ${!importLoading && importRoutes.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>No routes found</div>`}
1575
+ ${!importLoading && importRoutes.map(route => html`<div key=${route.name}>
1576
+ <div style=${{ padding: '8px 20px', fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
1577
+ ${route.name} <span style=${{ fontWeight: 400, textTransform: 'none' }}>(${route.prefix})</span>
1578
+ </div>
1579
+ ${route.endpoints.map((ep, i) => html`<div key=${i}
1580
+ style=${{ padding: '8px 20px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px', transition: 'background 0.1s' }}
1581
+ onClick=${() => importEndpoint(route, ep)}
1582
+ onMouseOver=${(e) => e.currentTarget.style.background = 'var(--bg-input)'}
1583
+ onMouseOut=${(e) => e.currentTarget.style.background = 'transparent'}>
1584
+ <span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '10px', padding: '1px 6px' }}>${ep.method}</span>
1585
+ <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)' }}>${ep.fullPath}</span>
1586
+ ${ep.bodyFields?.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: 'auto' }}>${ep.bodyFields.length} fields</span>`}
1587
+ </div>`)}
1588
+ </div>`)}
1589
+ </div>
1590
+ <//>
1591
+ </div>`}
1592
+ </div>`;
1593
+ }
1594
+