@kyro-cms/admin 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/index.cjs +659 -684
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +54 -51
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.js +660 -685
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/ActionBar.tsx +172 -292
  9. package/src/components/Admin.tsx +7 -1
  10. package/src/components/AutoForm.tsx +573 -367
  11. package/src/components/DetailView.tsx +22 -47
  12. package/src/components/GraphQLPlayground.tsx +473 -223
  13. package/src/components/ListView.tsx +1 -1
  14. package/src/components/MediaGallery.tsx +2 -2
  15. package/src/components/RestPlayground.tsx +482 -519
  16. package/src/components/blocks/AccordionBlock.tsx +1 -1
  17. package/src/components/blocks/ArrayBlock.tsx +1 -1
  18. package/src/components/blocks/ChildBlocksTree.tsx +6 -6
  19. package/src/components/blocks/CodeBlock.tsx +1 -1
  20. package/src/components/blocks/FileBlock.tsx +1 -1
  21. package/src/components/blocks/HeroBlock.tsx +1 -1
  22. package/src/components/blocks/ListBlock.tsx +1 -1
  23. package/src/components/blocks/RelationshipBlock.tsx +1 -1
  24. package/src/components/blocks/RichTextBlock.tsx +1 -1
  25. package/src/components/blocks/VideoBlock.tsx +1 -1
  26. package/src/components/fields/BlocksField.tsx +5 -5
  27. package/src/components/fields/RichTextField.tsx +3 -1
  28. package/src/components/ui/SplitButton.tsx +1 -1
  29. package/src/components/ui/Toast.tsx +2 -1
  30. package/src/layouts/AdminLayout.astro +16 -1
  31. package/src/pages/graphql-explorer.astro +7 -51
  32. package/src/pages/graphql.astro +7 -119
  33. package/src/pages/index.astro +4 -63
  34. package/src/pages/rest-playground.astro +3 -29
  35. package/src/styles/main.css +53 -43
@@ -2,26 +2,26 @@ import React, {
2
2
  useState,
3
3
  useCallback,
4
4
  useEffect,
5
- useMemo,
5
+ useRef,
6
6
  Suspense,
7
7
  lazy,
8
8
  } from "react";
9
9
  import {
10
10
  Book,
11
- Send,
12
11
  Trash2,
13
12
  Copy,
14
13
  RefreshCw,
15
- Settings,
16
- Maximize2,
17
14
  ChevronRight,
18
- ChevronDown,
19
- Search,
20
- Type,
21
15
  Activity,
22
16
  Zap,
23
17
  Info,
24
- X
18
+ X,
19
+ Download,
20
+ Check,
21
+ Code2,
22
+ Play,
23
+ Clock,
24
+ Terminal,
25
25
  } from "./ui/icons";
26
26
 
27
27
  interface GraphQLPlaygroundProps {
@@ -33,25 +33,47 @@ interface GraphQLPlaygroundProps {
33
33
 
34
34
  interface QueryTab {
35
35
  id: string;
36
- name: string;
37
36
  query: string;
38
37
  variables: string;
39
38
  headers: string;
40
39
  }
41
40
 
42
- // Lazy-load CodeMirror
41
+ interface HistoryEntry {
42
+ id: string;
43
+ query: string;
44
+ variables: string;
45
+ response: string;
46
+ timestamp: number;
47
+ duration: number;
48
+ statusCode: number;
49
+ }
50
+
43
51
  const CodeMirrorEditor = lazy(() =>
44
52
  import("@uiw/react-codemirror").then((mod) => ({ default: mod.default })),
45
53
  );
46
54
  import { javascript } from "@codemirror/lang-javascript";
47
- import { githubLight } from "@uiw/codemirror-theme-github";
48
55
  import { aura } from "@uiw/codemirror-theme-aura";
49
- import "graphiql/graphiql.css";
56
+
57
+ function prettifyQuery(query: string): string {
58
+ let indent = 0;
59
+ let result = "";
60
+ const lines = query.split("\n");
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed) continue;
64
+ if (trimmed.startsWith("}") || trimmed.startsWith("]")) indent = Math.max(0, indent - 1);
65
+ result += " ".repeat(indent) + trimmed + "\n";
66
+ if (trimmed.endsWith("{") || trimmed.endsWith("[")) indent++;
67
+ const opens = (trimmed.match(/\{/g) || []).length;
68
+ const closes = (trimmed.match(/\}/g) || []).length;
69
+ indent += opens - closes;
70
+ }
71
+ return result.trim();
72
+ }
50
73
 
51
74
  const DEFAULT_QUERY = `# Welcome to Kyro CMS GraphQL Playground
52
- # Try these example queries:
75
+ # Cmd+Enter to run, Cmd+Shift+P to prettify
53
76
 
54
- # 1. Introspection - Discover the schema
55
77
  {
56
78
  __schema {
57
79
  types {
@@ -65,21 +87,15 @@ const DEFAULT_QUERY = `# Welcome to Kyro CMS GraphQL Playground
65
87
  }
66
88
  }
67
89
 
