@kyro-cms/admin 0.1.5 → 0.1.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.
Files changed (164) hide show
  1. package/README.md +149 -51
  2. package/package.json +52 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +136 -27
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +1417 -661
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +3 -3
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +199 -57
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +786 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +191 -53
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +149 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/UserManagement.tsx +204 -0
  26. package/src/components/VersionHistoryPanel.tsx +3 -3
  27. package/src/components/WebhookManager.tsx +608 -0
  28. package/src/components/blocks/AccordionBlock.tsx +97 -0
  29. package/src/components/blocks/ArrayBlock.tsx +75 -0
  30. package/src/components/blocks/BlockEditModal.MARKER +12 -0
  31. package/src/components/blocks/BlockEditModal.tsx +774 -0
  32. package/src/components/blocks/ButtonBlock.tsx +165 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +66 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +151 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +64 -0
  38. package/src/components/blocks/HeadingBlock.tsx +81 -0
  39. package/src/components/blocks/HeroBlock.tsx +157 -0
  40. package/src/components/blocks/ImageBlock.tsx +83 -0
  41. package/src/components/blocks/LinkBlock.tsx +71 -0
  42. package/src/components/blocks/ListBlock.tsx +39 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +279 -0
  45. package/src/components/blocks/VStackBlock.tsx +75 -0
  46. package/src/components/blocks/VideoBlock.tsx +45 -0
  47. package/src/components/blocks/index.ts +10 -0
  48. package/src/components/fields/BlocksField.tsx +323 -0
  49. package/src/components/fields/CheckboxField.tsx +15 -9
  50. package/src/components/fields/CodeField.tsx +234 -0
  51. package/src/components/fields/DateField.tsx +38 -11
  52. package/src/components/fields/EditorClient.tsx +271 -0
  53. package/src/components/fields/FileField.tsx +390 -0
  54. package/src/components/fields/HybridContentField.tsx +109 -0
  55. package/src/components/fields/ImageField.tsx +429 -0
  56. package/src/components/fields/JSONField.tsx +361 -0
  57. package/src/components/fields/MarkdownField.tsx +282 -0
  58. package/src/components/fields/NumberField.tsx +42 -12
  59. package/src/components/fields/PortableTextField.tsx +143 -0
  60. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  61. package/src/components/fields/RelationshipField.tsx +231 -59
  62. package/src/components/fields/SelectField.tsx +25 -15
  63. package/src/components/fields/TextField.tsx +45 -14
  64. package/src/components/fields/extensions/blockComponents.tsx +237 -0
  65. package/src/components/fields/extensions/blocksStore.ts +273 -0
  66. package/src/components/fields/index.ts +13 -0
  67. package/src/components/index.ts +1 -2
  68. package/src/components/layout/Header.tsx +2 -2
  69. package/src/components/layout/Layout.tsx +2 -2
  70. package/src/components/ui/Badge.tsx +9 -4
  71. package/src/components/ui/BlockDrawer.tsx +79 -0
  72. package/src/components/ui/Button.tsx +1 -1
  73. package/src/components/ui/CommandPalette.tsx +362 -0
  74. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  75. package/src/components/ui/Dropdown.tsx +1 -1
  76. package/src/components/ui/Modal.tsx +37 -12
  77. package/src/components/ui/PromptModal.tsx +94 -0
  78. package/src/components/ui/SlidePanel.tsx +43 -16
  79. package/src/components/ui/Toast.tsx +80 -14
  80. package/src/env.d.ts +16 -0
  81. package/src/env.ts +20 -0
  82. package/src/index.ts +0 -1
  83. package/src/layouts/AdminLayout.astro +164 -170
  84. package/src/layouts/AuthLayout.astro +50 -0
  85. package/src/lib/MediaService.ts +541 -0
  86. package/src/lib/auth/sqlite-adapter.ts +319 -0
  87. package/src/lib/config.ts +22 -6
  88. package/src/lib/dataStore.ts +132 -74
  89. package/src/lib/db/adapter.ts +54 -0
  90. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  91. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  92. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  93. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  94. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  95. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  96. package/src/lib/db/index.ts +449 -0
  97. package/src/lib/db/mongodb-adapter.ts +207 -0
  98. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  99. package/src/lib/db/schema/mysql-auth.ts +113 -0
  100. package/src/lib/db/schema/mysql-content.ts +20 -0
  101. package/src/lib/db/schema/postgres-auth.ts +116 -0
  102. package/src/lib/db/schema/postgres-content.ts +35 -0
  103. package/src/lib/db/schema/postgres-media.ts +52 -0
  104. package/src/lib/db/schema/postgres-settings.ts +11 -0
  105. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  106. package/src/lib/db/schema/sqlite-content.ts +20 -0
  107. package/src/lib/graphql/index.ts +1 -0
  108. package/src/lib/graphql/schema.ts +443 -0
  109. package/src/lib/rate-limit.ts +267 -0
  110. package/src/lib/storage.ts +374 -0
  111. package/src/lib/store.ts +85 -0
  112. package/src/middleware.ts +116 -28
  113. package/src/pages/[collection]/[id].astro +178 -122
  114. package/src/pages/[collection]/index.astro +24 -156
  115. package/src/pages/admin/api-explorer.astro +98 -0
  116. package/src/pages/admin/graphql-explorer.astro +40 -0
  117. package/src/pages/admin/graphql.astro +97 -0
  118. package/src/pages/admin/index.astro +286 -0
  119. package/src/pages/admin/keys.astro +8 -0
  120. package/src/pages/admin/rest-playground.astro +44 -0
  121. package/src/pages/admin/webhooks.astro +8 -0
  122. package/src/pages/api/[collection]/[id]/publish.ts +44 -0
  123. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  124. package/src/pages/api/[collection]/[id]/versions.ts +36 -0
  125. package/src/pages/api/[collection]/[id].ts +102 -159
  126. package/src/pages/api/[collection]/index.ts +151 -230
  127. package/src/pages/api/auth/[id].ts +48 -69
  128. package/src/pages/api/auth/audit-logs.ts +20 -43
  129. package/src/pages/api/auth/login.ts +159 -45
  130. package/src/pages/api/auth/logout.ts +50 -20
  131. package/src/pages/api/auth/refresh.ts +119 -0
  132. package/src/pages/api/auth/register.ts +110 -40
  133. package/src/pages/api/auth/users.ts +22 -97
  134. package/src/pages/api/collections.ts +59 -0
  135. package/src/pages/api/globals/[slug]/test.ts +172 -0
  136. package/src/pages/api/globals/[slug].ts +42 -0
  137. package/src/pages/api/graphql.ts +90 -0
  138. package/src/pages/api/health.ts +417 -40
  139. package/src/pages/api/keys/[id].ts +26 -0
  140. package/src/pages/api/keys/index.ts +75 -0
  141. package/src/pages/api/media/[id].ts +309 -0
  142. package/src/pages/api/media/folders.ts +609 -0
  143. package/src/pages/api/media/index.ts +146 -0
  144. package/src/pages/api/media/resize.ts +267 -0
  145. package/src/pages/api/search.ts +82 -0
  146. package/src/pages/api/slug-availability.ts +70 -0
  147. package/src/pages/api/storage-config.ts +20 -0
  148. package/src/pages/api/storage-status.ts +206 -0
  149. package/src/pages/api/upload.ts +334 -0
  150. package/src/pages/api/webhooks/index.ts +71 -0
  151. package/src/pages/audit/index.astro +2 -104
  152. package/src/pages/login.astro +82 -0
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +102 -0
  156. package/src/pages/roles/index.astro +21 -21
  157. package/src/pages/settings/[slug].astro +162 -0
  158. package/src/pages/settings/index.astro +9 -0
  159. package/src/pages/users/[id].astro +29 -21
  160. package/src/pages/users/index.astro +22 -17
  161. package/src/pages/users/new.astro +18 -17
  162. package/src/styles/main.css +553 -128
  163. package/src/components/layout/Sidebar.tsx +0 -497
  164. package/src/pages/index.astro +0 -225
