@kyro-cms/admin 0.9.5 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +659 -684
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +54 -51
- package/dist/index.css.map +1 -1
- package/dist/index.js +660 -685
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar.tsx +172 -292
- package/src/components/Admin.tsx +7 -1
- package/src/components/AutoForm.tsx +573 -367
- package/src/components/DetailView.tsx +22 -47
- package/src/components/GraphQLPlayground.tsx +473 -223
- package/src/components/ListView.tsx +1 -1
- package/src/components/MediaGallery.tsx +2 -2
- package/src/components/RestPlayground.tsx +482 -519
- package/src/components/blocks/AccordionBlock.tsx +1 -1
- package/src/components/blocks/ArrayBlock.tsx +1 -1
- package/src/components/blocks/ChildBlocksTree.tsx +6 -6
- package/src/components/blocks/CodeBlock.tsx +1 -1
- package/src/components/blocks/FileBlock.tsx +1 -1
- package/src/components/blocks/HeroBlock.tsx +1 -1
- package/src/components/blocks/ListBlock.tsx +1 -1
- package/src/components/blocks/RelationshipBlock.tsx +1 -1
- package/src/components/blocks/RichTextBlock.tsx +1 -1
- package/src/components/blocks/VideoBlock.tsx +1 -1
- package/src/components/fields/BlocksField.tsx +5 -5
- package/src/components/fields/RichTextField.tsx +3 -1
- package/src/components/ui/SplitButton.tsx +1 -1
- package/src/components/ui/Toast.tsx +2 -1
- package/src/layouts/AdminLayout.astro +16 -1
- package/src/pages/graphql-explorer.astro +7 -51
- package/src/pages/graphql.astro +7 -119
- package/src/pages/index.astro +4 -63
- package/src/pages/rest-playground.astro +3 -29
- package/src/styles/main.css +53 -43
|
@@ -2,6 +2,15 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
|
2
2
|
import { useUIStore, toast } from "../lib/stores";
|
|
3
3
|
import { apiPath } from "../lib/paths";
|
|
4
4
|
import { Modal } from "./ui/Modal";
|
|
5
|
+
import {
|
|
6
|
+
Copy,
|
|
7
|
+
Download,
|
|
8
|
+
Check,
|
|
9
|
+
Play,
|
|
10
|
+
Plus,
|
|
11
|
+
X,
|
|
12
|
+
ChevronRight,
|
|
13
|
+
} from "./ui/icons";
|
|
5
14
|
|
|
6
15
|
interface EnvVariable {
|
|
7
16
|
key: string;
|
|
@@ -54,16 +63,26 @@ const STORAGE_KEYS = {
|
|
|
54
63
|
env: "kyro-rest-env",
|
|
55
64
|
};
|
|
56
65
|
|
|
66
|
+
const METHOD_COLORS: Record<string, string> = {
|
|
67
|
+
GET: "#22c55e",
|
|
68
|
+
POST: "#3b82f6",
|
|
69
|
+
PATCH: "#eab308",
|
|
70
|
+
DELETE: "#ef4444",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const METHOD_BG: Record<string, string> = {
|
|
74
|
+
GET: "rgba(34,197,94,0.12)",
|
|
75
|
+
POST: "rgba(59,130,246,0.12)",
|
|
76
|
+
PATCH: "rgba(234,179,8,0.12)",
|
|
77
|
+
DELETE: "rgba(239,68,68,0.12)",
|
|
78
|
+
};
|
|
79
|
+
|
|
57
80
|
export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
|
|
58
|
-
const [sidebarTab, setSidebarTab] = useState<
|
|
59
|
-
"collections" | "saved" | "history" | "env"
|
|
60
|
-
>("collections");
|
|
81
|
+
const [sidebarTab, setSidebarTab] = useState<"collections" | "saved" | "history" | "env">("collections");
|
|
61
82
|
const [folders, setFolders] = useState<RequestFolder[]>([]);
|
|
62
83
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
|
63
84
|
const [envVars, setEnvVars] = useState<EnvVariable[]>([]);
|
|
64
|
-
const [selectedRequest, setSelectedRequest] = useState<SavedRequest | null>(
|
|
65
|
-
null,
|
|
66
|
-
);
|
|
85
|
+
const [selectedRequest, setSelectedRequest] = useState<SavedRequest | null>(null);
|
|
67
86
|
const [currentRequest, setCurrentRequest] = useState<SavedRequest>({
|
|
68
87
|
id: "new",
|
|
69
88
|
name: "Untitled Request",
|
|
@@ -72,67 +91,56 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
|
|
|
72
91
|
headers: {},
|
|
73
92
|
body: "",
|
|
74
93
|
});
|
|
75
|
-
const [response, setResponse] = useState<
|
|
94
|
+
const [response, setResponse] = useState<{ status: number; duration: number; size: number; data: any } | null>(null);
|
|
76
95
|
const [loading, setLoading] = useState(false);
|
|
77
96
|
const [error, setError] = useState<string | null>(null);
|
|
78
|
-
const [
|
|
79
|
-
"params",
|
|
80
|
-
);
|
|
97
|
+
const [activeEditorTab, setActiveEditorTab] = useState<"params" | "headers" | "body">("params");
|
|
81
98
|
const [showFolderModal, setShowFolderModal] = useState(false);
|
|
82
99
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
83
100
|
const [newFolderName, setNewFolderName] = useState("");
|
|
84
101
|
const [saveToFolderId, setSaveToFolderId] = useState("");
|
|
85
102
|
const [saveRequestName, setSaveRequestName] = useState("");
|
|
103
|
+
const [copied, setCopied] = useState(false);
|
|
104
|
+
const [showSidebar, setShowSidebar] = useState(true);
|
|
105
|
+
const [mobilePanel, setMobilePanel] = useState<"editor" | "response">("editor");
|
|
106
|
+
const [splitPos, setSplitPos] = useState(50);
|
|
107
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
108
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
86
110
|
const { confirm } = useUIStore();
|
|
111
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
87
112
|
|
|
88
|
-
// Load from localStorage
|
|
89
113
|
useEffect(() => {
|
|
90
114
|
const savedFolders = localStorage.getItem(STORAGE_KEYS.folders);
|
|
91
115
|
if (savedFolders) setFolders(JSON.parse(savedFolders));
|
|
92
|
-
|
|
93
116
|
const savedHistory = localStorage.getItem(STORAGE_KEYS.history);
|
|
94
117
|
if (savedHistory) setHistory(JSON.parse(savedHistory));
|
|
95
|
-
|
|
96
118
|
const savedEnv = localStorage.getItem(STORAGE_KEYS.env);
|
|
97
119
|
if (savedEnv) setEnvVars(JSON.parse(savedEnv));
|
|
98
|
-
else
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
]);
|
|
103
|
-
|
|
120
|
+
else setEnvVars([
|
|
121
|
+
{ key: "baseUrl", value: apiPath, enabled: true },
|
|
122
|
+
{ key: "token", value: "", enabled: true },
|
|
123
|
+
]);
|
|
104
124
|
setIsMounted(true);
|
|
105
125
|
}, []);
|
|
106
126
|
|
|
107
|
-
const [isMounted, setIsMounted] = useState(false);
|
|
108
|
-
|
|
109
|
-
// Save to localStorage
|
|
110
127
|
useEffect(() => {
|
|
111
|
-
if (isMounted)
|
|
112
|
-
localStorage.setItem(STORAGE_KEYS.folders, JSON.stringify(folders));
|
|
113
|
-
}
|
|
128
|
+
if (isMounted) localStorage.setItem(STORAGE_KEYS.folders, JSON.stringify(folders));
|
|
114
129
|
}, [folders, isMounted]);
|
|
115
130
|
|
|
116
131
|
useEffect(() => {
|
|
117
|
-
if (isMounted)
|
|
118
|
-
localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history));
|
|
119
|
-
}
|
|
132
|
+
if (isMounted) localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history));
|
|
120
133
|
}, [history, isMounted]);
|
|
121
134
|
|
|
122
135
|
useEffect(() => {
|
|
123
|
-
if (isMounted)
|
|
124
|
-
localStorage.setItem(STORAGE_KEYS.env, JSON.stringify(envVars));
|
|
125
|
-
}
|
|
136
|
+
if (isMounted) localStorage.setItem(STORAGE_KEYS.env, JSON.stringify(envVars));
|
|
126
137
|
}, [envVars, isMounted]);
|
|
127
138
|
|
|
128
139
|
const resolveUrl = (url: string) => {
|
|
129
140
|
let resolved = url;
|
|
130
141
|
envVars.forEach((v) => {
|
|
131
|
-
if (v.enabled) {
|
|
132
|
-
resolved = resolved.replace(`{{${v.key}}}`, v.value);
|
|
133
|
-
}
|
|
142
|
+
if (v.enabled) resolved = resolved.replace(`{{${v.key}}}`, v.value);
|
|
134
143
|
});
|
|
135
|
-
// Default base URL if relative
|
|
136
144
|
if (resolved.startsWith("/")) {
|
|
137
145
|
const baseUrl = envVars.find((v) => v.key === "baseUrl" && v.enabled)?.value || apiPath;
|
|
138
146
|
resolved = `${baseUrl}${resolved}`;
|
|
@@ -140,57 +148,53 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
|
|
|
140
148
|
return resolved;
|
|
141
149
|
};
|
|
142
150
|
|
|
143
|
-
const handleSend = async () => {
|
|
151
|
+
const handleSend = useCallback(async () => {
|
|
144
152
|
setLoading(true);
|
|
145
153
|
setError(null);
|
|
146
154
|
const start = Date.now();
|
|
147
155
|
try {
|
|
148
156
|
const url = resolveUrl(currentRequest.url);
|
|
149
157
|
const headers: Record<string, string> = { ...currentRequest.headers };
|
|
150
|
-
|
|
151
158
|
const token = envVars.find(v => v.key === 'token' && v.enabled)?.value;
|
|
152
159
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
153
160
|
|
|
154
161
|
const res = await fetch(url, {
|
|
155
162
|
method: currentRequest.method,
|
|
156
|
-
headers: {
|
|
157
|
-
|
|
158
|
-
...headers,
|
|
159
|
-
},
|
|
160
|
-
body:
|
|
161
|
-
currentRequest.method !== "GET" && currentRequest.body
|
|
162
|
-
? currentRequest.body
|
|
163
|
-
: undefined,
|
|
163
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
164
|
+
body: currentRequest.method !== "GET" && currentRequest.body ? currentRequest.body : undefined,
|
|
164
165
|
});
|
|
165
166
|
|
|
166
167
|
const duration = Date.now() - start;
|
|
167
168
|
const status = res.status;
|
|
168
169
|
const data = await res.json().catch(() => ({}));
|
|
169
170
|
|
|
170
|
-
setResponse({
|
|
171
|
-
status,
|
|
172
|
-
duration,
|
|
173
|
-
size: JSON.stringify(data).length,
|
|
174
|
-
data,
|
|
175
|
-
});
|
|
171
|
+
setResponse({ status, duration, size: JSON.stringify(data).length, data });
|
|
176
172
|
|
|
177
|
-
|
|
178
|
-
const historyItem: HistoryItem = {
|
|
173
|
+
setHistory((prev) => [{
|
|
179
174
|
id: Date.now().toString(),
|
|
180
175
|
timestamp: Date.now(),
|
|
181
176
|
method: currentRequest.method,
|
|
182
177
|
url: currentRequest.url,
|
|
183
178
|
status,
|
|
184
179
|
duration,
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const message = err instanceof Error ? err.message : "Unknown error";
|
|
189
|
-
setError(message);
|
|
180
|
+
}, ...prev].slice(0, 50));
|
|
181
|
+
} catch (err: unknown) {
|
|
182
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
190
183
|
} finally {
|
|
191
184
|
setLoading(false);
|
|
192
185
|
}
|
|
193
|
-
};
|
|
186
|
+
}, [currentRequest, envVars]);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const handler = (e: KeyboardEvent) => {
|
|
190
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
handleSend();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
window.addEventListener("keydown", handler);
|
|
196
|
+
return () => window.removeEventListener("keydown", handler);
|
|
197
|
+
}, [handleSend]);
|
|
194
198
|
|
|
195
199
|
const loadRequest = (req: SavedRequest) => {
|
|
196
200
|
setCurrentRequest(req);
|
|
@@ -201,12 +205,7 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
|
|
|
201
205
|
|
|
202
206
|
const createFolder = () => {
|
|
203
207
|
if (!newFolderName.trim()) return;
|
|
204
|
-
|
|
205
|
-
id: Date.now().toString(),
|
|
206
|
-
name: newFolderName,
|
|
207
|
-
requests: [],
|
|
208
|
-
};
|
|
209
|
-
setFolders((prev) => [...prev, newFolder]);
|
|
208
|
+
setFolders((prev) => [...prev, { id: Date.now().toString(), name: newFolderName, requests: [] }]);
|
|
210
209
|
setNewFolderName("");
|
|
211
210
|
setShowFolderModal(false);
|
|
212
211
|
};
|
|
@@ -216,514 +215,498 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
|
|
|
216
215
|
title: "Delete Folder",
|
|
217
216
|
message: "Are you sure? All requests inside will be deleted.",
|
|
218
217
|
variant: "danger",
|
|
219
|
-
onConfirm: () =>
|
|
220
|
-
setFolders((prev) => prev.filter((f) => f.id !== id));
|
|
221
|
-
}
|
|
218
|
+
onConfirm: () => setFolders((prev) => prev.filter((f) => f.id !== id)),
|
|
222
219
|
});
|
|
223
220
|
};
|
|
224
221
|
|
|
225
222
|
const saveRequest = () => {
|
|
226
223
|
if (!saveRequestName.trim() || !saveToFolderId) return;
|
|
227
|
-
|
|
228
224
|
const newSavedRequest: SavedRequest = {
|
|
229
|
-
...currentRequest,
|
|
230
|
-
id: Date.now().toString(),
|
|
231
|
-
name: saveRequestName,
|
|
232
|
-
folderId: saveToFolderId,
|
|
225
|
+
...currentRequest, id: Date.now().toString(), name: saveRequestName, folderId: saveToFolderId,
|
|
233
226
|
};
|
|
234
|
-
|
|
235
|
-
setFolders((prev) =>
|
|
236
|
-
prev.map((f) =>
|
|
237
|
-
f.id === saveToFolderId
|
|
238
|
-
? { ...f, requests: [...f.requests, newSavedRequest] }
|
|
239
|
-
: f,
|
|
240
|
-
),
|
|
241
|
-
);
|
|
227
|
+
setFolders((prev) => prev.map((f) => f.id === saveToFolderId ? { ...f, requests: [...f.requests, newSavedRequest] } : f));
|
|
242
228
|
setSelectedRequest(newSavedRequest);
|
|
243
229
|
setShowSaveModal(false);
|
|
244
230
|
};
|
|
245
231
|
|
|
246
232
|
const deleteRequest = (id: string) => {
|
|
247
|
-
setFolders((prev) =>
|
|
248
|
-
prev.map((f) => ({
|
|
249
|
-
...f,
|
|
250
|
-
requests: f.requests.filter((r) => r.id !== id),
|
|
251
|
-
})),
|
|
252
|
-
);
|
|
233
|
+
setFolders((prev) => prev.map((f) => ({ ...f, requests: f.requests.filter((r) => r.id !== id) })));
|
|
253
234
|
if (selectedRequest?.id === id) setSelectedRequest(null);
|
|
254
235
|
};
|
|
255
236
|
|
|
256
|
-
const getMethodColor = (method: string) => {
|
|
257
|
-
switch (method) {
|
|
258
|
-
case "GET":
|
|
259
|
-
return "bg-green-500";
|
|
260
|
-
case "POST":
|
|
261
|
-
return "bg-blue-500";
|
|
262
|
-
case "PATCH":
|
|
263
|
-
return "bg-yellow-500";
|
|
264
|
-
case "DELETE":
|
|
265
|
-
return "bg-red-500";
|
|
266
|
-
default:
|
|
267
|
-
return "bg-gray-500";
|
|
268
|
-
}
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
const getStatusColor = (status: number) => {
|
|
272
|
-
if (status === 0) return "bg-red-500";
|
|
273
|
-
if (status < 300) return "bg-green-500";
|
|
274
|
-
if (status < 400) return "bg-blue-500";
|
|
275
|
-
if (status < 500) return "bg-yellow-500";
|
|
276
|
-
return "bg-red-500";
|
|
277
|
-
};
|
|
278
|
-
|
|
279
237
|
const duplicateRequest = () => {
|
|
280
|
-
|
|
281
|
-
...currentRequest,
|
|
282
|
-
id: Date.now().toString(),
|
|
283
|
-
name: `${currentRequest.name} (copy)`,
|
|
284
|
-
};
|
|
285
|
-
setCurrentRequest(duplicate);
|
|
238
|
+
setCurrentRequest({ ...currentRequest, id: Date.now().toString(), name: `${currentRequest.name} (copy)` });
|
|
286
239
|
setSelectedRequest(null);
|
|
287
240
|
};
|
|
288
241
|
|
|
289
|
-
// Export all data
|
|
290
242
|
const exportData = () => {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
exportedAt: new Date().toISOString(),
|
|
294
|
-
folders,
|
|
295
|
-
history: history.slice(0, 50),
|
|
296
|
-
envVars,
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
300
|
-
type: "application/json",
|
|
301
|
-
});
|
|
302
|
-
const url = URL.createObjectURL(blob);
|
|
243
|
+
const blob = new Blob([JSON.stringify({ version: "1.0", exportedAt: new Date().toISOString(), folders, history: history.slice(0, 50), envVars }, null, 2)], { type: "application/json" });
|
|
244
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
303
245
|
const a = document.createElement("a");
|
|
304
|
-
a.href =
|
|
246
|
+
a.href = blobUrl;
|
|
305
247
|
a.download = `kyro-rest-playground-${Date.now()}.json`;
|
|
306
|
-
document.body.appendChild(a);
|
|
307
248
|
a.click();
|
|
308
|
-
|
|
309
|
-
URL.revokeObjectURL(url);
|
|
249
|
+
URL.revokeObjectURL(blobUrl);
|
|
310
250
|
};
|
|
311
251
|
|
|
312
|
-
// Import data
|
|
313
252
|
const importData = (file: File) => {
|
|
314
253
|
const reader = new FileReader();
|
|
315
254
|
reader.onload = (e) => {
|
|
316
255
|
try {
|
|
317
256
|
const data = JSON.parse(e.target?.result as string);
|
|
318
|
-
|
|
319
|
-
if (data.
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
if (data.history) {
|
|
323
|
-
setHistory((prev) => [...data.history, ...prev].slice(0, 50));
|
|
324
|
-
}
|
|
325
|
-
if (data.envVars) {
|
|
326
|
-
setEnvVars((prev) => [...prev, ...data.envVars]);
|
|
327
|
-
}
|
|
328
|
-
|
|
257
|
+
if (data.folders) setFolders((prev) => [...prev, ...data.folders]);
|
|
258
|
+
if (data.history) setHistory((prev) => [...data.history, ...prev].slice(0, 50));
|
|
259
|
+
if (data.envVars) setEnvVars((prev) => [...prev, ...data.envVars]);
|
|
329
260
|
toast.success("Your playground data has been imported.");
|
|
330
|
-
} catch (
|
|
331
|
-
toast.error("Invalid JSON file structure.");
|
|
332
|
-
}
|
|
261
|
+
} catch { toast.error("Invalid JSON file structure."); }
|
|
333
262
|
};
|
|
334
263
|
reader.readAsText(file);
|
|
335
264
|
};
|
|
336
265
|
|
|
337
|
-
// Clear all data
|
|
338
266
|
const clearAllData = () => {
|
|
339
267
|
confirm({
|
|
340
268
|
title: "Clear All Data?",
|
|
341
|
-
message: "Are you sure
|
|
269
|
+
message: "Are you sure? This action cannot be undone.",
|
|
342
270
|
variant: "danger",
|
|
343
271
|
onConfirm: () => {
|
|
344
272
|
setFolders([]);
|
|
345
273
|
setHistory([]);
|
|
346
|
-
setEnvVars([
|
|
347
|
-
{ key: "baseUrl", value: apiPath, enabled: true },
|
|
348
|
-
{ key: "token", value: "", enabled: true },
|
|
349
|
-
]);
|
|
274
|
+
setEnvVars([{ key: "baseUrl", value: apiPath, enabled: true }, { key: "token", value: "", enabled: true }]);
|
|
350
275
|
localStorage.removeItem(STORAGE_KEYS.folders);
|
|
351
276
|
localStorage.removeItem(STORAGE_KEYS.history);
|
|
352
277
|
localStorage.removeItem(STORAGE_KEYS.env);
|
|
353
|
-
}
|
|
278
|
+
},
|
|
354
279
|
});
|
|
355
280
|
};
|
|
356
281
|
|
|
282
|
+
const handleCopyResponse = async () => {
|
|
283
|
+
if (response?.data) {
|
|
284
|
+
await navigator.clipboard.writeText(JSON.stringify(response.data, null, 2));
|
|
285
|
+
setCopied(true);
|
|
286
|
+
setTimeout(() => setCopied(false), 2000);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const handleDownloadResponse = () => {
|
|
291
|
+
if (!response?.data) return;
|
|
292
|
+
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: "application/json" });
|
|
293
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
294
|
+
const a = document.createElement("a");
|
|
295
|
+
a.href = blobUrl;
|
|
296
|
+
a.download = "rest-response.json";
|
|
297
|
+
a.click();
|
|
298
|
+
URL.revokeObjectURL(blobUrl);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const handlePrettifyBody = () => {
|
|
302
|
+
try {
|
|
303
|
+
const parsed = JSON.parse(currentRequest.body || "{}");
|
|
304
|
+
setCurrentRequest((prev) => ({ ...prev, body: JSON.stringify(parsed, null, 2) }));
|
|
305
|
+
} catch { /* ignore */ }
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const startDrag = useCallback((e: React.MouseEvent) => {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
setIsDragging(true);
|
|
311
|
+
}, []);
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (!isDragging) return;
|
|
315
|
+
const onMove = (e: MouseEvent) => {
|
|
316
|
+
if (!containerRef.current) return;
|
|
317
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
318
|
+
setSplitPos(Math.max(20, Math.min(80, ((e.clientX - rect.left) / rect.width) * 100)));
|
|
319
|
+
};
|
|
320
|
+
const onUp = () => setIsDragging(false);
|
|
321
|
+
window.addEventListener("mousemove", onMove);
|
|
322
|
+
window.addEventListener("mouseup", onUp);
|
|
323
|
+
return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
|
|
324
|
+
}, [isDragging]);
|
|
325
|
+
|
|
326
|
+
const editorPills = [
|
|
327
|
+
{ key: "params" as const, label: "Params" },
|
|
328
|
+
{ key: "headers" as const, label: "Headers" },
|
|
329
|
+
{ key: "body" as const, label: "Body" },
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
const sidebarPills = [
|
|
333
|
+
{ key: "collections" as const, label: "Collections" },
|
|
334
|
+
{ key: "saved" as const, label: "Saved" },
|
|
335
|
+
{ key: "history" as const, label: "History" },
|
|
336
|
+
{ key: "env" as const, label: "Env" },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const methodColor = METHOD_COLORS[currentRequest.method] || "#6b7280";
|
|
340
|
+
|
|
357
341
|
return (
|
|
358
|
-
<div className="h-full flex">
|
|
359
|
-
{/*
|
|
360
|
-
<div className="
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
342
|
+
<div ref={containerRef} className="h-full flex flex-col bg-[var(--kyro-bg)] overflow-hidden rounded-lg border border-[var(--kyro-border)]">
|
|
343
|
+
{/* Compact top bar */}
|
|
344
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] shrink-0">
|
|
345
|
+
<button
|
|
346
|
+
onClick={() => setShowSidebar(!showSidebar)}
|
|
347
|
+
className="p-1 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
|
|
348
|
+
title="Toggle sidebar"
|
|
349
|
+
>
|
|
350
|
+
<ChevronRight className={`w-4 h-4 transition-transform ${showSidebar ? "rotate-180" : ""}`} />
|
|
351
|
+
</button>
|
|
352
|
+
<select
|
|
353
|
+
value={currentRequest.method}
|
|
354
|
+
onChange={(e) => setCurrentRequest({ ...currentRequest, method: e.target.value })}
|
|
355
|
+
className="px-2 py-1 text-[10px] font-bold rounded-md border-0 text-white"
|
|
356
|
+
style={{ backgroundColor: methodColor }}
|
|
357
|
+
>
|
|
358
|
+
<option value="GET">GET</option>
|
|
359
|
+
<option value="POST">POST</option>
|
|
360
|
+
<option value="PATCH">PATCH</option>
|
|
361
|
+
<option value="DELETE">DELETE</option>
|
|
362
|
+
</select>
|
|
363
|
+
<input
|
|
364
|
+
ref={inputRef}
|
|
365
|
+
type="text"
|
|
366
|
+
value={currentRequest.url}
|
|
367
|
+
onChange={(e) => setCurrentRequest({ ...currentRequest, url: e.target.value })}
|
|
368
|
+
placeholder="https://api.example.com/endpoint"
|
|
369
|
+
className="flex-1 min-w-0 px-3 py-1.5 text-xs bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-md text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:border-[var(--kyro-primary)] font-mono"
|
|
370
|
+
/>
|
|
371
|
+
<div className="flex items-center gap-1">
|
|
372
|
+
<button
|
|
373
|
+
onClick={() => setShowSaveModal(true)}
|
|
374
|
+
className="px-2.5 py-1.5 text-[10px] font-semibold rounded-md border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
|
|
375
|
+
>
|
|
376
|
+
Save
|
|
377
|
+
</button>
|
|
378
|
+
<button
|
|
379
|
+
onClick={handleSend}
|
|
380
|
+
disabled={loading || !currentRequest.url}
|
|
381
|
+
className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-[var(--kyro-primary)] text-white text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
|
|
382
|
+
>
|
|
383
|
+
<Play className="w-3 h-3" />
|
|
384
|
+
{loading ? "..." : "Send"}
|
|
385
|
+
</button>
|
|
375
386
|
</div>
|
|
387
|
+
</div>
|
|
376
388
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
name: "Update",
|
|
403
|
-
},
|
|
404
|
-
{
|
|
405
|
-
method: "DELETE",
|
|
406
|
-
path: col.endpoints.delete,
|
|
407
|
-
name: "Delete",
|
|
408
|
-
},
|
|
409
|
-
].map((endpoint, i) => (
|
|
410
|
-
<button type="button"
|
|
411
|
-
key={i}
|
|
412
|
-
onClick={() => {
|
|
413
|
-
setCurrentRequest((prev) => ({
|
|
414
|
-
...prev,
|
|
415
|
-
method: endpoint.method as SavedRequest["method"],
|
|
416
|
-
url: endpoint.path,
|
|
417
|
-
name: `${col.name} - ${endpoint.name}`,
|
|
418
|
-
}));
|
|
419
|
-
setResponse("");
|
|
420
|
-
}}
|
|
421
|
-
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-[var(--kyro-surface-accent)] transition-colors"
|
|
422
|
-
>
|
|
423
|
-
<span
|
|
424
|
-
className={`${getMethodColor(endpoint.method)} text-white px-1.5 py-0.5 rounded text-[10px] font-bold`}
|
|
425
|
-
>
|
|
426
|
-
{endpoint.method}
|
|
427
|
-
</span>
|
|
428
|
-
<span className="text-[var(--kyro-text-secondary)] truncate">
|
|
429
|
-
{endpoint.name}
|
|
430
|
-
</span>
|
|
431
|
-
</button>
|
|
432
|
-
))}
|
|
433
|
-
</div>
|
|
389
|
+
<div className="flex-1 flex overflow-hidden relative">
|
|
390
|
+
{/* Mobile sidebar backdrop */}
|
|
391
|
+
{showSidebar && (
|
|
392
|
+
<div
|
|
393
|
+
className="fixed inset-0 bg-black/40 z-10 md:hidden"
|
|
394
|
+
onClick={() => setShowSidebar(false)}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{/* Left sidebar */}
|
|
399
|
+
{showSidebar && (
|
|
400
|
+
<div className="absolute md:relative z-20 h-full w-60 flex-shrink-0 flex flex-col border-r border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
401
|
+
<div className="flex border-b border-[var(--kyro-border)]">
|
|
402
|
+
{sidebarPills.map((p) => (
|
|
403
|
+
<button
|
|
404
|
+
key={p.key}
|
|
405
|
+
onClick={() => setSidebarTab(p.key)}
|
|
406
|
+
className={`flex-1 px-1 py-1.5 text-[9px] font-semibold tracking-wider transition-all ${
|
|
407
|
+
sidebarTab === p.key
|
|
408
|
+
? "text-[var(--kyro-primary)] border-b-2 border-[var(--kyro-primary)] bg-[var(--kyro-surface-accent)]"
|
|
409
|
+
: "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
|
|
410
|
+
}`}
|
|
411
|
+
>
|
|
412
|
+
{p.label}
|
|
413
|
+
</button>
|
|
434
414
|
))}
|
|
435
415
|
</div>
|
|
436
|
-
)}
|
|
437
416
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
417
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
418
|
+
{sidebarTab === "collections" && (
|
|
419
|
+
<div className="space-y-2">
|
|
420
|
+
{collections.map((col) => (
|
|
421
|
+
<div key={col.slug}>
|
|
422
|
+
<h3 className="text-[10px] font-bold text-[var(--kyro-text-muted)] mb-1 px-2">{col.name}</h3>
|
|
423
|
+
{[
|
|
424
|
+
{ method: "GET", path: col.endpoints.list, name: "List" },
|
|
425
|
+
{ method: "POST", path: col.endpoints.create, name: "Create" },
|
|
426
|
+
{ method: "GET", path: col.endpoints.read, name: "Get" },
|
|
427
|
+
{ method: "PATCH", path: col.endpoints.update, name: "Update" },
|
|
428
|
+
{ method: "DELETE", path: col.endpoints.delete, name: "Delete" },
|
|
429
|
+
].map((ep, i) => (
|
|
430
|
+
<button
|
|
431
|
+
key={i}
|
|
432
|
+
onClick={() => {
|
|
433
|
+
setCurrentRequest((prev) => ({ ...prev, method: ep.method, url: ep.path, name: `${col.name} - ${ep.name}` }));
|
|
434
|
+
setResponse(null);
|
|
435
|
+
}}
|
|
436
|
+
className="w-full flex items-center gap-2 px-2 py-1 rounded text-[10px] hover:bg-[var(--kyro-surface-accent)] transition-colors"
|
|
437
|
+
>
|
|
438
|
+
<span
|
|
439
|
+
className="text-white px-1 py-0.5 rounded text-[8px] font-bold"
|
|
440
|
+
style={{ backgroundColor: METHOD_COLORS[ep.method] }}
|
|
441
|
+
>
|
|
442
|
+
{ep.method}
|
|
443
|
+
</span>
|
|
444
|
+
<span className="text-[var(--kyro-text-secondary)] truncate">{ep.name}</span>
|
|
445
|
+
</button>
|
|
446
|
+
))}
|
|
447
|
+
</div>
|
|
448
|
+
))}
|
|
456
449
|
</div>
|
|
457
|
-
</div>
|
|
458
|
-
{folders.length === 0 && (
|
|
459
|
-
<p className="text-xs text-[var(--kyro-text-muted)] text-center py-4">
|
|
460
|
-
No saved requests yet
|
|
461
|
-
</p>
|
|
462
450
|
)}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
<button type="button" onClick={() => deleteFolder(folder.id)} className="text-[var(--kyro-text-muted)] hover:text-red-500">
|
|
470
|
-
×
|
|
451
|
+
|
|
452
|
+
{sidebarTab === "saved" && (
|
|
453
|
+
<div className="space-y-2">
|
|
454
|
+
<div className="flex items-center justify-between px-2 mb-1">
|
|
455
|
+
<button onClick={() => setShowFolderModal(true)} className="text-[10px] font-semibold text-[var(--kyro-primary)] hover:underline flex items-center gap-1">
|
|
456
|
+
<Plus className="w-3 h-3" /> Folder
|
|
471
457
|
</button>
|
|
458
|
+
<div className="flex gap-2">
|
|
459
|
+
<button onClick={exportData} className="text-[9px] text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">Export</button>
|
|
460
|
+
<label className="text-[9px] text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] cursor-pointer">
|
|
461
|
+
Import
|
|
462
|
+
<input type="file" className="hidden" accept=".json" onChange={(e) => e.target.files?.[0] && importData(e.target.files[0])} />
|
|
463
|
+
</label>
|
|
464
|
+
</div>
|
|
472
465
|
</div>
|
|
473
|
-
{
|
|
474
|
-
<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
466
|
+
{folders.length === 0 && (
|
|
467
|
+
<p className="text-[10px] text-[var(--kyro-text-muted)] text-center py-4">No saved requests yet</p>
|
|
468
|
+
)}
|
|
469
|
+
{folders.map((folder) => (
|
|
470
|
+
<div key={folder.id}>
|
|
471
|
+
<div className="flex items-center justify-between px-2 mb-1">
|
|
472
|
+
<h3 className="text-[10px] font-bold text-[var(--kyro-text-muted)]">{folder.name}</h3>
|
|
473
|
+
<button onClick={() => deleteFolder(folder.id)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)]">
|
|
474
|
+
<X className="w-3 h-3" />
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
{folder.requests.map((req) => (
|
|
478
|
+
<div
|
|
479
|
+
key={req.id}
|
|
480
|
+
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer transition-colors ${
|
|
481
|
+
selectedRequest?.id === req.id
|
|
482
|
+
? "bg-[var(--kyro-primary)] text-white"
|
|
483
|
+
: "hover:bg-[var(--kyro-surface-accent)]"
|
|
484
|
+
}`}
|
|
485
|
+
onClick={() => loadRequest(req)}
|
|
486
|
+
>
|
|
487
|
+
<span
|
|
488
|
+
className="text-white px-1 py-0.5 rounded text-[8px] font-bold"
|
|
489
|
+
style={{ backgroundColor: METHOD_COLORS[req.method] }}
|
|
490
|
+
>
|
|
491
|
+
{req.method}
|
|
492
|
+
</span>
|
|
493
|
+
<span className={`flex-1 text-[10px] truncate ${selectedRequest?.id === req.id ? "text-white" : "text-[var(--kyro-text-secondary)]"}`}>
|
|
494
|
+
{req.name}
|
|
495
|
+
</span>
|
|
496
|
+
<button onClick={(e) => { e.stopPropagation(); deleteRequest(req.id); }}
|
|
497
|
+
className="opacity-0 group-hover:opacity-100 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)]"
|
|
498
|
+
>
|
|
499
|
+
<X className="w-2.5 h-2.5" />
|
|
500
|
+
</button>
|
|
501
|
+
</div>
|
|
502
|
+
))}
|
|
499
503
|
</div>
|
|
500
504
|
))}
|
|
501
505
|
</div>
|
|
502
|
-
)
|
|
503
|
-
</div>
|
|
504
|
-
)}
|
|
506
|
+
)}
|
|
505
507
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
<button type="button" onClick={() => setHistory([])} className="text-[10px] font-bold text-red-500 hover:underline">
|
|
511
|
-
Clear History
|
|
512
|
-
</button>
|
|
513
|
-
</div>
|
|
514
|
-
{history.map((item) => (
|
|
515
|
-
<div
|
|
516
|
-
key={item.id}
|
|
517
|
-
className="p-2 rounded hover:bg-[var(--kyro-surface-accent)] cursor-pointer transition-colors border-b border-[var(--kyro-border)] last:border-0"
|
|
518
|
-
onClick={() => {
|
|
519
|
-
setCurrentRequest((prev) => ({
|
|
520
|
-
...prev,
|
|
521
|
-
method: item.method as SavedRequest["method"],
|
|
522
|
-
url: item.url,
|
|
523
|
-
}));
|
|
524
|
-
setResponse(null);
|
|
525
|
-
}}
|
|
526
|
-
>
|
|
527
|
-
<div className="flex items-center justify-between mb-1">
|
|
528
|
-
<span
|
|
529
|
-
className={`${getMethodColor(item.method)} text-white px-1 py-0.5 rounded text-[10px] font-bold`}
|
|
530
|
-
>
|
|
531
|
-
{item.method}
|
|
532
|
-
</span>
|
|
533
|
-
<span
|
|
534
|
-
className={`${getStatusColor(item.status)} text-white px-1 py-0.5 rounded text-[10px] font-bold`}
|
|
535
|
-
>
|
|
536
|
-
{item.status}
|
|
537
|
-
</span>
|
|
538
|
-
</div>
|
|
539
|
-
<div className="text-[10px] text-[var(--kyro-text-secondary)] truncate mb-1">
|
|
540
|
-
{item.url}
|
|
541
|
-
</div>
|
|
542
|
-
<div className="text-[9px] text-[var(--kyro-text-muted)] flex justify-between">
|
|
543
|
-
<span>{new Date(item.timestamp).toLocaleTimeString()}</span>
|
|
544
|
-
<span>{item.duration}ms</span>
|
|
508
|
+
{sidebarTab === "history" && (
|
|
509
|
+
<div className="space-y-1">
|
|
510
|
+
<div className="flex justify-end px-2 mb-1">
|
|
511
|
+
<button onClick={() => setHistory([])} className="text-[9px] font-semibold text-[var(--kyro-danger)] hover:underline">Clear</button>
|
|
545
512
|
</div>
|
|
513
|
+
{history.map((item) => (
|
|
514
|
+
<div
|
|
515
|
+
key={item.id}
|
|
516
|
+
className="p-1.5 rounded hover:bg-[var(--kyro-surface-accent)] cursor-pointer transition-colors border-b border-[var(--kyro-border)] last:border-0"
|
|
517
|
+
onClick={() => { setCurrentRequest((prev) => ({ ...prev, method: item.method, url: item.url })); setResponse(null); }}
|
|
518
|
+
>
|
|
519
|
+
<div className="flex items-center justify-between gap-1 mb-0.5">
|
|
520
|
+
<span className="text-white px-1 py-0.5 rounded text-[8px] font-bold" style={{ backgroundColor: METHOD_COLORS[item.method] }}>
|
|
521
|
+
{item.method}
|
|
522
|
+
</span>
|
|
523
|
+
<span
|
|
524
|
+
className="text-white px-1 py-0.5 rounded text-[8px] font-bold"
|
|
525
|
+
style={{ backgroundColor: item.status < 400 ? METHOD_COLORS.GET : METHOD_COLORS.DELETE }}
|
|
526
|
+
>
|
|
527
|
+
{item.status}
|
|
528
|
+
</span>
|
|
529
|
+
</div>
|
|
530
|
+
<div className="text-[9px] text-[var(--kyro-text-secondary)] truncate mb-0.5">{item.url}</div>
|
|
531
|
+
<div className="text-[8px] text-[var(--kyro-text-muted)] flex justify-between">
|
|
532
|
+
<span>{new Date(item.timestamp).toLocaleTimeString()}</span>
|
|
533
|
+
<span>{item.duration}ms</span>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
))}
|
|
546
537
|
</div>
|
|
547
|
-
)
|
|
548
|
-
</div>
|
|
549
|
-
)}
|
|
538
|
+
)}
|
|
550
539
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
<button type="button" onClick={clearAllData} className="text-[10px] text-red-500 font-bold hover:underline">Reset All</button>
|
|
557
|
-
</div>
|
|
558
|
-
<div className="space-y-3">
|
|
559
|
-
{envVars.map((env, i) => (
|
|
560
|
-
<div key={i} className="p-3 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)] space-y-2">
|
|
561
|
-
<div className="flex items-center justify-between">
|
|
562
|
-
<span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)]">{env.key}</span>
|
|
563
|
-
<input type="checkbox" checked={env.enabled} onChange={() => {
|
|
564
|
-
const next = [...envVars];
|
|
565
|
-
next[i].enabled = !next[i].enabled;
|
|
566
|
-
setEnvVars(next);
|
|
567
|
-
}} />
|
|
568
|
-
</div>
|
|
569
|
-
<input
|
|
570
|
-
type="text"
|
|
571
|
-
value={env.value}
|
|
572
|
-
onChange={(e) => {
|
|
573
|
-
const next = [...envVars];
|
|
574
|
-
next[i].value = e.target.value;
|
|
575
|
-
setEnvVars(next);
|
|
576
|
-
}}
|
|
577
|
-
className="w-full bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded px-2 py-1 text-xs"
|
|
578
|
-
/>
|
|
540
|
+
{sidebarTab === "env" && (
|
|
541
|
+
<div className="space-y-3">
|
|
542
|
+
<div className="flex justify-between items-center px-2">
|
|
543
|
+
<h3 className="text-[10px] font-bold text-[var(--kyro-text-muted)]">Variables</h3>
|
|
544
|
+
<button onClick={clearAllData} className="text-[9px] text-[var(--kyro-danger)] font-semibold hover:underline">Reset</button>
|
|
579
545
|
</div>
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
546
|
+
{envVars.map((env, i) => (
|
|
547
|
+
<div key={i} className="p-2 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)] space-y-1.5">
|
|
548
|
+
<div className="flex items-center justify-between">
|
|
549
|
+
<span className="text-[9px] font-bold text-[var(--kyro-text-muted)]">{env.key}</span>
|
|
550
|
+
<input type="checkbox" checked={env.enabled} onChange={() => { const n = [...envVars]; n[i].enabled = !n[i].enabled; setEnvVars(n); }} className="accent-[var(--kyro-primary)]" />
|
|
551
|
+
</div>
|
|
552
|
+
<input type="text" value={env.value} onChange={(e) => { const n = [...envVars]; n[i].value = e.target.value; setEnvVars(n); }}
|
|
553
|
+
className="w-full bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded px-2 py-1 text-[10px] font-mono text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)]"
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
))}
|
|
557
|
+
<button onClick={() => setEnvVars([...envVars, { key: "", value: "", enabled: true }])}
|
|
558
|
+
className="w-full py-1.5 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-[10px] text-[var(--kyro-text-muted)] hover:border-[var(--kyro-primary)] hover:text-[var(--kyro-primary)] transition-all"
|
|
559
|
+
>
|
|
560
|
+
+ Add Variable
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
)}
|
|
585
564
|
</div>
|
|
586
|
-
)}
|
|
587
|
-
</div>
|
|
588
|
-
</div>
|
|
589
|
-
|
|
590
|
-
{/* Main Area */}
|
|
591
|
-
<div className="flex-1 flex flex-col bg-[var(--kyro-bg)]">
|
|
592
|
-
{/* URL Bar */}
|
|
593
|
-
<div className="p-4 border-b border-[var(--kyro-border)] flex gap-2">
|
|
594
|
-
<select
|
|
595
|
-
value={currentRequest.method}
|
|
596
|
-
onChange={(e) =>
|
|
597
|
-
setCurrentRequest({ ...currentRequest, method: e.target.value })
|
|
598
|
-
}
|
|
599
|
-
className={`px-3 py-2 rounded-lg font-bold text-white ${getMethodColor(currentRequest.method)}`}
|
|
600
|
-
>
|
|
601
|
-
<option value="GET">GET</option>
|
|
602
|
-
<option value="POST">POST</option>
|
|
603
|
-
<option value="PATCH">PATCH</option>
|
|
604
|
-
<option value="DELETE">DELETE</option>
|
|
605
|
-
</select>
|
|
606
|
-
<div className="flex-1 relative">
|
|
607
|
-
<input
|
|
608
|
-
type="text"
|
|
609
|
-
value={currentRequest.url}
|
|
610
|
-
onChange={(e) =>
|
|
611
|
-
setCurrentRequest({ ...currentRequest, url: e.target.value })
|
|
612
|
-
}
|
|
613
|
-
placeholder="Enter request URL..."
|
|
614
|
-
className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-pink-500"
|
|
615
|
-
/>
|
|
616
565
|
</div>
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
</div>
|
|
631
|
-
|
|
632
|
-
{/* Request Tabs */}
|
|
633
|
-
<div className="flex border-b border-[var(--kyro-border)]">
|
|
634
|
-
{(["params", "headers", "body"] as const).map((tab) => (
|
|
635
|
-
<button type="button"
|
|
636
|
-
key={tab}
|
|
637
|
-
onClick={() => setActiveTab(tab)}
|
|
638
|
-
className={`px-6 py-2 text-xs font-bold tracking-widest ${activeTab === tab
|
|
639
|
-
? "text-pink-500 border-b-2 border-pink-500"
|
|
640
|
-
: "text-[var(--kyro-text-muted)]"
|
|
641
|
-
}`}
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
{/* Main split area */}
|
|
569
|
+
<div className="flex-1 flex flex-col md:flex-row overflow-hidden">
|
|
570
|
+
{/* Mobile panel switcher */}
|
|
571
|
+
<div className="flex md:hidden border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] px-3 py-1.5 gap-1">
|
|
572
|
+
<button
|
|
573
|
+
onClick={() => setMobilePanel("editor")}
|
|
574
|
+
className={`flex-1 px-3 py-1 text-[10px] font-semibold rounded-md transition-all ${
|
|
575
|
+
mobilePanel === "editor"
|
|
576
|
+
? "bg-[var(--kyro-primary)] text-white"
|
|
577
|
+
: "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
|
|
578
|
+
}`}
|
|
642
579
|
>
|
|
643
|
-
|
|
580
|
+
Request
|
|
644
581
|
</button>
|
|
645
|
-
|
|
646
|
-
|
|
582
|
+
<button
|
|
583
|
+
onClick={() => setMobilePanel("response")}
|
|
584
|
+
className={`flex-1 px-3 py-1 text-[10px] font-semibold rounded-md transition-all ${
|
|
585
|
+
mobilePanel === "response"
|
|
586
|
+
? "bg-[var(--kyro-primary)] text-white"
|
|
587
|
+
: "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
|
|
588
|
+
}`}
|
|
589
|
+
>
|
|
590
|
+
Response
|
|
591
|
+
</button>
|
|
592
|
+
</div>
|
|
647
593
|
|
|
648
|
-
|
|
649
|
-
<div
|
|
650
|
-
{
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
594
|
+
{/* Left: Editor */}
|
|
595
|
+
<div
|
|
596
|
+
className={`${mobilePanel === "editor" ? "flex" : "hidden"} md:flex flex-col overflow-hidden border-r border-[var(--kyro-border)] w-full md:w-auto`}
|
|
597
|
+
style={{ width: typeof window !== "undefined" && window.innerWidth >= 768 ? `${splitPos}%` : undefined }}
|
|
598
|
+
>
|
|
599
|
+
{/* Editor pills */}
|
|
600
|
+
<div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
601
|
+
{editorPills.map((p) => (
|
|
602
|
+
<button
|
|
603
|
+
key={p.key}
|
|
604
|
+
onClick={() => setActiveEditorTab(p.key)}
|
|
605
|
+
className={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${
|
|
606
|
+
activeEditorTab === p.key
|
|
607
|
+
? "bg-[var(--kyro-primary)] text-white"
|
|
608
|
+
: "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
|
|
609
|
+
}`}
|
|
610
|
+
>
|
|
611
|
+
{p.label}
|
|
612
|
+
</button>
|
|
613
|
+
))}
|
|
614
|
+
{activeEditorTab === "body" && (
|
|
615
|
+
<button onClick={handlePrettifyBody} className="ml-auto px-2 py-1 text-[10px] font-semibold rounded-md text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]">
|
|
616
|
+
Prettify
|
|
617
|
+
</button>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
{/* Editor content */}
|
|
622
|
+
<div className="flex-1 overflow-hidden">
|
|
623
|
+
{activeEditorTab === "params" && (
|
|
624
|
+
<div className="flex items-center justify-center h-full text-[11px] text-[var(--kyro-text-muted)]">
|
|
625
|
+
Use query parameters in the URL (e.g. <code className="mx-1 px-1 py-0.5 bg-[var(--kyro-surface-accent)] rounded font-mono">?limit=10</code>)
|
|
659
626
|
</div>
|
|
627
|
+
)}
|
|
628
|
+
{activeEditorTab === "headers" && (
|
|
660
629
|
<textarea
|
|
661
630
|
value={JSON.stringify(currentRequest.headers, null, 2)}
|
|
662
|
-
onChange={(e) => {
|
|
663
|
-
|
|
664
|
-
const headers = JSON.parse(e.target.value);
|
|
665
|
-
setCurrentRequest({ ...currentRequest, headers });
|
|
666
|
-
} catch (e) { }
|
|
667
|
-
}}
|
|
668
|
-
className="w-full h-40 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg p-3 font-mono text-xs focus:outline-none focus:border-pink-500"
|
|
631
|
+
onChange={(e) => { try { setCurrentRequest({ ...currentRequest, headers: JSON.parse(e.target.value) }); } catch { /* ignore */ } }}
|
|
632
|
+
className="w-full h-full bg-[var(--kyro-bg)] border-0 p-3 font-mono text-[11px] text-[var(--kyro-text-primary)] resize-none focus:outline-none placeholder:text-[var(--kyro-text-muted)]"
|
|
669
633
|
placeholder='{ "X-Custom-Header": "value" }'
|
|
670
634
|
/>
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
{activeTab === "body" && (
|
|
674
|
-
<div className="h-full flex flex-col">
|
|
675
|
-
<div className="text-xs text-[var(--kyro-text-muted)] mb-4">
|
|
676
|
-
Request Body (JSON)
|
|
677
|
-
</div>
|
|
635
|
+
)}
|
|
636
|
+
{activeEditorTab === "body" && (
|
|
678
637
|
<textarea
|
|
679
638
|
value={currentRequest.body}
|
|
680
|
-
onChange={(e) =>
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
className="flex-1 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg p-3 font-mono text-xs focus:outline-none focus:border-pink-500 min-h-[200px]"
|
|
639
|
+
onChange={(e) => setCurrentRequest({ ...currentRequest, body: e.target.value })}
|
|
640
|
+
className="w-full h-full bg-[var(--kyro-bg)] border-0 p-3 font-mono text-[11px] text-[var(--kyro-text-primary)] resize-none focus:outline-none placeholder:text-[var(--kyro-text-muted)]"
|
|
684
641
|
placeholder='{ "key": "value" }'
|
|
685
642
|
/>
|
|
686
|
-
|
|
687
|
-
|
|
643
|
+
)}
|
|
644
|
+
</div>
|
|
645
|
+
|
|
646
|
+
{/* Status bar */}
|
|
647
|
+
<div className="flex items-center justify-between px-3 py-1 border-t border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[9px] text-[var(--kyro-text-muted)] font-mono">
|
|
648
|
+
<span>{currentRequest.method} {currentRequest.url || "No URL"}</span>
|
|
649
|
+
<span>{currentRequest.body?.length || 0} chars</span>
|
|
650
|
+
</div>
|
|
688
651
|
</div>
|
|
689
652
|
|
|
690
|
-
{/*
|
|
691
|
-
|
|
692
|
-
<div
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
653
|
+
{/* Drag handle - hidden on mobile */}
|
|
654
|
+
{showSidebar && (
|
|
655
|
+
<div
|
|
656
|
+
className="hidden md:block absolute top-0 bottom-0 z-10 w-1.5 cursor-col-resize group"
|
|
657
|
+
style={{ left: `calc(${splitPos}% - 3px)` }}
|
|
658
|
+
onMouseDown={startDrag}
|
|
659
|
+
>
|
|
660
|
+
<div className="w-0.5 h-full mx-auto bg-transparent group-hover:bg-[var(--kyro-primary)] group-hover:opacity-40 transition-all" />
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
{/* Right: Response */}
|
|
665
|
+
<div
|
|
666
|
+
className={`${mobilePanel === "response" ? "flex" : "hidden"} md:flex flex-1 flex-col overflow-hidden min-w-0 w-full md:w-auto`}
|
|
667
|
+
style={{ width: typeof window !== "undefined" && window.innerWidth >= 768 ? (showSidebar ? `${100 - splitPos}%` : "50%") : undefined }}
|
|
668
|
+
>
|
|
669
|
+
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
670
|
+
<span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Response</span>
|
|
696
671
|
{response && (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
TIME: {response.duration}ms
|
|
672
|
+
<>
|
|
673
|
+
{response.duration > 0 && (
|
|
674
|
+
<span className="text-[9px] font-mono text-[var(--kyro-text-muted)]">{response.duration}ms</span>
|
|
675
|
+
)}
|
|
676
|
+
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
|
|
677
|
+
(response.status as number) < 400 ? "bg-[var(--kyro-success-bg)] text-[var(--kyro-success)]" : "bg-[var(--kyro-danger-bg)] text-[var(--kyro-danger)]"
|
|
678
|
+
}`}>
|
|
679
|
+
{response.status as number}
|
|
706
680
|
</span>
|
|
707
|
-
<span className="text-[
|
|
708
|
-
|
|
709
|
-
</span>
|
|
710
|
-
</div>
|
|
681
|
+
<span className="text-[9px] font-mono text-[var(--kyro-text-muted)]">{response.size}B</span>
|
|
682
|
+
</>
|
|
711
683
|
)}
|
|
684
|
+
<div className="ml-auto flex items-center gap-1">
|
|
685
|
+
<button onClick={handleCopyResponse} className="p-1 rounded text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Copy response">
|
|
686
|
+
{copied ? <Check className="w-3 h-3 text-[var(--kyro-success)]" /> : <Copy className="w-3 h-3" />}
|
|
687
|
+
</button>
|
|
688
|
+
<button onClick={handleDownloadResponse} className="p-1 rounded text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Download response">
|
|
689
|
+
<Download className="w-3 h-3" />
|
|
690
|
+
</button>
|
|
691
|
+
</div>
|
|
712
692
|
</div>
|
|
713
|
-
<div className="flex-1
|
|
693
|
+
<div className="flex-1 overflow-auto bg-[var(--kyro-bg-secondary)]">
|
|
714
694
|
{loading ? (
|
|
715
695
|
<div className="flex items-center justify-center h-full">
|
|
716
|
-
<div className="animate-spin w-
|
|
696
|
+
<div className="animate-spin w-6 h-6 border-2 border-[var(--kyro-primary)] border-t-transparent rounded-full" />
|
|
717
697
|
</div>
|
|
718
698
|
) : error ? (
|
|
719
|
-
<div className="
|
|
699
|
+
<div className="p-3 m-3 rounded bg-[var(--kyro-danger-bg)] border border-[var(--kyro-danger)]/20 text-[11px] text-[var(--kyro-danger)] font-medium">
|
|
700
|
+
⚠ {error}
|
|
701
|
+
</div>
|
|
720
702
|
) : response ? (
|
|
721
|
-
<pre className="text-
|
|
703
|
+
<pre className="text-[11px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap p-3">
|
|
722
704
|
{JSON.stringify(response.data, null, 2)}
|
|
723
705
|
</pre>
|
|
724
706
|
) : (
|
|
725
|
-
<div className="
|
|
726
|
-
|
|
707
|
+
<div className="flex flex-col items-center justify-center h-full opacity-30">
|
|
708
|
+
<Play className="w-10 h-10 mb-2" />
|
|
709
|
+
<p className="text-[11px] font-bold">Send a request</p>
|
|
727
710
|
</div>
|
|
728
711
|
)}
|
|
729
712
|
</div>
|
|
@@ -732,58 +715,38 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
|
|
|
732
715
|
</div>
|
|
733
716
|
|
|
734
717
|
{/* Modals */}
|
|
735
|
-
<Modal
|
|
736
|
-
open={showFolderModal}
|
|
737
|
-
onClose={() => setShowFolderModal(false)}
|
|
738
|
-
title="Create Folder"
|
|
739
|
-
size="md"
|
|
718
|
+
<Modal open={showFolderModal} onClose={() => setShowFolderModal(false)} title="Create Folder" size="sm"
|
|
740
719
|
footer={
|
|
741
|
-
|
|
742
|
-
<button
|
|
743
|
-
<button
|
|
744
|
-
|
|
720
|
+
<div className="flex gap-2">
|
|
721
|
+
<button onClick={() => setShowFolderModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
|
|
722
|
+
<button onClick={createFolder} className="kyro-btn kyro-btn-md kyro-btn-primary">Create</button>
|
|
723
|
+
</div>
|
|
745
724
|
}
|
|
746
725
|
>
|
|
747
|
-
<
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
value={newFolderName}
|
|
751
|
-
onChange={(e) => setNewFolderName(e.target.value)}
|
|
752
|
-
placeholder="Folder name..."
|
|
753
|
-
className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
|
|
754
|
-
/>
|
|
755
|
-
</div>
|
|
726
|
+
<input type="text" value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
|
727
|
+
placeholder="Folder name..." className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-3 py-2 text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)]"
|
|
728
|
+
/>
|
|
756
729
|
</Modal>
|
|
757
730
|
|
|
758
|
-
<Modal
|
|
759
|
-
open={showSaveModal}
|
|
760
|
-
onClose={() => setShowSaveModal(false)}
|
|
761
|
-
title="Save Request"
|
|
762
|
-
size="md"
|
|
731
|
+
<Modal open={showSaveModal} onClose={() => setShowSaveModal(false)} title="Save Request" size="sm"
|
|
763
732
|
footer={
|
|
764
|
-
|
|
765
|
-
<button
|
|
766
|
-
<button
|
|
767
|
-
|
|
733
|
+
<div className="flex gap-2">
|
|
734
|
+
<button onClick={() => setShowSaveModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
|
|
735
|
+
<button onClick={saveRequest} className="kyro-btn kyro-btn-md kyro-btn-primary" disabled={!saveRequestName || !saveToFolderId}>Save</button>
|
|
736
|
+
</div>
|
|
768
737
|
}
|
|
769
738
|
>
|
|
770
|
-
<div className="space-y-
|
|
739
|
+
<div className="space-y-3">
|
|
771
740
|
<div>
|
|
772
|
-
<label className="text-[10px] font-bold
|
|
773
|
-
<input
|
|
774
|
-
|
|
775
|
-
value={saveRequestName}
|
|
776
|
-
onChange={(e) => setSaveRequestName(e.target.value)}
|
|
777
|
-
placeholder="e.g. List Posts..."
|
|
778
|
-
className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
|
|
741
|
+
<label className="text-[10px] font-bold text-[var(--kyro-text-muted)] block mb-1">Request Name</label>
|
|
742
|
+
<input type="text" value={saveRequestName} onChange={(e) => setSaveRequestName(e.target.value)}
|
|
743
|
+
placeholder="e.g. List Posts" className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-3 py-2 text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)]"
|
|
779
744
|
/>
|
|
780
745
|
</div>
|
|
781
746
|
<div>
|
|
782
|
-
<label className="text-[10px] font-bold
|
|
783
|
-
<select
|
|
784
|
-
|
|
785
|
-
onChange={(e) => setSaveToFolderId(e.target.value)}
|
|
786
|
-
className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
|
|
747
|
+
<label className="text-[10px] font-bold text-[var(--kyro-text-muted)] block mb-1">Folder</label>
|
|
748
|
+
<select value={saveToFolderId} onChange={(e) => setSaveToFolderId(e.target.value)}
|
|
749
|
+
className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-3 py-2 text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)]"
|
|
787
750
|
>
|
|
788
751
|
<option value="">Select Folder...</option>
|
|
789
752
|
{folders.map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
|