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