@@ -0,0 +1,951 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+
3
+ interface EnvVariable {
4
+ key: string;
5
+ value: string;
6
+ enabled: boolean;
7
+ }
8
+
9
+ interface RequestFolder {
10
+ id: string;
11
+ name: string;
12
+ requests: SavedRequest[];
13
+ }
14
+
15
+ interface SavedRequest {
16
+ id: string;
17
+ name: string;
18
+ method: string;
19
+ url: string;
20
+ headers: Record<string, string>;
21
+ body?: string;
22
+ folderId?: string;
23
+ }
24
+
25
+ interface HistoryItem {
26
+ id: string;
27
+ timestamp: number;
28
+ method: string;
29
+ url: string;
30
+ status: number;
31
+ duration: number;
32
+ }
33
+
34
+ interface RestPlaygroundProps {
35
+ collections?: Array<{
36
+ name: string;
37
+ slug: string;
38
+ endpoints: {
39
+ list: string;
40
+ create: string;
41
+ read: string;
42
+ update: string;
43
+ delete: string;
44
+ };
45
+ }>;
46
+ }
47
+
48
+ const STORAGE_KEYS = {
49
+ folders: "kyro-rest-folders",
50
+ history: "kyro-rest-history",
51
+ env: "kyro-rest-env",
52
+ activeEnv: "kyro-rest-active-env",
53
+ };
54
+
55
+ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
56
+ const [folders, setFolders] = useState<RequestFolder[]>([]);
57
+ const [history, setHistory] = useState<HistoryItem[]>([]);
58
+ const [envVars, setEnvVars] = useState<EnvVariable[]>([]);
59
+ const [activeEnv, setActiveEnv] = useState<string>("default");
60
+ const [selectedRequest, setSelectedRequest] = useState<SavedRequest | null>(
61
+ null,
62
+ );
63
+ const [currentRequest, setCurrentRequest] = useState<SavedRequest>({
64
+ id: "",
65
+ name: "New Request",
66
+ method: "GET",
67
+ url: "",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: "",
70
+ });
71
+ const [response, setResponse] = useState<string>("");
72
+ const [responseStatus, setResponseStatus] = useState<number | null>(null);
73
+ const [responseTime, setResponseTime] = useState<number | null>(null);
74
+ const [loading, setLoading] = useState(false);
75
+ const [activeTab, setActiveTab] = useState<"headers" | "body" | "params">(
76
+ "headers",
77
+ );
78
+ const [showSaveModal, setShowSaveModal] = useState(false);
79
+ const [showEnvModal, setShowEnvModal] = useState(false);
80
+ const [showExportModal, setShowExportModal] = useState(false);
81
+ const [saveName, setSaveName] = useState("");
82
+ const [saveFolder, setSaveFolder] = useState<string | null>(null);
83
+ const [sidebarTab, setSidebarTab] = useState<
84
+ "collections" | "saved" | "history" | "env"
85
+ >("collections");
86
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
87
+
88
+ // Load from localStorage
89
+ useEffect(() => {
90
+ const savedFolders = localStorage.getItem(STORAGE_KEYS.folders);
91
+ if (savedFolders) setFolders(JSON.parse(savedFolders));
92
+
93
+ const savedHistory = localStorage.getItem(STORAGE_KEYS.history);
94
+ if (savedHistory) setHistory(JSON.parse(savedHistory));
95
+
96
+ const savedEnv = localStorage.getItem(STORAGE_KEYS.env);
97
+ if (savedEnv) setEnvVars(JSON.parse(savedEnv));
98
+ else
99
+ setEnvVars([
100
+ { key: "baseUrl", value: "/api", enabled: true },
101
+ { key: "token", value: "", enabled: true },
102
+ ]);
103
+
104
+ const savedActiveEnv = localStorage.getItem(STORAGE_KEYS.activeEnv);
105
+ if (savedActiveEnv) setActiveEnv(savedActiveEnv);
106
+ }, []);
107
+
108
+ // Save to localStorage
109
+ useEffect(() => {
110
+ localStorage.setItem(STORAGE_KEYS.folders, JSON.stringify(folders));
111
+ }, [folders]);
112
+
113
+ useEffect(() => {
114
+ localStorage.setItem(
115
+ STORAGE_KEYS.history,
116
+ JSON.stringify(history.slice(0, 50)),
117
+ );
118
+ }, [history]);
119
+
120
+ useEffect(() => {
121
+ localStorage.setItem(STORAGE_KEYS.env, JSON.stringify(envVars));
122
+ }, [envVars]);
123
+
124
+ useEffect(() => {
125
+ localStorage.setItem(STORAGE_KEYS.activeEnv, activeEnv);
126
+ }, [activeEnv]);
127
+
128
+ const replaceEnvVars = useCallback(
129
+ (text: string): string => {
130
+ if (!text) return text;
131
+ let result = text;
132
+ for (const env of envVars.filter((e) => e.enabled)) {
133
+ const pattern = new RegExp(`\\{\\{${env.key}\\}\\}`, "g");
134
+ result = result.replace(pattern, env.value);
135
+ }
136
+ return result;
137
+ },
138
+ [envVars],
139
+ );
140
+
141
+ const executeRequest = async () => {
142
+ const url = replaceEnvVars(currentRequest.url);
143
+ if (!url) return;
144
+
145
+ setLoading(true);
146
+ setResponse("");
147
+ setResponseStatus(null);
148
+ setResponseTime(null);
149
+
150
+ const startTime = performance.now();
151
+ const headers: Record<string, string> = {};
152
+ for (const [key, value] of Object.entries(currentRequest.headers)) {
153
+ headers[key] = replaceEnvVars(value);
154
+ }
155
+
156
+ try {
157
+ const options: RequestInit = {
158
+ method: currentRequest.method,
159
+ headers,
160
+ };
161
+
162
+ if (
163
+ ["POST", "PUT", "PATCH"].includes(currentRequest.method) &&
164
+ currentRequest.body
165
+ ) {
166
+ options.body = replaceEnvVars(currentRequest.body);
167
+ }
168
+
169
+ const res = await fetch(url, options);
170
+ const endTime = performance.now();
171
+
172
+ setResponseStatus(res.status);
173
+ setResponseTime(Math.round(endTime - startTime));
174
+
175
+ const contentType = res.headers.get("content-type");
176
+ if (contentType?.includes("application/json")) {
177
+ const data = await res.json();
178
+ setResponse(JSON.stringify(data, null, 2));
179
+ } else {
180
+ setResponse(await res.text());
181
+ }
182
+
183
+ // Add to history
184
+ setHistory((prev) =>
185
+ [
186
+ {
187
+ id: Date.now().toString(),
188
+ timestamp: Date.now(),
189
+ method: currentRequest.method,
190
+ url: currentRequest.url,
191
+ status: res.status,
192
+ duration: Math.round(endTime - startTime),
193
+ },
194
+ ...prev,
195
+ ].slice(0, 50),
196
+ );
197
+ } catch (error) {
198
+ setResponseStatus(0);
199
+ setResponse(error instanceof Error ? error.message : "Request failed");
200
+ } finally {
201
+ setLoading(false);
202
+ }
203
+ };
204
+
205
+ const saveRequest = () => {
206
+ const request: SavedRequest = {
207
+ ...currentRequest,
208
+ id: currentRequest.id || Date.now().toString(),
209
+ name: saveName || currentRequest.name,
210
+ folderId: saveFolder || undefined,
211
+ };
212
+
213
+ if (saveFolder) {
214
+ setFolders((prev) =>
215
+ prev
216
+ .map((f) =>
217
+ f.id === saveFolder
218
+ ? { ...f, requests: [...f.requests, request] }
219
+ : f,
220
+ )
221
+ .concat(
222
+ prev.find((f) => f.id === saveFolder)
223
+ ? []
224
+ : [{ id: saveFolder, name: saveFolder, requests: [request] }],
225
+ ),
226
+ );
227
+ } else {
228
+ setFolders((prev) => [
229
+ ...prev,
230
+ { id: "default", name: "Uncategorized", requests: [request] },
231
+ ]);
232
+ }
233
+
234
+ setCurrentRequest(request);
235
+ setShowSaveModal(false);
236
+ setSaveName("");
237
+ };
238
+
239
+ const loadRequest = (request: SavedRequest) => {
240
+ setSelectedRequest(request);
241
+ setCurrentRequest({ ...request });
242
+ setResponse("");
243
+ setResponseStatus(null);
244
+ };
245
+
246
+ const deleteRequest = (requestId: string) => {
247
+ setFolders((prev) =>
248
+ prev
249
+ .map((f) => ({
250
+ ...f,
251
+ requests: f.requests.filter((r) => r.id !== requestId),
252
+ }))
253
+ .filter((f) => f.requests.length > 0),
254
+ );
255
+ if (selectedRequest?.id === requestId) {
256
+ setSelectedRequest(null);
257
+ }
258
+ };
259
+
260
+ const updateHeader = (key: string, value: string) => {
261
+ setCurrentRequest((prev) => ({
262
+ ...prev,
263
+ headers: { ...prev.headers, [key]: value },
264
+ }));
265
+ };
266
+
267
+ const removeHeader = (key: string) => {
268
+ const newHeaders = { ...currentRequest.headers };
269
+ delete newHeaders[key];
270
+ setCurrentRequest((prev) => ({ ...prev, headers: newHeaders }));
271
+ };
272
+
273
+ const addHeader = () => {
274
+ setCurrentRequest((prev) => ({
275
+ ...prev,
276
+ headers: { ...prev.headers, "": "" },
277
+ }));
278
+ };
279
+
280
+ const getMethodColor = (method: string) => {
281
+ switch (method) {
282
+ case "GET":
283
+ return "bg-green-500";
284
+ case "POST":
285
+ return "bg-blue-500";
286
+ case "PUT":
287
+ return "bg-blue-500";
288
+ case "PATCH":
289
+ return "bg-yellow-500";
290
+ case "DELETE":
291
+ return "bg-red-500";
292
+ default:
293
+ return "bg-gray-500";
294
+ }
295
+ };
296
+
297
+ const getStatusColor = (status: number) => {
298
+ if (status === 0) return "bg-red-500";
299
+ if (status < 300) return "bg-green-500";
300
+ if (status < 400) return "bg-blue-500";
301
+ if (status < 500) return "bg-yellow-500";
302
+ return "bg-red-500";
303
+ };
304
+
305
+ const duplicateRequest = () => {
306
+ const duplicate: SavedRequest = {
307
+ ...currentRequest,
308
+ id: Date.now().toString(),
309
+ name: `${currentRequest.name} (copy)`,
310
+ };
311
+ setCurrentRequest(duplicate);
312
+ setSelectedRequest(null);
313
+ };
314
+
315
+ // Export all data
316
+ const exportData = () => {
317
+ const data = {
318
+ version: "1.0",
319
+ exportedAt: new Date().toISOString(),
320
+ folders,
321
+ history: history.slice(0, 50),
322
+ envVars,
323
+ };
324
+
325
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
326
+ type: "application/json",
327
+ });
328
+ const url = URL.createObjectURL(blob);
329
+ const a = document.createElement("a");
330
+ a.href = url;
331
+ a.download = `kyro-rest-playground-${Date.now()}.json`;
332
+ document.body.appendChild(a);
333
+ a.click();
334
+ document.body.removeChild(a);
335
+ URL.revokeObjectURL(url);
336
+ };
337
+
338
+ // Import data
339
+ const importData = (file: File) => {
340
+ const reader = new FileReader();
341
+ reader.onload = (e) => {
342
+ try {
343
+ const data = JSON.parse(e.target?.result as string);
344
+
345
+ if (data.folders) {
346
+ setFolders((prev) => [...prev, ...data.folders]);
347
+ }
348
+ if (data.history) {
349
+ setHistory((prev) => [...data.history, ...prev].slice(0, 50));
350
+ }
351
+ if (data.envVars) {
352
+ setEnvVars((prev) => [...prev, ...data.envVars]);
353
+ }
354
+
355
+ alert("Import successful!");
356
+ } catch (error) {
357
+ alert("Failed to import: Invalid JSON file");
358
+ }
359
+ };
360
+ reader.readAsText(file);
361
+ };
362
+
363
+ // Clear all data
364
+ const clearAllData = () => {
365
+ if (
366
+ confirm(
367
+ "Are you sure you want to clear all saved requests, history, and environment variables?",
368
+ )
369
+ ) {
370
+ setFolders([]);
371
+ setHistory([]);
372
+ setEnvVars([
373
+ { key: "baseUrl", value: "/api", enabled: true },
374
+ { key: "token", value: "", enabled: true },
375
+ ]);
376
+ localStorage.removeItem(STORAGE_KEYS.folders);
377
+ localStorage.removeItem(STORAGE_KEYS.history);
378
+ localStorage.removeItem(STORAGE_KEYS.env);
379
+ }
380
+ };
381
+
382
+ return (
383
+ <div className="h-full flex">
384
+ {/* Left Sidebar */}
385
+ <div className="w-72 flex-shrink-0 flex flex-col border-r border-[var(--kyro-border)]">
386
+ {/* Tabs */}
387
+ <div className="flex border-b border-[var(--kyro-border)]">
388
+ {(["collections", "saved", "history", "env"] as const).map((tab) => (
389
+ <button type="button"
390
+ key={tab}
391
+ onClick={() => setSidebarTab(tab)}
392
+ className={`flex-1 px-2 py-2 text-xs font-bold transition-colors ${
393
+ sidebarTab === tab
394
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)] border-b-2 border-pink-500"
395
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
396
+ }`}
397
+ >
398
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
399
+ </button>
400
+ ))}
401
+ </div>
402
+
403
+ {/* Content */}
404
+ <div className="flex-1 overflow-y-auto p-3">
405
+ {/* Collections */}
406
+ {sidebarTab === "collections" && (
407
+ <div className="space-y-3">
408
+ {collections.map((col) => (
409
+ <div key={col.slug}>
410
+ <h3 className="text-xs font-bold text-[var(--kyro-text-muted)] uppercase mb-2 px-2">
411
+ {col.name}
412
+ </h3>
413
+ {[
414
+ { method: "GET", path: col.endpoints.list, name: "List" },
415
+ {
416
+ method: "POST",
417
+ path: col.endpoints.create,
418
+ name: "Create",
419
+ },
420
+ {
421
+ method: "GET",
422
+ path: col.endpoints.read,
423
+ name: "Get by ID",
424
+ },
425
+ {
426
+ method: "PATCH",
427
+ path: col.endpoints.update,
428
+ name: "Update",
429
+ },
430
+ {
431
+ method: "DELETE",
432
+ path: col.endpoints.delete,
433
+ name: "Delete",
434
+ },
435
+ ].map((endpoint, i) => (
436
+ <button type="button"
437
+ key={i}
438
+ onClick={() => {
439
+ setCurrentRequest((prev) => ({
440
+ ...prev,
441
+ method: endpoint.method as any,
442
+ url: endpoint.path,
443
+ name: `${col.name} - ${endpoint.name}`,
444
+ }));
445
+ setResponse("");
446
+ }}
447
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-[var(--kyro-surface-accent)] transition-colors"
448
+ >
449
+ <span
450
+ className={`${getMethodColor(endpoint.method)} text-white px-1.5 py-0.5 rounded text-[10px] font-bold`}
451
+ >
452
+ {endpoint.method}
453
+ </span>
454
+ <span className="text-[var(--kyro-text-secondary)] truncate">
455
+ {endpoint.name}
456
+ </span>
457
+ </button>
458
+ ))}
459
+ </div>
460
+ ))}
461
+ </div>
462
+ )}
463
+
464
+ {/* Saved */}
465
+ {sidebarTab === "saved" && (
466
+ <div className="space-y-3">
467
+ {folders.length === 0 && (
468
+ <p className="text-xs text-[var(--kyro-text-muted)] text-center py-4">
469
+ No saved requests yet
470
+ </p>
471
+ )}
472
+ {folders.map((folder) => (
473
+ <div key={folder.id}>
474
+ <h3 className="text-xs font-bold text-[var(--kyro-text-muted)] uppercase mb-2 px-2">
475
+ {folder.name}
476
+ </h3>
477
+ {folder.requests.map((req) => (
478
+ <div
479
+ key={req.id}
480
+ className={`group flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
481
+ selectedRequest?.id === req.id
482
+ ? "bg-[var(--kyro-sidebar-active)]"
483
+ : "hover:bg-[var(--kyro-surface-accent)]"
484
+ }`}
485
+ onClick={() => loadRequest(req)}
486
+ >
487
+ <span
488
+ className={`${getMethodColor(req.method)} text-white px-1 py-0.5 rounded text-[10px] font-bold`}
489
+ >
490
+ {req.method}
491
+ </span>
492
+ <span className="flex-1 text-xs text-[var(--kyro-text-secondary)] truncate">
493
+ {req.name}
494
+ </span>
495
+ <button type="button"
496
+ onClick={(e) => {
497
+ e.stopPropagation();
498
+ deleteRequest(req.id);
499
+ }}
500
+ className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-600"
501
+ >
502
+ <svg
503
+ className="w-3 h-3"
504
+ fill="none"
505
+ stroke="currentColor"
506
+ viewBox="0 0 24 24"
507
+ >
508
+ <path
509
+ strokeLinecap="round"
510
+ strokeLinejoin="round"
511
+ strokeWidth="2"
512
+ d="M6 18L18 6M6 6l12 12"
513
+ />
514
+ </svg>
515
+ </button>
516
+ </div>
517
+ ))}
518
+ </div>
519
+ ))}
520
+
521
+ {/* Export/Import/Clear Actions */}
522
+ <div className="flex gap-2 mt-4 pt-4 border-t border-[var(--kyro-border]">
523
+ <button type="button"
524
+ onClick={exportData}
525
+ className="flex-1 px-3 py-2 text-xs font-bold bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] rounded-lg hover:bg-[var(--kyro-surface)] transition-colors"
526
+ title="Export all data"
527
+ >
528
+ Export
529
+ </button>
530
+ <button type="button"
531
+ onClick={() => fileInputRef.current?.click()}
532
+ className="flex-1 px-3 py-2 text-xs font-bold bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] rounded-lg hover:bg-[var(--kyro-surface)] transition-colors"
533
+ title="Import data"
534
+ >
535
+ Import
536
+ </button>
537
+ <button type="button"
538
+ onClick={clearAllData}
539
+ className="px-3 py-2 text-xs font-bold bg-red-500/10 text-red-600 rounded-lg hover:bg-red-500/20 transition-colors"
540
+ title="Clear all data"
541
+ >
542
+ <svg
543
+ className="w-4 h-4"
544
+ fill="none"
545
+ stroke="currentColor"
546
+ viewBox="0 0 24 24"
547
+ >
548
+ <path
549
+ strokeLinecap="round"
550
+ strokeLinejoin="round"
551
+ strokeWidth="2"
552
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
553
+ />
554
+ </svg>
555
+ </button>
556
+ </div>
557
+ <input
558
+ ref={fileInputRef}
559
+ type="file"
560
+ accept=".json"
561
+ onChange={(e) => {
562
+ const file = e.target.files?.[0];
563
+ if (file) importData(file);
564
+ e.target.value = "";
565
+ }}
566
+ className="hidden"
567
+ />
568
+ </div>
569
+ )}
570
+
571
+ {/* History */}
572
+ {sidebarTab === "history" && (
573
+ <div className="space-y-1">
574
+ {history.length === 0 && (
575
+ <p className="text-xs text-[var(--kyro-text-muted)] text-center py-4">
576
+ No history yet
577
+ </p>
578
+ )}
579
+ {history.map((item) => (
580
+ <button type="button"
581
+ key={item.id}
582
+ onClick={() => {
583
+ setCurrentRequest((prev) => ({
584
+ ...prev,
585
+ method: item.method as any,
586
+ url: item.url,
587
+ }));
588
+ setResponse("");
589
+ }}
590
+ className="w-full flex items-center gap-2 px-2 py-2 rounded hover:bg-[var(--kyro-surface-accent)] transition-colors"
591
+ >
592
+ <span
593
+ className={`${getMethodColor(item.method)} text-white px-1 py-0.5 rounded text-[10px] font-bold`}
594
+ >
595
+ {item.method}
596
+ </span>
597
+ <div className="flex-1 min-w-0">
598
+ <p className="text-xs text-[var(--kyro-text-secondary)] truncate">
599
+ {item.url}
600
+ </p>
601
+ <p className="text-[10px] text-[var(--kyro-text-muted)]">
602
+ {new Date(item.timestamp).toLocaleTimeString()} •{" "}
603
+ {item.duration}ms
604
+ </p>
605
+ </div>
606
+ <span
607
+ className={`${getStatusColor(item.status)} text-white px-1.5 py-0.5 rounded text-[10px] font-bold`}
608
+ >
609
+ {item.status || "ERR"}
610
+ </span>
611
+ </button>
612
+ ))}
613
+ </div>
614
+ )}
615
+
616
+ {/* Environment */}
617
+ {sidebarTab === "env" && (
618
+ <div className="space-y-3">
619
+ <div className="flex items-center justify-between mb-2">
620
+ <span className="text-xs font-bold text-[var(--kyro-text-muted)] uppercase">
621
+ Variables
622
+ </span>
623
+ <button type="button"
624
+ onClick={() => {
625
+ setEnvVars((prev) => [
626
+ ...prev,
627
+ { key: "", value: "", enabled: true },
628
+ ]);
629
+ }}
630
+ className="text-xs text-pink-500 hover:text-pink-600"
631
+ >
632
+ + Add
633
+ </button>
634
+ </div>
635
+ {envVars.map((env, i) => (
636
+ <div key={i} className="flex items-center gap-2">
637
+ <input
638
+ type="checkbox"
639
+ checked={env.enabled}
640
+ onChange={(e) => {
641
+ const newVars = [...envVars];
642
+ newVars[i].enabled = e.target.checked;
643
+ setEnvVars(newVars);
644
+ }}
645
+ className="rounded"
646
+ />
647
+ <input
648
+ type="text"
649
+ value={env.key}
650
+ onChange={(e) => {
651
+ const newVars = [...envVars];
652
+ newVars[i].key = e.target.value;
653
+ setEnvVars(newVars);
654
+ }}
655
+ placeholder="key"
656
+ className="flex-1 px-2 py-1 text-xs bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded"
657
+ />
658
+ <input
659
+ type="text"
660
+ value={env.value}
661
+ onChange={(e) => {
662
+ const newVars = [...envVars];
663
+ newVars[i].value = e.target.value;
664
+ setEnvVars(newVars);
665
+ }}
666
+ placeholder="value"
667
+ className="flex-1 px-2 py-1 text-xs bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded"
668
+ />
669
+ <button type="button"
670
+ onClick={() =>
671
+ setEnvVars((prev) => prev.filter((_, j) => j !== i))
672
+ }
673
+ className="text-red-500 hover:text-red-600"
674
+ >
675
+ <svg
676
+ className="w-4 h-4"
677
+ fill="none"
678
+ stroke="currentColor"
679
+ viewBox="0 0 24 24"
680
+ >
681
+ <path
682
+ strokeLinecap="round"
683
+ strokeLinejoin="round"
684
+ strokeWidth="2"
685
+ d="M6 18L18 6M6 6l12 12"
686
+ />
687
+ </svg>
688
+ </button>
689
+ </div>
690
+ ))}
691
+ <p className="text-xs text-[var(--kyro-text-muted)]">
692
+ Use {"{{variableName}}"} in URLs, headers, and body
693
+ </p>
694
+ </div>
695
+ )}
696
+ </div>
697
+ </div>
698
+
699
+ {/* Main Content */}
700
+ <div className="flex-1 flex flex-col min-w-0">
701
+ {/* URL Bar */}
702
+ <div className="flex items-center gap-3 p-4 border-b border-[var(--kyro-border)]">
703
+ <select
704
+ value={currentRequest.method}
705
+ onChange={(e) =>
706
+ setCurrentRequest((prev) => ({ ...prev, method: e.target.value }))
707
+ }
708
+ className={`px-3 py-2 rounded-lg font-bold text-sm ${getMethodColor(currentRequest.method)} text-white`}
709
+ >
710
+ {["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => (
711
+ <option key={m} value={m}>
712
+ {m}
713
+ </option>
714
+ ))}
715
+ </select>
716
+ <input
717
+ type="text"
718
+ value={currentRequest.url}
719
+ onChange={(e) =>
720
+ setCurrentRequest((prev) => ({ ...prev, url: e.target.value }))
721
+ }
722
+ placeholder="Enter URL or paste {{variable}}"
723
+ className="flex-1 px-4 py-2 bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-pink-500"
724
+ />
725
+ <button type="button"
726
+ onClick={executeRequest}
727
+ disabled={loading || !currentRequest.url}
728
+ className="px-6 py-2 bg-pink-500 text-white rounded-lg font-bold text-sm hover:bg-pink-600 disabled:opacity-50 transition-colors"
729
+ >
730
+ {loading ? "Sending..." : "Send"}
731
+ </button>
732
+ <button type="button"
733
+ onClick={() => setShowSaveModal(true)}
734
+ className="px-4 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg font-bold text-sm hover:bg-[var(--kyro-surface)] transition-colors"
735
+ title="Save request"
736
+ >
737
+ <svg
738
+ className="w-4 h-4"
739
+ fill="none"
740
+ stroke="currentColor"
741
+ viewBox="0 0 24 24"
742
+ >
743
+ <path
744
+ strokeLinecap="round"
745
+ strokeLinejoin="round"
746
+ strokeWidth="2"
747
+ d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
748
+ />
749
+ </svg>
750
+ </button>
751
+ <button type="button"
752
+ onClick={duplicateRequest}
753
+ className="px-4 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg font-bold text-sm hover:bg-[var(--kyro-surface)] transition-colors"
754
+ title="Duplicate"
755
+ >
756
+ <svg
757
+ className="w-4 h-4"
758
+ fill="none"
759
+ stroke="currentColor"
760
+ viewBox="0 0 24 24"
761
+ >
762
+ <path
763
+ strokeLinecap="round"
764
+ strokeLinejoin="round"
765
+ strokeWidth="2"
766
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
767
+ />
768
+ </svg>
769
+ </button>
770
+ </div>
771
+
772
+ {/* Tabs */}
773
+ <div className="flex border-b border-[var(--kyro-border)]">
774
+ {(["headers", "body", "params"] as const).map((tab) => (
775
+ <button type="button"
776
+ key={tab}
777
+ onClick={() => setActiveTab(tab)}
778
+ className={`px-4 py-2 text-sm font-bold transition-colors ${
779
+ activeTab === tab
780
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)] border-b-2 border-pink-500"
781
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
782
+ }`}
783
+ >
784
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
785
+ {tab === "headers" && (
786
+ <span className="ml-2 text-xs text-[var(--kyro-text-muted)]">
787
+ ({Object.keys(currentRequest.headers).filter((k) => k).length}
788
+ )
789
+ </span>
790
+ )}
791
+ </button>
792
+ ))}
793
+ </div>
794
+
795
+ {/* Tab Content */}
796
+ <div className="flex-1 overflow-auto p-4">
797
+ {activeTab === "headers" && (
798
+ <div className="space-y-2">
799
+ {Object.entries(currentRequest.headers).map(([key, value], i) => (
800
+ <div key={i} className="flex items-center gap-2">
801
+ <input
802
+ type="text"
803
+ value={key}
804
+ onChange={(e) => updateHeader(e.target.value, value)}
805
+ placeholder="Header name"
806
+ className="flex-1 px-3 py-2 bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded text-sm focus:outline-none focus:border-pink-500"
807
+ />
808
+ <input
809
+ type="text"
810
+ value={value}
811
+ onChange={(e) => updateHeader(key, e.target.value)}
812
+ placeholder="Value"
813
+ className="flex-1 px-3 py-2 bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded text-sm font-mono focus:outline-none focus:border-pink-500"
814
+ />
815
+ <button type="button"
816
+ onClick={() => removeHeader(key)}
817
+ className="p-2 text-red-500 hover:text-red-600"
818
+ >
819
+ <svg
820
+ className="w-4 h-4"
821
+ fill="none"
822
+ stroke="currentColor"
823
+ viewBox="0 0 24 24"
824
+ >
825
+ <path
826
+ strokeLinecap="round"
827
+ strokeLinejoin="round"
828
+ strokeWidth="2"
829
+ d="M6 18L18 6M6 6l12 12"
830
+ />
831
+ </svg>
832
+ </button>
833
+ </div>
834
+ ))}
835
+ <button type="button"
836
+ onClick={addHeader}
837
+ className="text-xs text-pink-500 hover:text-pink-600 font-medium"
838
+ >
839
+ + Add Header
840
+ </button>
841
+ </div>
842
+ )}
843
+
844
+ {activeTab === "body" && (
845
+ <textarea
846
+ value={currentRequest.body}
847
+ onChange={(e) =>
848
+ setCurrentRequest((prev) => ({ ...prev, body: e.target.value }))
849
+ }
850
+ placeholder='{"key": "value"}'
851
+ className="w-full h-full min-h-[200px] p-4 bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg text-sm font-mono resize-none focus:outline-none focus:border-pink-500"
852
+ />
853
+ )}
854
+
855
+ {activeTab === "params" && (
856
+ <div className="text-sm text-[var(--kyro-text-secondary)]">
857
+ <p>Add query parameters to your URL using:</p>
858
+ <code className="block mt-2 p-2 bg-[var(--kyro-surface-accent)] rounded text-xs">
859
+ ?param1=value1&param2=value2
860
+ </code>
861
+ </div>
862
+ )}
863
+ </div>
864
+
865
+ {/* Response */}
866
+ <div className="border-t border-[var(--kyro-border)]">
867
+ <div className="flex items-center justify-between px-4 py-2 bg-[var(--kyro-surface-accent)]">
868
+ <span className="text-sm font-bold text-[var(--kyro-text-secondary)]">
869
+ Response
870
+ </span>
871
+ <div className="flex items-center gap-3">
872
+ {responseStatus && (
873
+ <>
874
+ <span
875
+ className={`px-2 py-0.5 rounded text-xs font-bold text-white ${getStatusColor(responseStatus)}`}
876
+ >
877
+ {responseStatus}
878
+ </span>
879
+ {responseTime && (
880
+ <span className="text-xs text-[var(--kyro-text-muted)]">
881
+ {responseTime}ms
882
+ </span>
883
+ )}
884
+ </>
885
+ )}
886
+ </div>
887
+ </div>
888
+ <pre className="max-h-64 overflow-auto p-4 text-xs font-mono bg-[var(--kyro-surface)]">
889
+ {response || "Send a request to see the response"}
890
+ </pre>
891
+ </div>
892
+ </div>
893
+
894
+ {/* Save Modal */}
895
+ {showSaveModal && (
896
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
897
+ <div className="bg-[var(--kyro-surface)] rounded-lg p-6 w-96 border border-[var(--kyro-border)]">
898
+ <h3 className="text-lg font-bold text-[var(--kyro-text-primary)] mb-4">
899
+ Save Request
900
+ </h3>
901
+ <div className="space-y-4">
902
+ <div>
903
+ <label className="block text-sm font-bold text-[var(--kyro-text-secondary)] mb-1">
904
+ Name
905
+ </label>
906
+ <input
907
+ type="text"
908
+ value={saveName}
909
+ onChange={(e) => setSaveName(e.target.value)}
910
+ placeholder={currentRequest.name}
911
+ className="w-full px-3 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded text-sm focus:outline-none focus:border-pink-500"
912
+ />
913
+ </div>
914
+ <div>
915
+ <label className="block text-sm font-bold text-[var(--kyro-text-secondary)] mb-1">
916
+ Folder
917
+ </label>
918
+ <select
919
+ value={saveFolder || ""}
920
+ onChange={(e) => setSaveFolder(e.target.value || null)}
921
+ className="w-full px-3 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded text-sm focus:outline-none focus:border-pink-500"
922
+ >
923
+ <option value="">Uncategorized</option>
924
+ {folders.map((f) => (
925
+ <option key={f.id} value={f.id}>
926
+ {f.name}
927
+ </option>
928
+ ))}
929
+ </select>
930
+ </div>
931
+ </div>
932
+ <div className="flex gap-3 mt-6">
933
+ <button type="button"
934
+ onClick={() => setShowSaveModal(false)}
935
+ className="flex-1 px-4 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg font-bold text-sm hover:bg-[var(--kyro-surface)]"
936
+ >
937
+ Cancel
938
+ </button>
939
+ <button type="button"
940
+ onClick={saveRequest}
941
+ className="flex-1 px-4 py-2 bg-pink-500 text-white rounded-lg font-bold text-sm hover:bg-pink-600"
942
+ >
943
+ Save
944
+ </button>
945
+ </div>
946
+ </div>
947
+ </div>
948
+ )}
949
+ </div>
950
+ );
951
+ }