68
- # 2. List all collections
69
- {
70
- collections {
71
- slug
72
- name
73
- }
74
- }
75
-
76
- # 3. Ping the API
77
- {
78
- ping
79
- }
90
+ # Example: fetch all posts (uncomment to use)
91
+ # {
92
+ # posts(page: 1, limit: 10) {
93
+ # docs { id title slug status }
94
+ # totalDocs
95
+ # }
96
+ # }
80
97
  `;
81
98
 
82
- // --- Docs Types ---
83
99
  interface TypeInfo {
84
100
  name: string;
85
101
  kind: string;
@@ -120,38 +136,40 @@ export function GraphQLPlayground({
120
136
  initialShowDocs = false,
121
137
  }: GraphQLPlaygroundProps) {
122
138
  const [token, setToken] = useState<string>("");
139
+ const [showToken, setShowToken] = useState(false);
123
140
  const [isConnected, setIsConnected] = useState(false);
124
- const [tabs, setTabs] = useState<QueryTab[]>([
125
- {
126
- id: "default",
127
- name: "Query",
128
- query: initialQuery || DEFAULT_QUERY,
129
- variables: initialVariables || "{}",
130
- headers: "",
131
- },
132
- ]);
133
- const [currentTabId, setCurrentTabId] = useState("default");
141
+ const [tab, setTab] = useState<QueryTab>({
142
+ id: "default",
143
+ query: initialQuery || DEFAULT_QUERY,
144
+ variables: initialVariables || "{}",
145
+ headers: "",
146
+ });
134
147
  const [response, setResponse] = useState<string>("");
135
148
  const [isLoading, setIsLoading] = useState(false);
136
149
  const [error, setError] = useState<string | null>(null);
137
- const [activeTab, setActiveTab] = useState<"query" | "variables" | "headers">(
138
- "query",
139
- );
150
+ const [activeEditorTab, setActiveEditorTab] = useState<"query" | "variables" | "headers">("query");
140
151
  const [isMounted, setIsMounted] = useState(false);
141
152
  const [showDocs, setShowDocs] = useState(initialShowDocs);
142
153
  const [schema, setSchema] = useState<SchemaInfo | null>(null);
143
154
  const [loadingSchema, setLoadingSchema] = useState(false);
144
155
  const [selectedType, setSelectedType] = useState<TypeInfo | null>(null);
156
+ const [rightTab, setRightTab] = useState<"response" | "docs" | "history">(
157
+ initialShowDocs ? "docs" : "response",
158
+ );
159
+ const [history, setHistory] = useState<HistoryEntry[]>([]);
160
+ const [lastDuration, setLastDuration] = useState<number>(0);
161
+ const [lastStatus, setLastStatus] = useState<number>(0);
162
+ const [copied, setCopied] = useState(false);
163
+ const [splitPos, setSplitPos] = useState(50);
164
+ const [mobilePanel, setMobilePanel] = useState<"editor" | "response">("editor");
165
+ const [isDragging, setIsDragging] = useState(false);
166
+ const containerRef = useRef<HTMLDivElement>(null);
167
+ const cursorPos = useRef({ line: 1, col: 1 });
145
168
 
146
169
  useEffect(() => {
147
170
  setIsMounted(true);
148
171
  }, []);
149
172
 
150
- const currentTab = useMemo(
151
- () => tabs.find((t) => t.id === currentTabId) || tabs[0],
152
- [tabs, currentTabId],
153
- );
154
-
155
173
  const fetchSchema = useCallback(async () => {
156
174
  setLoadingSchema(true);
157
175
  try {
@@ -212,55 +230,110 @@ export function GraphQLPlayground({
212
230
  }, [endpoint]);
213
231
 
214
232
  useEffect(() => {
215
- if (showDocs && !schema) {
216
- fetchSchema();
217
- }
233
+ if (showDocs && !schema) fetchSchema();
218
234
  }, [showDocs, schema, fetchSchema]);
219
235
 
220
- const handleRun = async () => {
236
+ const handleRun = useCallback(async () => {
221
237
  setIsLoading(true);
222
238
  setError(null);
239
+ const startTime = performance.now();
223
240
  try {
224
241
  const headers: Record<string, string> = {
225
242
  "Content-Type": "application/json",
226
243
  };
227
244
  if (token) headers["Authorization"] = `Bearer ${token}`;
228
-
229
- // Add custom headers from tab
230
- if (currentTab.headers) {
245
+ if (tab.headers) {
231
246
  try {
232
- const customHeaders = JSON.parse(currentTab.headers);
247
+ const customHeaders = JSON.parse(tab.headers);
233
248
  Object.assign(headers, customHeaders);
234
- } catch (e) {
235
- console.warn("Invalid custom headers JSON");
236
- }
249
+ } catch { /* ignore invalid */ }
237
250
  }
238
251
 
239
252
  const res = await fetch(endpoint, {
240
253
  method: "POST",
241
254
  headers,
242
255
  body: JSON.stringify({
243
- query: currentTab.query,
244
- variables: currentTab.variables ? JSON.parse(currentTab.variables) : {},
256
+ query: tab.query,
257
+ variables: tab.variables ? JSON.parse(tab.variables) : {},
245
258
  }),
246
259
  });
247
260
 
261
+ const endTime = performance.now();
262
+ const duration = Math.round(endTime - startTime);
263
+ setLastDuration(duration);
264
+ setLastStatus(res.status);
265
+
248
266
  const data = await res.json();
249
- setResponse(JSON.stringify(data, null, 2));
267
+ const formatted = JSON.stringify(data, null, 2);
268
+ setResponse(formatted);
250
269
  if (data.errors) setError("Query returned errors");
270
+
271
+ setHistory((prev) => [
272
+ {
273
+ id: Date.now().toString(),
274
+ query: tab.query,
275
+ variables: tab.variables,
276
+ response: formatted,
277
+ timestamp: Date.now(),
278
+ duration,
279
+ statusCode: res.status,
280
+ },
281
+ ...prev.slice(0, 49),
282
+ ]);
251
283
  } catch (err: unknown) {
252
284
  const message = err instanceof Error ? err.message : "Request failed";
253
- setError(message || "Request failed");
285
+ setError(message);
254
286
  setResponse(JSON.stringify({ error: message }, null, 2));
255
287
  } finally {
256
288
  setIsLoading(false);
257
289
  }
290
+ }, [endpoint, token, tab]);
291
+
292
+ useEffect(() => {
293
+ const handler = (e: KeyboardEvent) => {
294
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
295
+ e.preventDefault();
296
+ handleRun();
297
+ }
298
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "p") {
299
+ e.preventDefault();
300
+ handlePrettify();
301
+ }
302
+ };
303
+ window.addEventListener("keydown", handler);
304
+ return () => window.removeEventListener("keydown", handler);
305
+ }, [handleRun]);
306
+
307
+ const updateTab = (key: "query" | "variables" | "headers", value: string) => {
308
+ setTab((prev) => ({ ...prev, [key]: value }));
309
+ };
310
+
311
+ const handlePrettify = () => {
312
+ const pretty = prettifyQuery(tab.query);
313
+ setTab((prev) => ({ ...prev, query: pretty }));
314
+ };
315
+
316
+ const handleCopyResponse = async () => {
317
+ if (response) {
318
+ await navigator.clipboard.writeText(response);
319
+ setCopied(true);
320
+ setTimeout(() => setCopied(false), 2000);
321
+ }
322
+ };
323
+
324
+ const handleDownloadResponse = () => {
325
+ if (!response) return;
326
+ const blob = new Blob([response], { type: "application/json" });
327
+ const url = URL.createObjectURL(blob);
328
+ const a = document.createElement("a");
329
+ a.href = url;
330
+ a.download = "graphql-response.json";
331
+ a.click();
332
+ URL.revokeObjectURL(url);
258
333
  };
259
334
 
260
- const updateTab = (key: keyof QueryTab, value: string) => {
261
- setTabs((prev) =>
262
- prev.map((t) => (t.id === currentTabId ? { ...t, [key]: value } : t)),
263
- );
335
+ const handleClearEditor = () => {
336
+ setTab((prev) => ({ ...prev, query: "" }));
264
337
  };
265
338
 
266
339
  const extensions = [javascript()];
@@ -268,218 +341,395 @@ export function GraphQLPlayground({
268
341
 
269
342
  const renderType = (type: Record<string, unknown>): string => {
270
343
  if (!type) return "Unknown";
271
- if (type.name) return type.name;
344
+ if (type.name) return type.name as string;
272
345
  if (type.ofType) {
273
- if (type.kind === "NON_NULL") return `${renderType(type.ofType)}!`;
274
- if (type.kind === "LIST") return `[${renderType(type.ofType)}]`;
275
- return renderType(type.ofType);
346
+ if (type.kind === "NON_NULL") return `${renderType(type.ofType as Record<string, unknown>)}!`;
347
+ if (type.kind === "LIST") return `[${renderType(type.ofType as Record<string, unknown>)}]`;
348
+ return renderType(type.ofType as Record<string, unknown>);
276
349
  }
277
350
  return "Unknown";
278
351
  };
279
352
 
353
+ const startDrag = useCallback((e: React.MouseEvent) => {
354
+ e.preventDefault();
355
+ setIsDragging(true);
356
+ }, []);
357
+
358
+ useEffect(() => {
359
+ if (!isDragging) return;
360
+ const onMove = (e: MouseEvent) => {
361
+ if (!containerRef.current) return;
362
+ const rect = containerRef.current.getBoundingClientRect();
363
+ const pct = ((e.clientX - rect.left) / rect.width) * 100;
364
+ setSplitPos(Math.max(20, Math.min(80, pct)));
365
+ };
366
+ const onUp = () => setIsDragging(false);
367
+ window.addEventListener("mousemove", onMove);
368
+ window.addEventListener("mouseup", onUp);
369
+ return () => {
370
+ window.removeEventListener("mousemove", onMove);
371
+ window.removeEventListener("mouseup", onUp);
372
+ };
373
+ }, [isDragging]);
374
+
375
+ const editorPills = [
376
+ { key: "query" as const, label: "Query", shortcut: "" },
377
+ { key: "variables" as const, label: "Variables", shortcut: "" },
378
+ { key: "headers" as const, label: "Headers", shortcut: "" },
379
+ ];
380
+
381
+ const rightPills = [
382
+ { key: "response" as const, label: "Response", badge: lastStatus ? `${lastStatus}ms` : undefined },
383
+ { key: "docs" as const, label: "Docs" },
384
+ { key: "history" as const, label: `History${history.length ? ` (${history.length})` : ""}` },
385
+ ];
386
+
387
+ const editorValue =
388
+ activeEditorTab === "query"
389
+ ? tab.query
390
+ : activeEditorTab === "variables"
391
+ ? tab.variables
392
+ : tab.headers;
393
+
280
394
  return (
281
- <div className="h-full flex flex-col bg-[var(--kyro-bg)] overflow-hidden rounded-lg border border-[var(--kyro-border)]">
282
- {/* Header */}
283
- <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
284
- <div className="flex items-center gap-4">
285
- <div className="flex items-center gap-2">
286
- <div className="w-8 h-8 rounded-lg bg-pink-500/10 flex items-center justify-center text-pink-500">
287
- <Zap className="w-4 h-4" />
288
- </div>
289
- <span className="font-bold text-sm tracking-tight">GraphQL Playground</span>
395
+ <div ref={containerRef} className="h-full flex flex-col bg-[var(--kyro-bg)] overflow-hidden rounded-lg border border-[var(--kyro-border)]">
396
+ {/* Compact top bar */}
397
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] shrink-0">
398
+ <div className="flex items-center gap-1.5 min-w-0">
399
+ <div className="w-7 h-7 rounded-lg bg-[var(--kyro-primary)]/10 flex items-center justify-center text-[var(--kyro-primary)] shrink-0">
400
+ <Zap className="w-3.5 h-3.5" />
290
401
  </div>
291
- <div className="h-4 w-px bg-[var(--kyro-border)]" />
402
+ <span className="text-xs font-semibold hidden sm:inline">GraphQL</span>
403
+ </div>
404
+ <div className="flex items-center gap-1.5 text-[10px] text-[var(--kyro-text-muted)]">
405
+ <div className={`w-1.5 h-1.5 rounded-full ${isConnected ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-danger)]"}`} />
406
+ <span className="hidden sm:inline">{isConnected ? "Connected" : "Disconnected"}</span>
407
+ </div>
408
+ <div className="h-4 w-px bg-[var(--kyro-border)]" />
409
+ {token ? (
410
+ <div className="flex items-center gap-1 px-2 py-1 rounded-md bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] text-[10px] font-mono text-[var(--kyro-text-muted)]">
411
+ <span className="w-2 h-2 rounded-full bg-[var(--kyro-success)]" />
412
+ Bearer ••••{token.slice(-4)}
413
+ <button onClick={() => { setToken(""); setShowToken(false); }} className="ml-1 hover:text-[var(--kyro-danger)]">
414
+ <X className="w-3 h-3" />
415
+ </button>
416
+ </div>
417
+ ) : (
292
418
  <div className="flex items-center gap-1">
293
- <div className={`w-2 h-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500"} animate-pulse`} />
294
- <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)]">
295
- {isConnected ? "Connected" : "Disconnected"}
296
- </span>
419
+ <input
420
+ type={showToken ? "text" : "password"}
421
+ value={token}
422
+ onChange={(e) => setToken(e.target.value)}
423
+ placeholder="Bearer token"
424
+ className="w-24 px-2 py-1 text-[10px] font-mono 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)]"
425
+ />
426
+ <button onClick={() => setShowToken(!showToken)} className="p-1 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
427
+ {showToken ? <X className="w-3 h-3" /> : <Info className="w-3 h-3" />}
428
+ </button>
297
429
  </div>
