@kyro-cms/admin 0.1.6 → 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 (163) hide show
  1. package/README.md +149 -51
  2. package/package.json +53 -6
  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 +23 -6
  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 +70 -11
  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 +200 -139
  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 +42 -24
  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 +11 -11
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +13 -13
  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
@@ -0,0 +1,627 @@
1
+ import React, {
2
+ useState,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ Suspense,
7
+ lazy,
8
+ } from "react";
9
+
10
+ interface GraphQLPlaygroundProps {
11
+ endpoint?: string;
12
+ initialQuery?: string;
13
+ initialVariables?: string;
14
+ }
15
+
16
+ interface QueryTab {
17
+ id: string;
18
+ name: string;
19
+ query: string;
20
+ variables: string;
21
+ headers: string;
22
+ }
23
+
24
+ // Lazy-load CodeMirror
25
+ const CodeMirrorEditor = lazy(() =>
26
+ import("@uiw/react-codemirror").then((mod) => ({ default: mod.default })),
27
+ );
28
+ import { javascript } from "@codemirror/lang-javascript";
29
+ import { githubLight } from "@uiw/codemirror-theme-github";
30
+ import { aura } from "@uiw/codemirror-theme-aura";
31
+ import "graphiql/graphiql.css";
32
+
33
+ const DEFAULT_QUERY = `# Welcome to Kyro CMS GraphQL Playground
34
+ # Try these example queries:
35
+
36
+ # 1. Introspection - Discover the schema
37
+ {
38
+ _schema {
39
+ types {
40
+ name
41
+ fields {
42
+ name
43
+ type
44
+ required
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ # 2. List all collections
51
+ {
52
+ collections {
53
+ slug
54
+ name
55
+ fields
56
+ }
57
+ }
58
+
59
+ # 3. Ping the API
60
+ {
61
+ ping
62
+ }
63
+
64
+ # 4. Query posts (if available)
65
+ {
66
+ posts(page: 1, limit: 10) {
67
+ docs {
68
+ id
69
+ title
70
+ slug
71
+ status
72
+ }
73
+ totalDocs
74
+ totalPages
75
+ page
76
+ }
77
+ }
78
+ `;
79
+
80
+ export function GraphQLPlayground({
81
+ endpoint = "/api/graphql",
82
+ initialQuery,
83
+ initialVariables,
84
+ }: GraphQLPlaygroundProps) {
85
+ const [token, setToken] = useState<string>("");
86
+ const [isConnected, setIsConnected] = useState(false);
87
+ const [tabs, setTabs] = useState<QueryTab[]>([
88
+ {
89
+ id: "default",
90
+ name: "Query",
91
+ query: initialQuery || DEFAULT_QUERY,
92
+ variables: initialVariables || "{}",
93
+ headers: "",
94
+ },
95
+ ]);
96
+ const [currentTabId, setCurrentTabId] = useState("default");
97
+ const [response, setResponse] = useState<string>("");
98
+ const [isLoading, setIsLoading] = useState(false);
99
+ const [error, setError] = useState<string | null>(null);
100
+ const [activeTab, setActiveTab] = useState<"query" | "variables" | "headers">(
101
+ "query",
102
+ );
103
+ const [isDark, setIsDark] = useState(false);
104
+ const [isMounted, setIsMounted] = useState(false);
105
+
106
+ useEffect(() => {
107
+ setIsMounted(true);
108
+ setIsDark(document.documentElement.classList.contains("dark"));
109
+
110
+ const storedToken = localStorage.getItem("graphql_auth_token");
111
+ if (storedToken) {
112
+ setToken(storedToken);
113
+ setIsConnected(true);
114
+ }
115
+
116
+ const observer = new MutationObserver(() => {
117
+ setIsDark(document.documentElement.classList.contains("dark"));
118
+ });
119
+
120
+ observer.observe(document.documentElement, {
121
+ attributes: true,
122
+ attributeFilter: ["class"],
123
+ });
124
+
125
+ return () => observer.disconnect();
126
+ }, []);
127
+
128
+ const currentTab = tabs.find((t) => t.id === currentTabId) || tabs[0];
129
+
130
+ const handleTokenSubmit = useCallback(
131
+ (e: React.FormEvent) => {
132
+ e.preventDefault();
133
+ if (token.trim()) {
134
+ localStorage.setItem("graphql_auth_token", token.trim());
135
+ setIsConnected(true);
136
+ setTabs((prev) =>
137
+ prev.map((tab) => ({
138
+ ...tab,
139
+ headers: JSON.stringify(
140
+ { Authorization: `Bearer ${token.trim()}` },
141
+ null,
142
+ 2,
143
+ ),
144
+ })),
145
+ );
146
+ }
147
+ },
148
+ [token],
149
+ );
150
+
151
+ const clearToken = useCallback(() => {
152
+ localStorage.removeItem("graphql_auth_token");
153
+ setToken("");
154
+ setIsConnected(false);
155
+ }, []);
156
+
157
+ const executeQuery = useCallback(async () => {
158
+ if (!currentTab.query.trim()) {
159
+ setError("Please enter a query");
160
+ return;
161
+ }
162
+
163
+ setIsLoading(true);
164
+ setError(null);
165
+
166
+ try {
167
+ let variables: Record<string, any> | undefined;
168
+ if (currentTab.variables.trim()) {
169
+ try {
170
+ variables = JSON.parse(currentTab.variables);
171
+ } catch {
172
+ setError("Invalid JSON in variables");
173
+ setIsLoading(false);
174
+ return;
175
+ }
176
+ }
177
+
178
+ let headers: Record<string, string> = {
179
+ "Content-Type": "application/json",
180
+ };
181
+ if (currentTab.headers.trim()) {
182
+ try {
183
+ headers = { ...headers, ...JSON.parse(currentTab.headers) };
184
+ } catch {
185
+ setError("Invalid JSON in headers");
186
+ setIsLoading(false);
187
+ return;
188
+ }
189
+ }
190
+
191
+ const res = await fetch(endpoint, {
192
+ method: "POST",
193
+ headers,
194
+ body: JSON.stringify({
195
+ query: currentTab.query,
196
+ variables,
197
+ }),
198
+ });
199
+
200
+ const data = await res.json();
201
+ setResponse(JSON.stringify(data, null, 2));
202
+ } catch (err) {
203
+ setError(err instanceof Error ? err.message : "Request failed");
204
+ } finally {
205
+ setIsLoading(false);
206
+ }
207
+ }, [currentTab, endpoint]);
208
+
209
+ const addNewTab = useCallback(() => {
210
+ const newTab: QueryTab = {
211
+ id: `tab-${Date.now()}`,
212
+ name: `Query ${tabs.length + 1}`,
213
+ query: "# New Query\n{\n \n}",
214
+ variables: "{}",
215
+ headers: token
216
+ ? JSON.stringify({ Authorization: `Bearer ${token}` }, null, 2)
217
+ : "",
218
+ };
219
+ setTabs((prev) => [...prev, newTab]);
220
+ setCurrentTabId(newTab.id);
221
+ }, [tabs.length, token]);
222
+
223
+ const closeTab = useCallback(
224
+ (tabId: string, e: React.MouseEvent) => {
225
+ e.stopPropagation();
226
+ if (tabs.length <= 1) return;
227
+
228
+ const newTabs = tabs.filter((t) => t.id !== tabId);
229
+ setTabs(newTabs);
230
+ if (currentTabId === tabId) {
231
+ setCurrentTabId(newTabs[0].id);
232
+ }
233
+ },
234
+ [tabs, currentTabId],
235
+ );
236
+
237
+ const updateTab = useCallback(
238
+ (field: keyof QueryTab, value: string) => {
239
+ setTabs((prev) =>
240
+ prev.map((tab) =>
241
+ tab.id === currentTabId ? { ...tab, [field]: value } : tab,
242
+ ),
243
+ );
244
+ },
245
+ [currentTabId],
246
+ );
247
+
248
+ // Keyboard shortcut: Ctrl+Enter to run
249
+ useEffect(() => {
250
+ const handleKeyDown = (e: KeyboardEvent) => {
251
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
252
+ e.preventDefault();
253
+ executeQuery();
254
+ }
255
+ };
256
+
257
+ document.addEventListener("keydown", handleKeyDown);
258
+ return () => document.removeEventListener("keydown", handleKeyDown);
259
+ }, [executeQuery]);
260
+
261
+ const theme = isDark ? aura : githubLight;
262
+
263
+ const extensions = useMemo(() => [javascript({ jsx: true })], []);
264
+
265
+ return (
266
+ <div className="h-full flex flex-col bg-[var(--kyro-surface)] rounded-lg overflow-hidden border border-[var(--kyro-border)]">
267
+ {/* Header */}
268
+ <div className="flex items-center justify-between px-4 py-3 bg-[var(--kyro-surface-accent)] border-b border-[var(--kyro-border)]">
269
+ <div className="flex items-center gap-4">
270
+ <div className="flex items-center gap-2">
271
+ <svg
272
+ className="w-5 h-5 text-pink-500"
273
+ viewBox="0 0 24 24"
274
+ fill="none"
275
+ stroke="currentColor"
276
+ >
277
+ <path
278
+ strokeLinecap="round"
279
+ strokeLinejoin="round"
280
+ strokeWidth="2"
281
+ d="M13 10V3L4 14h7v7l9-11h-7z"
282
+ />
283
+ </svg>
284
+ <span className="font-bold text-sm text-[var(--kyro-text-primary)]">
285
+ GraphQL
286
+ </span>
287
+ </div>
288
+ <div className="flex items-center gap-1 px-2 py-1 rounded bg-green-500/10">
289
+ <div
290
+ className={`w-2 h-2 rounded-full ${
291
+ isConnected ? "bg-green-500" : "bg-gray-500"
292
+ }`}
293
+ />
294
+ <span className="text-xs text-[var(--kyro-text-secondary)]">
295
+ {isConnected ? "Connected" : "Not connected"}
296
+ </span>
297
+ </div>
298
+ </div>
299
+
300
+ <div className="flex items-center gap-3">
301
+ {isConnected && (
302
+ <span className="text-xs text-[var(--kyro-text-muted)] font-mono">
303
+ {token.slice(0, 12)}...
304
+ </span>
305
+ )}
306
+ <button type="button"
307
+ onClick={clearToken}
308
+ className="text-xs text-pink-500 hover:text-pink-600 font-medium"
309
+ >
310
+ {isConnected ? "Disconnect" : "Connect"}
311
+ </button>
312
+ </div>
313
+ </div>
314
+
315
+ {/* Token Input (when not connected) */}
316
+ {!isConnected && (
317
+ <div className="p-6 border-b border-[var(--kyro-border)]">
318
+ <form onSubmit={handleTokenSubmit} className="flex gap-3">
319
+ <input
320
+ type="text"
321
+ value={token}
322
+ onChange={(e) => setToken(e.target.value)}
323
+ placeholder="Enter auth token (Bearer ...)"
324
+ 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"
325
+ />
326
+ <button
327
+ type="submit"
328
+ disabled={!token.trim()}
329
+ className="px-6 py-2 bg-pink-500 text-white rounded-lg font-bold text-sm hover:bg-pink-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
330
+ >
331
+ Connect
332
+ </button>
333
+ </form>
334
+ <p className="text-xs text-[var(--kyro-text-muted)] mt-2">
335
+ Get your token from the Auth section or login endpoint
336
+ </p>
337
+ </div>
338
+ )}
339
+
340
+ {/* Tabs */}
341
+ <div className="flex items-center gap-1 px-2 py-2 bg-[var(--kyro-surface-accent)] border-b border-[var(--kyro-border)] overflow-x-auto">
342
+ {tabs.map((tab) => (
343
+ <div
344
+ key={tab.id}
345
+ className={`group flex items-center gap-2 px-3 py-1.5 rounded-t cursor-pointer transition-colors ${
346
+ currentTabId === tab.id
347
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
348
+ : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)]"
349
+ }`}
350
+ onClick={() => setCurrentTabId(tab.id)}
351
+ >
352
+ <span className="text-xs font-medium">{tab.name}</span>
353
+ {tabs.length > 1 && (
354
+ <button type="button"
355
+ onClick={(e) => closeTab(tab.id, e)}
356
+ className="opacity-0 group-hover:opacity-100 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
357
+ >
358
+ <svg
359
+ className="w-3 h-3"
360
+ fill="none"
361
+ stroke="currentColor"
362
+ viewBox="0 0 24 24"
363
+ >
364
+ <path
365
+ strokeLinecap="round"
366
+ strokeLinejoin="round"
367
+ strokeWidth="2"
368
+ d="M6 18L18 6M6 6l12 12"
369
+ />
370
+ </svg>
371
+ </button>
372
+ )}
373
+ </div>
374
+ ))}
375
+ <button type="button"
376
+ onClick={addNewTab}
377
+ className="p-1.5 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] rounded"
378
+ >
379
+ <svg
380
+ className="w-4 h-4"
381
+ fill="none"
382
+ stroke="currentColor"
383
+ viewBox="0 0 24 24"
384
+ >
385
+ <path
386
+ strokeLinecap="round"
387
+ strokeLinejoin="round"
388
+ strokeWidth="2"
389
+ d="M12 4v16m8-8H4"
390
+ />
391
+ </svg>
392
+ </button>
393
+ </div>
394
+
395
+ {/* Editor Area */}
396
+ <div className="flex-1 flex overflow-hidden">
397
+ {/* Left: Query Editor */}
398
+ <div className="flex-1 flex flex-col border-r border-[var(--kyro-border)]">
399
+ {/* Editor Toolbar */}
400
+ <div className="flex items-center gap-2 px-3 py-2 bg-[var(--kyro-surface)] border-b border-[var(--kyro-border)]">
401
+ <button type="button"
402
+ onClick={() => setActiveTab("query")}
403
+ className={`px-3 py-1 text-xs font-bold rounded transition-colors ${
404
+ activeTab === "query"
405
+ ? "bg-pink-500 text-white"
406
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
407
+ }`}
408
+ >
409
+ Query
410
+ </button>
411
+ <button type="button"
412
+ onClick={() => setActiveTab("variables")}
413
+ className={`px-3 py-1 text-xs font-bold rounded transition-colors ${
414
+ activeTab === "variables"
415
+ ? "bg-pink-500 text-white"
416
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
417
+ }`}
418
+ >
419
+ Variables
420
+ </button>
421
+ <button type="button"
422
+ onClick={() => setActiveTab("headers")}
423
+ className={`px-3 py-1 text-xs font-bold rounded transition-colors ${
424
+ activeTab === "headers"
425
+ ? "bg-pink-500 text-white"
426
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
427
+ }`}
428
+ >
429
+ Headers
430
+ </button>
431
+
432
+ <div className="flex-1" />
433
+
434
+ <button type="button"
435
+ onClick={executeQuery}
436
+ disabled={isLoading}
437
+ className="flex items-center gap-2 px-4 py-1.5 bg-pink-500 text-white rounded font-bold text-xs hover:bg-pink-600 transition-colors disabled:opacity-50"
438
+ >
439
+ {isLoading ? (
440
+ <svg
441
+ className="w-4 h-4 animate-spin"
442
+ fill="none"
443
+ viewBox="0 0 24 24"
444
+ >
445
+ <circle
446
+ className="opacity-25"
447
+ cx="12"
448
+ cy="12"
449
+ r="10"
450
+ stroke="currentColor"
451
+ strokeWidth="4"
452
+ />
453
+ <path
454
+ className="opacity-75"
455
+ fill="currentColor"
456
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
457
+ />
458
+ </svg>
459
+ ) : (
460
+ <svg
461
+ className="w-4 h-4"
462
+ fill="none"
463
+ stroke="currentColor"
464
+ viewBox="0 0 24 24"
465
+ >
466
+ <path
467
+ strokeLinecap="round"
468
+ strokeLinejoin="round"
469
+ strokeWidth="2"
470
+ d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
471
+ />
472
+ <path
473
+ strokeLinecap="round"
474
+ strokeLinejoin="round"
475
+ strokeWidth="2"
476
+ d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
477
+ />
478
+ </svg>
479
+ )}
480
+ Run
481
+ <kbd className="ml-1 px-1 py-0.5 bg-white/20 rounded text-[10px]">
482
+ ⌘↵
483
+ </kbd>
484
+ </button>
485
+ </div>
486
+
487
+ {/* Editor Content */}
488
+ <div className="flex-1 overflow-auto">
489
+ {activeTab === "query" && (
490
+ <Suspense
491
+ fallback={
492
+ <textarea
493
+ value={currentTab.query}
494
+ onChange={(e) => updateTab("query", e.target.value)}
495
+ className="w-full h-full min-h-[300px] p-4 bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)] font-mono text-sm resize-none focus:outline-none"
496
+ placeholder="Enter your GraphQL query..."
497
+ spellCheck={false}
498
+ />
499
+ }
500
+ >
501
+ {isMounted && (
502
+ <CodeMirrorEditor
503
+ value={currentTab.query}
504
+ height="100%"
505
+ extensions={extensions}
506
+ theme={theme}
507
+ onChange={(value) => updateTab("query", value)}
508
+ basicSetup={true}
509
+ style={{
510
+ height: "100%",
511
+ minHeight: "300px",
512
+ fontSize: "13px",
513
+ fontFamily: "'Fira Code', 'JetBrains Mono', monospace",
514
+ }}
515
+ />
516
+ )}
517
+ </Suspense>
518
+ )}
519
+ {activeTab === "variables" && (
520
+ <Suspense
521
+ fallback={
522
+ <textarea
523
+ value={currentTab.variables}
524
+ onChange={(e) => updateTab("variables", e.target.value)}
525
+ className="w-full h-full min-h-[300px] p-4 bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)] font-mono text-sm resize-none focus:outline-none"
526
+ placeholder='{ "key": "value" }'
527
+ spellCheck={false}
528
+ />
529
+ }
530
+ >
531
+ {isMounted && (
532
+ <CodeMirrorEditor
533
+ value={currentTab.variables}
534
+ height="100%"
535
+ extensions={extensions}
536
+ theme={theme}
537
+ onChange={(value) => updateTab("variables", value)}
538
+ basicSetup={true}
539
+ style={{
540
+ height: "100%",
541
+ minHeight: "300px",
542
+ fontSize: "13px",
543
+ fontFamily: "'Fira Code', 'JetBrains Mono', monospace",
544
+ }}
545
+ />
546
+ )}
547
+ </Suspense>
548
+ )}
549
+ {activeTab === "headers" && (
550
+ <Suspense
551
+ fallback={
552
+ <textarea
553
+ value={currentTab.headers}
554
+ onChange={(e) => updateTab("headers", e.target.value)}
555
+ className="w-full h-full min-h-[300px] p-4 bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)] font-mono text-sm resize-none focus:outline-none"
556
+ placeholder='{ "Authorization": "Bearer your-token" }'
557
+ spellCheck={false}
558
+ />
559
+ }
560
+ >
561
+ {isMounted && (
562
+ <CodeMirrorEditor
563
+ value={currentTab.headers}
564
+ height="100%"
565
+ extensions={extensions}
566
+ theme={theme}
567
+ onChange={(value) => updateTab("headers", value)}
568
+ basicSetup={true}
569
+ style={{
570
+ height: "100%",
571
+ minHeight: "300px",
572
+ fontSize: "13px",
573
+ fontFamily: "'Fira Code', 'JetBrains Mono', monospace",
574
+ }}
575
+ />
576
+ )}
577
+ </Suspense>
578
+ )}
579
+ </div>
580
+ </div>
581
+
582
+ {/* Right: Response */}
583
+ <div className="flex-1 flex flex-col">
584
+ <div className="flex items-center gap-2 px-3 py-2 bg-[var(--kyro-surface)] border-b border-[var(--kyro-border)]">
585
+ <span className="text-xs font-bold text-[var(--kyro-text-secondary)]">
586
+ Response
587
+ </span>
588
+ {error && (
589
+ <span className="px-2 py-0.5 bg-red-500/10 text-red-500 text-xs rounded">
590
+ Error
591
+ </span>
592
+ )}
593
+ </div>
594
+ <div className="flex-1 overflow-auto p-4">
595
+ {isLoading ? (
596
+ <div className="flex items-center justify-center h-full">
597
+ <div className="animate-spin w-8 h-8 border-2 border-pink-500 border-t-transparent rounded-full" />
598
+ </div>
599
+ ) : response ? (
600
+ <pre className="text-sm font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap">
601
+ {response}
602
+ </pre>
603
+ ) : (
604
+ <div className="flex flex-col items-center justify-center h-full text-[var(--kyro-text-muted)]">
605
+ <svg
606
+ className="w-12 h-12 mb-4 opacity-50"
607
+ fill="none"
608
+ stroke="currentColor"
609
+ viewBox="0 0 24 24"
610
+ >
611
+ <path
612
+ strokeLinecap="round"
613
+ strokeLinejoin="round"
614
+ strokeWidth="1.5"
615
+ d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
616
+ />
617
+ </svg>
618
+ <p className="text-sm">Run a query to see the response</p>
619
+ <p className="text-xs mt-1">Press Cmd+Enter to run</p>
620
+ </div>
621
+ )}
622
+ </div>
623
+ </div>
624
+ </div>
625
+ </div>
626
+ );
627
+ }