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