298
- </div>
299
- <div className="flex items-center gap-2">
300
- <button
301
- onClick={() => setShowDocs(!showDocs)}
302
- className={`kyro-btn kyro-btn-md flex items-center gap-2 ${showDocs
303
- ? "bg-pink-500 text-white border-pink-500 hover:bg-pink-600 hover:border-pink-600 shadow-[0_0_15px_rgba(236,72,153,0.3)]"
304
- : "kyro-btn-ghost"
305
- }`}
306
- >
430
+ )}
431
+ <div className="ml-auto flex items-center gap-1 flex-wrap justify-end">
432
+ <button onClick={() => { setShowDocs(!showDocs); setRightTab("docs"); }} className={`p-1.5 rounded-lg transition-all ${rightTab === "docs" && showDocs ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title="Schema docs">
307
433
  <Book className="w-3.5 h-3.5" />
308
- Documentation
309
434
  </button>
435
+ <button onClick={() => setRightTab("history")} className={`p-1.5 rounded-lg transition-all ${rightTab === "history" ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title="History">
436
+ <Clock className="w-3.5 h-3.5" />
437
+ </button>
438
+ <button onClick={handlePrettify} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Prettify (Cmd+Shift+P)">
439
+ <Code2 className="w-3.5 h-3.5" />
440
+ </button>
441
+ <button onClick={handleCopyResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Copy response">
442
+ {copied ? <Check className="w-3.5 h-3.5 text-[var(--kyro-success)]" /> : <Copy className="w-3.5 h-3.5" />}
443
+ </button>
444
+ <button onClick={handleDownloadResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Download response">
445
+ <Download className="w-3.5 h-3.5" />
446
+ </button>
447
+ <button onClick={handleClearEditor} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger-bg)]" title="Clear editor">
448
+ <Trash2 className="w-3.5 h-3.5" />
449
+ </button>
450
+ <div className="h-4 w-px bg-[var(--kyro-border)] mx-1" />
310
451
  <button
311
452
  onClick={handleRun}
312
453
  disabled={isLoading}
313
- className="kyro-btn kyro-btn-md kyro-btn-primary flex items-center gap-2"
454
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-[var(--kyro-primary)] text-white text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
314
455
  >
315
- <Send className="w-3.5 h-3.5" />
316
- {isLoading ? "Running..." : "Run Query"}
456
+ <Play className="w-3 h-3" />
457
+ {isLoading ? "..." : "Run"}
317
458
  </button>
318
459
  </div>
319
460
  </div>
320
461
 
321
- <div className="flex-1 flex overflow-hidden">
322
- {/* Left: Editor and Schema */}
323
- <div className={`flex flex-col border-r border-[var(--kyro-border)] transition-all duration-300 ${showDocs ? "w-1/2" : "w-1/2"}`}>
324
- {/* Tabs for Query/Vars/Headers */}
325
- <div className="flex border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
326
- {(["query", "variables", "headers"] as const).map((tab) => (
462
+ {/* Main split area */}
463
+ <div className="flex-1 flex flex-col md:flex-row overflow-hidden relative">
464
+ {/* Mobile panel switcher */}
465
+ <div className="flex md:hidden gap-1 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] shrink-0">
466
+ <button
467
+ onClick={() => setMobilePanel("editor")}
468
+ className={`flex-1 px-3 py-1.5 text-[10px] font-semibold rounded-md transition-all ${mobilePanel === "editor" ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`}
469
+ >
470
+ Editor
471
+ </button>
472
+ <button
473
+ onClick={() => setMobilePanel("response")}
474
+ className={`flex-1 px-3 py-1.5 text-[10px] font-semibold rounded-md transition-all ${mobilePanel === "response" ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`}
475
+ >
476
+ Output
477
+ </button>
478
+ </div>
479
+
480
+ {/* Left: editor */}
481
+ <div className={`${mobilePanel === "editor" ? "flex" : "hidden"} md:flex flex-col overflow-hidden border-r border-[var(--kyro-border)] w-full md:w-auto`} style={{ flex: 'none', width: typeof window !== "undefined" && window.innerWidth >= 768 ? `${splitPos}%` : "100%" }}>
482
+ {/* Editor pills */}
483
+ <div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
484
+ {editorPills.map((p) => (
327
485
  <button
328
- key={tab}
329
- onClick={() => setActiveTab(tab)}
330
- className={`px-4 py-2 text-xs font-bold tracking-widest transition-all ${activeTab === tab
331
- ? "text-pink-500 border-b-2 border-pink-500"
332
- : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
486
+ key={p.key}
487
+ onClick={() => setActiveEditorTab(p.key)}
488
+ className={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${activeEditorTab === p.key
489
+ ? "bg-[var(--kyro-primary)] text-white"
490
+ : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
333
491
  }`}
334
492
  >
335
- {tab}
493
+ {p.label}
336
494
  </button>
337
495
  ))}
338
496
  </div>
339
497
 
498
+ {/* CodeMirror */}
340
499
  <div className="flex-1 overflow-hidden relative bg-[var(--kyro-bg)]">
341
- <Suspense fallback={<div className="p-4 text-xs">Loading editor...</div>}>
500
+ <Suspense fallback={<div className="p-3 text-[10px] text-[var(--kyro-text-muted)]">Loading editor...</div>}>
342
501
  {isMounted && (
343
502
  <CodeMirrorEditor
344
- value={activeTab === "query" ? currentTab.query : activeTab === "variables" ? currentTab.variables : currentTab.headers}
503
+ value={editorValue}
345
504
  height="100%"
346
505
  extensions={extensions}
347
506
  theme={theme}
348
- onChange={(val) => updateTab(activeTab, val)}
349
- basicSetup={true}
507
+ onChange={(val) => updateTab(activeEditorTab, val)}
508
+ basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true }}
350
509
  style={{
351
510
  height: "100%",
352
- fontSize: "13px",
511
+ fontSize: "12px",
353
512
  fontFamily: "'Fira Code', monospace",
354
513
  }}
355
514
  />
356
515
  )}
357
516
  </Suspense>
358
517
  </div>
518
+
519
+ {/* Status bar */}
520
+ <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">
521
+ <span>Ln {cursorPos.current.line}, Col {cursorPos.current.col}</span>
522
+ <span>{tab.query.length} chars</span>
523
+ </div>
359
524
  </div>
360
525
 
361
- {/* Center/Right: Response or Docs */}
362
- <div className="flex-1 flex flex-col min-w-0">
363
- {showDocs ? (
364
- <div className="flex-1 flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
365
- <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)]">
366
- <span className="text-xs font-bold tracking-widest text-[var(--kyro-text-secondary)]">Schema Explorer</span>
367
- <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-red-500">
368
- <X className="w-4 h-4" />
369
- </button>
370
- </div>
371
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
372
- {loadingSchema ? (
373
- <div className="flex items-center justify-center h-40">
374
- <RefreshCw className="w-6 h-6 animate-spin text-pink-500" />
375
- </div>
376
- ) : schema ? (
377
- <div className="space-y-6">
378
- {selectedType ? (
379
- <div className="space-y-4">
380
- <button
381
- onClick={() => setSelectedType(null)}
382
- className="flex items-center gap-1 text-xs text-pink-500 font-bold hover:underline"
383
- >
384
- ← Back to types
385
- </button>
386
- <div>
387
- <h3 className="text-lg font-bold text-[var(--kyro-text-primary)]">{selectedType.name}</h3>
388
- <p className="text-xs text-[var(--kyro-text-muted)] italic">{selectedType.kind}</p>
389
- {selectedType.description && (
390
- <p className="mt-2 text-sm text-[var(--kyro-text-secondary)] leading-relaxed">{selectedType.description}</p>
526
+ {/* Drag handle */}
527
+ <div
528
+ className="hidden md:block absolute top-0 bottom-0 z-10 w-1.5 cursor-col-resize group"
529
+ style={{ left: `calc(${splitPos}% - 3px)` }}
530
+ onMouseDown={startDrag}
531
+ >
532
+ <div className="w-0.5 h-full mx-auto bg-transparent group-hover:bg-[var(--kyro-primary)] group-hover:opacity-40 transition-all" />
533
+ </div>
534
+
535
+ {/* Right panel */}
536
+ <div className={`${mobilePanel === "response" ? "flex" : "hidden"} md:flex flex-1 flex-col overflow-hidden min-w-0 w-full md:w-auto`} style={{ flex: undefined, width: typeof window !== "undefined" && window.innerWidth >= 768 ? `${100 - splitPos}%` : "100%" }}>
537
+ {/* Right pills */}
538
+ <div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
539
+ {rightPills.map((p) => (
540
+ <button
541
+ key={p.key}
542
+ onClick={() => { setRightTab(p.key); if (p.key === "docs") setShowDocs(true); }}
543
+ className={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${rightTab === p.key
544
+ ? "bg-[var(--kyro-primary)] text-white"
545
+ : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
546
+ }`}
547
+ >
548
+ {p.label}
549
+ </button>
550
+ ))}
551
+ </div>
552
+
553
+ <div className="flex-1 overflow-hidden">
554
+ {rightTab === "docs" ? (
555
+ <div className="h-full flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
556
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--kyro-border)]">
557
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Schema Explorer</span>
558
+ <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
559
+ <X className="w-3.5 h-3.5" />
560
+ </button>
561
+ </div>
562
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
563
+ {loadingSchema ? (
564
+ <div className="flex items-center justify-center h-32">
565
+ <RefreshCw className="w-5 h-5 animate-spin text-[var(--kyro-primary)]" />
566
+ </div>
567
+ ) : schema ? (
568
+ <div className="space-y-4">
569
+ {selectedType ? (
570
+ <div className="space-y-3">
571
+ <button
572
+ onClick={() => setSelectedType(null)}
573
+ className="flex items-center gap-1 text-[10px] text-[var(--kyro-primary)] font-semibold hover:underline"
574
+ >
575
+ ← Back to types
576
+ </button>
577
+ <div>
578
+ <h3 className="text-sm font-bold text-[var(--kyro-text-primary)]">{selectedType.name}</h3>
579
+ <p className="text-[10px] text-[var(--kyro-text-muted)] italic">{selectedType.kind}</p>
580
+ {selectedType.description && (
581
+ <p className="mt-1.5 text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">{selectedType.description}</p>
582
+ )}
583
+ </div>
584
+ {selectedType.fields && (
585
+ <div className="space-y-2">
586
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Fields</h4>
587
+ {selectedType.fields.map(f => (
588
+ <div key={f.name} className="p-2.5 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)]">
589
+ <div className="flex items-center justify-between gap-2">
590
+ <span className="font-semibold text-[11px] text-[var(--kyro-text-primary)]">{f.name}</span>
591
+ <span className="text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1.5 py-0.5 rounded">{renderType(f.type)}</span>
592
+ </div>
593
+ {f.description && <p className="text-[10px] text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
594
+ {f.args && f.args.length > 0 && (
595
+ <div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-border)] space-y-0.5">
596
+ {f.args.map(a => (
597
+ <div key={a.name} className="text-[9px]">
598
+ <span className="text-[var(--kyro-text-muted)]">{a.name}:</span>{" "}
599
+ <span className="text-[var(--kyro-primary)]">{renderType(a.type)}</span>
600
+ </div>
601
+ ))}
602
+ </div>
603
+ )}
604
+ </div>
605
+ ))}
606
+ </div>
391
607
  )}
392
608
  </div>
393
- {selectedType.fields && (
394
- <div className="space-y-3">
395
- <h4 className="text-xs font-bold tracking-widest text-[var(--kyro-text-muted)] pt-4">Fields</h4>
396
- {selectedType.fields.map(f => (
397
- <div key={f.name} className="p-3 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)]">
398
- <div className="flex items-center justify-between gap-2">
399
- <span className="font-bold text-sm text-[var(--kyro-text-primary)]">{f.name}</span>
400
- <span className="text-[10px] font-mono text-pink-500 bg-pink-500/10 px-1.5 py-0.5 rounded">{renderType(f.type)}</span>
401
- </div>
402
- {f.description && <p className="text-xs text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
403
- {f.args && f.args.length > 0 && (
404
- <div className="mt-2 pl-4 border-l-2 border-[var(--kyro-border)] space-y-1">
405
- {f.args.map(a => (
406
- <div key={a.name} className="text-[10px]">
407
- <span className="text-[var(--kyro-text-muted)]">{a.name}:</span> <span className="text-pink-400">{renderType(a.type)}</span>
408
- </div>
409
- ))}
609
+ ) : (
610
+ <div className="space-y-3">
611
+ <div className="grid grid-cols-2 gap-2">
612
+ {["Query", "Mutation"].map(t => {
613
+ const found = schema.types.find(type => type.name === t);
614
+ if (!found) return null;
615
+ return (
616
+ <button
617
+ key={t}
618
+ onClick={() => setSelectedType(found)}
619
+ className="flex items-center justify-between p-3 bg-[var(--kyro-surface-accent)] rounded-md border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] transition-all text-left group"
620
+ >
621
+ <div>
622
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-muted)] block">{t}</span>
623
+ <span className="text-[11px] font-bold text-[var(--kyro-text-primary)]">Root Operations</span>
410
624
  </div>
411
- )}
412
- </div>
413
- ))}
625
+ <ChevronRight className="w-3.5 h-3.5 text-[var(--kyro-primary)] group-hover:translate-x-1 transition-transform" />
626
+ </button>
627
+ );
628
+ })}
414
629
  </div>
415
- )}
416
- </div>
417
- ) : (
418
- <div className="space-y-4">
419
- <div className="grid grid-cols-2 gap-2">
420
- {["Query", "Mutation"].map(t => {
421
- const found = schema.types.find(type => type.name === t);
422
- if (!found) return null;
423
- return (
630
+ <div className="space-y-1.5">
631
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">All Types</h4>
632
+ {schema.types.filter(t => !t.name.startsWith("__") && t.kind === "OBJECT").map(t => (
424
633
  <button
425
- key={t}
426
- onClick={() => setSelectedType(found)}
427
- className="flex items-center justify-between p-4 bg-[var(--kyro-surface-accent)] rounded-xl border border-[var(--kyro-border)] hover:border-pink-500 transition-all text-left group"
634
+ key={t.name}
635
+ onClick={() => setSelectedType(t)}
636
+ className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left"
428
637
  >
429
- <div>
430
- <span className="text-xs font-bold text-[var(--kyro-text-muted)] block">{t}</span>
431
- <span className="text-sm font-bold text-[var(--kyro-text-primary)]">Root Operations</span>
432
- </div>
433
- <ChevronRight className="w-4 h-4 text-pink-500 group-hover:translate-x-1 transition-transform" />
638
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
639
+ <ChevronRight className="w-3 h-3 opacity-30" />
434
640
  </button>
435
- );
436
- })}
437
- </div>
438
- <div className="space-y-2">
439
- <h4 className="text-xs font-bold tracking-widest text-[var(--kyro-text-muted)] pt-4">All Types</h4>
440
- {schema.types.filter(t => !t.name.startsWith("__") && t.kind === "OBJECT").map(t => (
441
- <button
442
- key={t.name}
443
- onClick={() => setSelectedType(t)}
444
- className="w-full flex items-center justify-between px-4 py-2 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left"
445
- >
446
- <span className="text-sm font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
447
- <ChevronRight className="w-3.5 h-3.5 opacity-30" />
448
- </button>
449
- ))}
641
+ ))}
642
+ </div>
450
643
  </div>
451
- </div>
452
- )}
453
- </div>
454
- ) : (
455
- <p className="text-xs text-[var(--kyro-text-muted)]">No schema loaded.</p>
456
- )}
644
+ )}
645
+ </div>
646
+ ) : (
647
+ <p className="text-[10px] text-[var(--kyro-text-muted)]">No schema loaded.</p>
648
+ )}
649
+ </div>
457
650
  </div>
458
- </div>
459
- ) : (
460
- <div className="flex-1 flex flex-col overflow-hidden">
461
- <div className="flex items-center px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
462
- <span className="text-xs font-bold tracking-widest text-[var(--kyro-text-secondary)]">Response</span>
651
+ ) : rightTab === "history" ? (
652
+ <div className="h-full flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
653
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--kyro-border)]">
654
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Query History</span>
655
+ {history.length > 0 && (
656
+ <button onClick={() => setHistory([])} className="text-[9px] text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)]">Clear</button>
657
+ )}
658
+ </div>
659
+ <div className="flex-1 overflow-y-auto">
660
+ {history.length === 0 ? (
661
+ <div className="flex flex-col items-center justify-center h-32 text-[var(--kyro-text-muted)]">
662
+ <Terminal className="w-8 h-8 mb-2 opacity-30" />
663
+ <p className="text-[10px]">No queries executed yet</p>
664
+ </div>
665
+ ) : (
666
+ <div className="p-2 space-y-1">
667
+ {history.map((entry) => (
668
+ <button
669
+ key={entry.id}
670
+ onClick={() => {
671
+ setTab((prev) => ({ ...prev, query: entry.query, variables: entry.variables }));
672
+ setResponse(entry.response);
673
+ }}
674
+ className="w-full text-left p-2 rounded-lg hover:bg-[var(--kyro-surface-accent)] border border-transparent hover:border-[var(--kyro-border)] transition-all"
675
+ >
676
+ <div className="text-[10px] font-mono text-[var(--kyro-text-primary)] truncate leading-relaxed">
677
+ {entry.query.split("\n").slice(0, 3).join(" ").substring(0, 80)}
678
+ </div>
679
+ <div className="flex items-center gap-2 mt-1 text-[9px] text-[var(--kyro-text-muted)]">
680
+ <span>{new Date(entry.timestamp).toLocaleTimeString()}</span>
681
+ <span>{entry.duration}ms</span>
682
+ <span className={`${entry.statusCode < 400 ? "text-[var(--kyro-success)]" : "text-[var(--kyro-danger)]"}`}>
683
+ {entry.statusCode}
684
+ </span>
685
+ </div>
686
+ </button>
687
+ ))}
688
+ </div>
689
+ )}
690
+ </div>
463
691
  </div>
464
- <div className="flex-1 overflow-auto p-4 bg-[var(--kyro-bg-secondary)]">
465
- {isLoading ? (
466
- <div className="flex flex-col items-center justify-center h-full gap-4">
467
- <RefreshCw className="w-8 h-8 animate-spin text-pink-500" />
468
- <span className="text-xs font-bold text-[var(--kyro-text-muted)]">Running Query...</span>
469
- </div>
470
- ) : response ? (
471
- <pre className="text-[13px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap selection:bg-pink-500/20">
472
- {response}
473
- </pre>
474
- ) : (
475
- <div className="flex flex-col items-center justify-center h-full opacity-30">
476
- <Activity className="w-16 h-16 mb-4" />
477
- <p className="text-sm font-bold">Query output will appear here</p>
478
- </div>
479
- )}
692
+ ) : (
693
+ <div className="h-full flex flex-col overflow-hidden">
694
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
695
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Response</span>
696
+ {lastDuration > 0 && (
697
+ <span className="text-[9px] font-mono text-[var(--kyro-text-muted)]">
698
+ {lastDuration}ms
699
+ </span>
700
+ )}
701
+ {lastStatus > 0 && (
702
+ <span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${lastStatus < 400 ? "bg-[var(--kyro-success-bg)] text-[var(--kyro-success)]" : "bg-[var(--kyro-danger-bg)] text-[var(--kyro-danger)]"
703
+ }`}>
704
+ {lastStatus}
705
+ </span>
706
+ )}
707
+ </div>
708
+ <div className="flex-1 overflow-auto bg-[var(--kyro-bg-secondary)]">
709
+ {isLoading ? (
710
+ <div className="flex flex-col items-center justify-center h-full gap-3">
711
+ <RefreshCw className="w-6 h-6 animate-spin text-[var(--kyro-primary)]" />
712
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-muted)]">Running Query...</span>
713
+ </div>
714
+ ) : response ? (
715
+ <pre className="text-[11px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap p-3 selection:bg-[var(--kyro-primary)]/20">
716
+ {error && (
717
+ <div className="mb-2 p-2 rounded bg-[var(--kyro-danger-bg)] border border-[var(--kyro-danger)]/20 text-[10px] text-[var(--kyro-danger)] font-medium">
718
+ ⚠ {error}
719
+ </div>
720
+ )}
721
+ {response}
722
+ </pre>
723
+ ) : (
724
+ <div className="flex flex-col items-center justify-center h-full opacity-30">
725
+ <Activity className="w-12 h-12 mb-3" />
726
+ <p className="text-[11px] font-bold">Press Run to execute</p>
727
+ </div>
728
+ )}
729
+ </div>
480
730
  </div>
481
- </div>
482
- )}
731
+ )}
732
+ </div>
483
733
  </div>
484
734
  </div>
485
735
  </div>