@kyro-cms/admin 0.9.7 → 0.9.9
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 +106 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +107 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/GraphQLPlayground.tsx +315 -38
- package/src/components/blocks/BlockEditModal.tsx +12 -0
- package/src/components/blocks/ChildBlocksTree.tsx +3 -2
- package/src/components/fields/BlocksField.tsx +65 -13
- package/src/components/fields/extensions/blockComponents.tsx +6 -0
- package/src/components/fields/extensions/blocksStore.ts +1 -0
- package/src/kyro-cms.d.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyro-cms/admin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"vitest": "^4.1.4"
|
|
121
121
|
},
|
|
122
122
|
"peerDependencies": {
|
|
123
|
-
"@kyro-cms/core": "^0.9.
|
|
123
|
+
"@kyro-cms/core": "^0.9.8",
|
|
124
124
|
"react": "^19.0.0",
|
|
125
125
|
"react-dom": "^19.0.0"
|
|
126
126
|
},
|
|
@@ -2,6 +2,7 @@ import React, {
|
|
|
2
2
|
useState,
|
|
3
3
|
useCallback,
|
|
4
4
|
useEffect,
|
|
5
|
+
useMemo,
|
|
5
6
|
useRef,
|
|
6
7
|
Suspense,
|
|
7
8
|
lazy,
|
|
@@ -24,6 +25,146 @@ import {
|
|
|
24
25
|
Terminal,
|
|
25
26
|
} from "./ui/icons";
|
|
26
27
|
|
|
28
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
29
|
+
return (
|
|
30
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
31
|
+
<path d="m6 9 6 6 6-6" />
|
|
32
|
+
</svg>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function JsonNode({ value, depth = 0 }: { value: any; depth?: number }) {
|
|
37
|
+
const [collapsed, setCollapsed] = React.useState(depth > 2);
|
|
38
|
+
const toggle = () => setCollapsed(!collapsed);
|
|
39
|
+
|
|
40
|
+
if (value === null || value === undefined) {
|
|
41
|
+
return <span className="text-[#888] font-medium">null</span>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof value === "boolean") {
|
|
45
|
+
return <span className="text-[#e67e22] font-medium">{String(value)}</span>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof value === "number") {
|
|
49
|
+
return <span className="text-[#3498db] font-medium">{value}</span>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof value === "string") {
|
|
53
|
+
const max = 120;
|
|
54
|
+
const display = value.length > max ? value.slice(0, max) + "…" : value;
|
|
55
|
+
return <span className="text-[#27ae60]">"{display}"</span>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
const count = value.length;
|
|
60
|
+
const isEmpty = count === 0;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<button onClick={toggle} className="inline-flex items-baseline gap-0.5 text-[11px] font-mono text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] transition-colors cursor-pointer !bg-transparent !border-none !p-0 !m-0">
|
|
65
|
+
{isEmpty ? (
|
|
66
|
+
<span className="text-[var(--kyro-text-muted)]">[]</span>
|
|
67
|
+
) : (
|
|
68
|
+
<>
|
|
69
|
+
{collapsed ? <ChevronRight className="w-2.5 h-2.5 self-center" /> : <ChevronDown className="w-2.5 h-2.5 self-center" />}
|
|
70
|
+
<span className="text-[#2980b9]">[</span>
|
|
71
|
+
{collapsed && (
|
|
72
|
+
<span className="text-[var(--kyro-text-muted)] truncate max-w-[200px] inline-block">
|
|
73
|
+
{count <= 2
|
|
74
|
+
? value.map((v: any, i: number) => (
|
|
75
|
+
<span key={i}>
|
|
76
|
+
{i > 0 && <span className="text-[var(--kyro-text-muted)]">, </span>}
|
|
77
|
+
<JsonNode value={v} depth={depth + 1} />
|
|
78
|
+
</span>
|
|
79
|
+
))
|
|
80
|
+
: `${count} items`
|
|
81
|
+
}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
<span className="text-[#2980b9]">]</span>
|
|
85
|
+
{collapsed && <span className="text-[10px] ml-1 text-[#2980b9]">{count}</span>}
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</button>
|
|
89
|
+
{!isEmpty && !collapsed && (
|
|
90
|
+
<div className="border-l border-[#2980b9]/20 ml-[6px] pl-3">
|
|
91
|
+
{value.map((item, i) => (
|
|
92
|
+
<div key={i} className="text-[11px] font-mono leading-relaxed">
|
|
93
|
+
<span className="text-[#2980b9] select-none">{i}: </span>
|
|
94
|
+
<JsonNode value={item} depth={depth + 1} />
|
|
95
|
+
{i < count - 1 && <span className="text-[var(--kyro-text-muted)]">,</span>}
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (typeof value === "object") {
|
|
105
|
+
const keys = Object.keys(value);
|
|
106
|
+
const isEmpty = keys.length === 0;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div>
|
|
110
|
+
<button onClick={toggle} className="inline-flex items-baseline gap-0.5 text-[11px] font-mono text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] transition-colors cursor-pointer !bg-transparent !border-none !p-0 !m-0">
|
|
111
|
+
{isEmpty ? (
|
|
112
|
+
<span className="text-[var(--kyro-text-muted)]">{'{}'}</span>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
{collapsed ? <ChevronRight className="w-2.5 h-2.5 self-center" /> : <ChevronDown className="w-2.5 h-2.5 self-center" />}
|
|
116
|
+
<span className="text-[#d4a03c]">{'{'}</span>
|
|
117
|
+
{collapsed && (
|
|
118
|
+
<span className="text-[var(--kyro-text-muted)] truncate max-w-[200px] inline-block">
|
|
119
|
+
{keys.length <= 2
|
|
120
|
+
? keys.map((k, i) => (
|
|
121
|
+
<span key={k}>
|
|
122
|
+
<span className="text-[#8e44ad]">"{k}"</span>
|
|
123
|
+
<span className="text-[var(--kyro-text-muted)]">: </span>
|
|
124
|
+
<JsonNode value={value[k]} depth={depth + 1} />
|
|
125
|
+
{i < keys.length - 1 && <span className="text-[var(--kyro-text-muted)]">, </span>}
|
|
126
|
+
</span>
|
|
127
|
+
))
|
|
128
|
+
: `${keys.length} keys`
|
|
129
|
+
}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
<span className="text-[#d4a03c]">{'}'}</span>
|
|
133
|
+
{collapsed && <span className="text-[10px] ml-1 text-[#d4a03c]">{keys.length}</span>}
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</button>
|
|
137
|
+
{!isEmpty && !collapsed && (
|
|
138
|
+
<div className="border-l border-[#d4a03c]/20 ml-[6px] pl-3">
|
|
139
|
+
{keys.map((key, i) => (
|
|
140
|
+
<div key={key} className="text-[11px] font-mono leading-relaxed">
|
|
141
|
+
<span className="text-[#8e44ad]">"{key}"</span>
|
|
142
|
+
<span className="text-[var(--kyro-text-muted)]">: </span>
|
|
143
|
+
<JsonNode value={value[key]} depth={depth + 1} />
|
|
144
|
+
{i < keys.length - 1 && <span className="text-[var(--kyro-text-muted)]">,</span>}
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return <span className="text-[var(--kyro-text-muted)]">{String(value)}</span>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function JsonViewer({ json }: { json: string }) {
|
|
157
|
+
const parsed = React.useMemo(() => {
|
|
158
|
+
try { return JSON.parse(json); } catch { return json; }
|
|
159
|
+
}, [json]);
|
|
160
|
+
|
|
161
|
+
if (typeof parsed === "string") {
|
|
162
|
+
return <pre className="text-[11px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap selection:bg-[var(--kyro-primary)]/20">{json}</pre>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return <JsonNode value={parsed} />;
|
|
166
|
+
}
|
|
167
|
+
|
|
27
168
|
interface GraphQLPlaygroundProps {
|
|
28
169
|
endpoint?: string;
|
|
29
170
|
initialQuery?: string;
|
|
@@ -52,6 +193,8 @@ const CodeMirrorEditor = lazy(() =>
|
|
|
52
193
|
import("@uiw/react-codemirror").then((mod) => ({ default: mod.default })),
|
|
53
194
|
);
|
|
54
195
|
import { javascript } from "@codemirror/lang-javascript";
|
|
196
|
+
import { json } from "@codemirror/lang-json";
|
|
197
|
+
import { CompletionContext, autocompletion } from "@codemirror/autocomplete";
|
|
55
198
|
import { aura } from "@uiw/codemirror-theme-aura";
|
|
56
199
|
|
|
57
200
|
function prettifyQuery(query: string): string {
|
|
@@ -71,6 +214,118 @@ function prettifyQuery(query: string): string {
|
|
|
71
214
|
return result.trim();
|
|
72
215
|
}
|
|
73
216
|
|
|
217
|
+
function resolveTypeName(type: Record<string, unknown>): string {
|
|
218
|
+
if (!type) return "Unknown";
|
|
219
|
+
if (type.name) return type.name as string;
|
|
220
|
+
if (type.ofType) return resolveTypeName(type.ofType as Record<string, unknown>);
|
|
221
|
+
return "Unknown";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isScalarType(typeName: string): boolean {
|
|
225
|
+
return ["String", "Int", "Float", "Boolean", "ID"].includes(typeName);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderType(type: Record<string, unknown>): string {
|
|
229
|
+
if (!type) return "Unknown";
|
|
230
|
+
if (type.name) return type.name as string;
|
|
231
|
+
if (type.ofType) {
|
|
232
|
+
if (type.kind === "NON_NULL") return `${renderType(type.ofType as Record<string, unknown>)}!`;
|
|
233
|
+
if (type.kind === "LIST") return `[${renderType(type.ofType as Record<string, unknown>)}]`;
|
|
234
|
+
return renderType(type.ofType as Record<string, unknown>);
|
|
235
|
+
}
|
|
236
|
+
return "Unknown";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveInnermostKind(type: Record<string, unknown>): string {
|
|
240
|
+
let current: Record<string, unknown> | undefined = type;
|
|
241
|
+
while (current && current.ofType) current = current.ofType as Record<string, unknown>;
|
|
242
|
+
return (current?.kind as string) || "Unknown";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function generateSkeletonQuery(field: FieldInfo, schema: SchemaInfo, isMutation: boolean): string {
|
|
246
|
+
const returnTypeName = resolveTypeName(field.type);
|
|
247
|
+
const returnType = schema.types.find((t) => t.name === returnTypeName);
|
|
248
|
+
|
|
249
|
+
function collectFields(fields: FieldInfo[], indent: number): string[] {
|
|
250
|
+
const lines: string[] = [];
|
|
251
|
+
for (const f of fields) {
|
|
252
|
+
if (f.isDeprecated) continue;
|
|
253
|
+
const typeName = resolveTypeName(f.type);
|
|
254
|
+
const pad = " ".repeat(indent);
|
|
255
|
+
if (isScalarType(typeName) || typeName === "JSON") {
|
|
256
|
+
lines.push(`${pad}${f.name}`);
|
|
257
|
+
} else {
|
|
258
|
+
lines.push(`${pad}${f.name} { id }`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const docField = returnType?.fields?.find((f) => f.name === "doc");
|
|
265
|
+
const messageField = returnType?.fields?.find((f) => f.name === "message");
|
|
266
|
+
|
|
267
|
+
let selection = "";
|
|
268
|
+
|
|
269
|
+
if (docField && isMutation) {
|
|
270
|
+
const docTypeName = resolveTypeName(docField.type);
|
|
271
|
+
const docType = schema.types.find((t) => t.name === docTypeName);
|
|
272
|
+
const docLines = collectFields(docType?.fields?.filter((f) => !f.isDeprecated) || [], 1);
|
|
273
|
+
selection = [" doc {", ...docLines, " }"]
|
|
274
|
+
.concat(messageField ? [" message"] : [])
|
|
275
|
+
.join("\n");
|
|
276
|
+
} else {
|
|
277
|
+
selection = collectFields(returnType?.fields || [], 1).join("\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const args = field.args
|
|
281
|
+
?.filter((a) => {
|
|
282
|
+
const t = renderType(a.type);
|
|
283
|
+
return t.endsWith("!");
|
|
284
|
+
})
|
|
285
|
+
.map((a) => {
|
|
286
|
+
const t = renderType(a.type).replace("!", "");
|
|
287
|
+
const val = t === "String" ? '""' : t === "Int" ? "0" : t === "Float" ? "0" : t === "Boolean" ? "false" : '""';
|
|
288
|
+
return `${a.name}: ${val}`;
|
|
289
|
+
})
|
|
290
|
+
.join(", ");
|
|
291
|
+
|
|
292
|
+
const fieldCall = `${field.name}${args ? `(${args})` : ""}`;
|
|
293
|
+
|
|
294
|
+
if (isMutation) {
|
|
295
|
+
return `mutation {\n ${fieldCall} {\n${selection}\n }\n}`;
|
|
296
|
+
}
|
|
297
|
+
return `query {\n ${fieldCall} {\n${selection}\n }\n}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildSchemaCompletionOverride(schema: SchemaInfo) {
|
|
301
|
+
const queryType = schema.types.find((t) => t.name === schema.queryType.name);
|
|
302
|
+
const mutationType = schema.mutationType
|
|
303
|
+
? schema.types.find((t) => t.name === schema.mutationType!.name)
|
|
304
|
+
: null;
|
|
305
|
+
|
|
306
|
+
const queryFields = queryType?.fields || [];
|
|
307
|
+
const mutationFields = mutationType?.fields || [];
|
|
308
|
+
|
|
309
|
+
const operationNames = [...queryFields.map((f) => f.name), ...mutationFields.map((f) => f.name)];
|
|
310
|
+
|
|
311
|
+
return (context: CompletionContext) => {
|
|
312
|
+
const word = context.matchBefore(/\w*/);
|
|
313
|
+
if (!word || (word.from === word.to && !context.explicit)) return null;
|
|
314
|
+
return {
|
|
315
|
+
from: word.from,
|
|
316
|
+
options: [
|
|
317
|
+
...operationNames.map((n) => ({
|
|
318
|
+
label: n,
|
|
319
|
+
type: "function" as const,
|
|
320
|
+
detail: "operation",
|
|
321
|
+
})),
|
|
322
|
+
{ label: "query", type: "keyword" as const, detail: "operation type" },
|
|
323
|
+
{ label: "mutation", type: "keyword" as const, detail: "operation type" },
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
74
329
|
const DEFAULT_QUERY = `# Welcome to Kyro CMS GraphQL Playground
|
|
75
330
|
# Cmd+Enter to run, Cmd+Shift+P to prettify
|
|
76
331
|
|
|
@@ -163,6 +418,7 @@ export function GraphQLPlayground({
|
|
|
163
418
|
const [splitPos, setSplitPos] = useState(50);
|
|
164
419
|
const [mobilePanel, setMobilePanel] = useState<"editor" | "response">("editor");
|
|
165
420
|
const [isDragging, setIsDragging] = useState(false);
|
|
421
|
+
const [isDesktop, setIsDesktop] = useState(false);
|
|
166
422
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
167
423
|
const cursorPos = useRef({ line: 1, col: 1 });
|
|
168
424
|
|
|
@@ -170,6 +426,13 @@ export function GraphQLPlayground({
|
|
|
170
426
|
setIsMounted(true);
|
|
171
427
|
}, []);
|
|
172
428
|
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
const check = () => setIsDesktop(window.innerWidth >= 768);
|
|
431
|
+
check();
|
|
432
|
+
window.addEventListener("resize", check);
|
|
433
|
+
return () => window.removeEventListener("resize", check);
|
|
434
|
+
}, []);
|
|
435
|
+
|
|
173
436
|
const fetchSchema = useCallback(async () => {
|
|
174
437
|
setLoadingSchema(true);
|
|
175
438
|
try {
|
|
@@ -241,7 +504,7 @@ export function GraphQLPlayground({
|
|
|
241
504
|
const headers: Record<string, string> = {
|
|
242
505
|
"Content-Type": "application/json",
|
|
243
506
|
};
|
|
244
|
-
if (token) headers["Authorization"] = `
|
|
507
|
+
if (token) headers["Authorization"] = `ApiKey ${token}`;
|
|
245
508
|
if (tab.headers) {
|
|
246
509
|
try {
|
|
247
510
|
const customHeaders = JSON.parse(tab.headers);
|
|
@@ -336,19 +599,25 @@ export function GraphQLPlayground({
|
|
|
336
599
|
setTab((prev) => ({ ...prev, query: "" }));
|
|
337
600
|
};
|
|
338
601
|
|
|
339
|
-
const
|
|
340
|
-
|
|
602
|
+
const handleInsertQuery = useCallback(
|
|
603
|
+
(field: FieldInfo) => {
|
|
604
|
+
const isMutation = schema?.mutationType?.name
|
|
605
|
+
? schema.types
|
|
606
|
+
.find((t) => t.name === schema.mutationType!.name)
|
|
607
|
+
?.fields?.some((f) => f.name === field.name)
|
|
608
|
+
: false;
|
|
609
|
+
if (!schema) return;
|
|
610
|
+
const q = generateSkeletonQuery(field, schema, !!isMutation);
|
|
611
|
+
setTab((prev) => ({ ...prev, query: q }));
|
|
612
|
+
setRightTab("response");
|
|
613
|
+
},
|
|
614
|
+
[schema],
|
|
615
|
+
);
|
|
341
616
|
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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>);
|
|
349
|
-
}
|
|
350
|
-
return "Unknown";
|
|
351
|
-
};
|
|
617
|
+
const queryExt = useMemo(() => [javascript(), schema ? autocompletion({ override: [buildSchemaCompletionOverride(schema)] }) : []].flat(), [schema]);
|
|
618
|
+
const jsonExt = useMemo(() => [json()], []);
|
|
619
|
+
const extensions = activeEditorTab === "query" ? queryExt : jsonExt;
|
|
620
|
+
const theme = aura;
|
|
352
621
|
|
|
353
622
|
const startDrag = useCallback((e: React.MouseEvent) => {
|
|
354
623
|
e.preventDefault();
|
|
@@ -409,7 +678,7 @@ export function GraphQLPlayground({
|
|
|
409
678
|
{token ? (
|
|
410
679
|
<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
680
|
<span className="w-2 h-2 rounded-full bg-[var(--kyro-success)]" />
|
|
412
|
-
|
|
681
|
+
API Key ••••{token.slice(-4)}
|
|
413
682
|
<button onClick={() => { setToken(""); setShowToken(false); }} className="ml-1 hover:text-[var(--kyro-danger)]">
|
|
414
683
|
<X className="w-3 h-3" />
|
|
415
684
|
</button>
|
|
@@ -420,7 +689,7 @@ export function GraphQLPlayground({
|
|
|
420
689
|
type={showToken ? "text" : "password"}
|
|
421
690
|
value={token}
|
|
422
691
|
onChange={(e) => setToken(e.target.value)}
|
|
423
|
-
placeholder="
|
|
692
|
+
placeholder="API key"
|
|
424
693
|
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
694
|
/>
|
|
426
695
|
<button onClick={() => setShowToken(!showToken)} className="p-1 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
|
|
@@ -478,7 +747,7 @@ export function GraphQLPlayground({
|
|
|
478
747
|
</div>
|
|
479
748
|
|
|
480
749
|
{/* 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:
|
|
750
|
+
<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: isDesktop ? `${splitPos}%` : "100%" }}>
|
|
482
751
|
{/* Editor pills */}
|
|
483
752
|
<div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
484
753
|
{editorPills.map((p) => (
|
|
@@ -533,7 +802,7 @@ export function GraphQLPlayground({
|
|
|
533
802
|
</div>
|
|
534
803
|
|
|
535
804
|
{/* 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:
|
|
805
|
+
<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: isDesktop ? `${100 - splitPos}%` : "100%" }}>
|
|
537
806
|
{/* Right pills */}
|
|
538
807
|
<div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
539
808
|
{rightPills.map((p) => (
|
|
@@ -584,25 +853,33 @@ export function GraphQLPlayground({
|
|
|
584
853
|
{selectedType.fields && (
|
|
585
854
|
<div className="space-y-2">
|
|
586
855
|
<h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Fields</h4>
|
|
587
|
-
{selectedType.fields.map(f =>
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
<span className="text-[var(--kyro-primary)]">{renderType(a.type)}</span>
|
|
600
|
-
</div>
|
|
601
|
-
))}
|
|
856
|
+
{selectedType.fields.map(f => {
|
|
857
|
+
const isRootOp = (selectedType.name === "Query" || selectedType.name === "Mutation") && !!schema;
|
|
858
|
+
return (
|
|
859
|
+
<button
|
|
860
|
+
key={f.name}
|
|
861
|
+
type="button"
|
|
862
|
+
onClick={isRootOp ? () => handleInsertQuery(f) : undefined}
|
|
863
|
+
className={`w-full text-left p-2.5 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)] transition-all ${isRootOp ? "hover:border-[var(--kyro-primary)] hover:shadow-sm cursor-pointer" : ""}`}
|
|
864
|
+
>
|
|
865
|
+
<div className="flex items-center justify-between gap-2">
|
|
866
|
+
<span className="font-semibold text-[11px] text-[var(--kyro-text-primary)]">{f.name}</span>
|
|
867
|
+
<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>
|
|
602
868
|
</div>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
869
|
+
{f.description && <p className="text-[10px] text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
|
|
870
|
+
{f.args && f.args.length > 0 && (
|
|
871
|
+
<div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-border)] space-y-0.5">
|
|
872
|
+
{f.args.map(a => (
|
|
873
|
+
<div key={a.name} className="text-[9px]">
|
|
874
|
+
<span className="text-[var(--kyro-text-muted)]">{a.name}:</span>{" "}
|
|
875
|
+
<span className="text-[var(--kyro-primary)]">{renderType(a.type)}</span>
|
|
876
|
+
</div>
|
|
877
|
+
))}
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
</button>
|
|
881
|
+
);
|
|
882
|
+
})}
|
|
606
883
|
</div>
|
|
607
884
|
)}
|
|
608
885
|
</div>
|
|
@@ -712,14 +989,14 @@ export function GraphQLPlayground({
|
|
|
712
989
|
<span className="text-[10px] font-semibold text-[var(--kyro-text-muted)]">Running Query...</span>
|
|
713
990
|
</div>
|
|
714
991
|
) : response ? (
|
|
715
|
-
<
|
|
992
|
+
<div className="h-full overflow-auto p-3">
|
|
716
993
|
{error && (
|
|
717
994
|
<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
995
|
⚠ {error}
|
|
719
996
|
</div>
|
|
720
997
|
)}
|
|
721
|
-
{response}
|
|
722
|
-
</
|
|
998
|
+
<JsonViewer json={response} />
|
|
999
|
+
</div>
|
|
723
1000
|
) : (
|
|
724
1001
|
<div className="flex flex-col items-center justify-center h-full opacity-30">
|
|
725
1002
|
<Activity className="w-12 h-12 mb-3" />
|
|
@@ -107,6 +107,18 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
|
|
|
107
107
|
accentClass={theme.border}
|
|
108
108
|
>
|
|
109
109
|
<div className="space-y-4">
|
|
110
|
+
<div>
|
|
111
|
+
<label className="text-[10px] font-medium text-[var(--kyro-text-muted)] mb-1 block">
|
|
112
|
+
Block Name
|
|
113
|
+
</label>
|
|
114
|
+
<input
|
|
115
|
+
value={(blockData?.name as string) || ""}
|
|
116
|
+
onChange={(e) => updateBlock(block.id, { name: e.target.value })}
|
|
117
|
+
placeholder={blockSchema?.label || (block.type as string)}
|
|
118
|
+
className="w-full bg-[var(--kyro-bg-primary)] border border-[var(--kyro-border)] rounded-lg px-3 py-2 text-sm text-[var(--kyro-text-primary)] outline-none focus:border-[var(--kyro-primary)] transition-colors"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
110
122
|
{renderFields()}
|
|
111
123
|
|
|
112
124
|
{children.length > 0 && (
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
blockIcons,
|
|
5
5
|
getBlockComponent,
|
|
6
6
|
getBlockLabel,
|
|
7
|
+
getBlockDisplayLabel,
|
|
7
8
|
} from "../fields/extensions/blockComponents";
|
|
8
9
|
import { createNewBlock } from "../fields/extensions/blocksStore";
|
|
9
10
|
import { BlockDrawer } from "../ui/BlockDrawer";
|
|
@@ -137,7 +138,7 @@ export const ChildBlocksTree: React.FC<ChildBlocksTreeProps> = ({
|
|
|
137
138
|
|
|
138
139
|
<div className="flex-1 min-w-0">
|
|
139
140
|
<div className="text-xs font-medium text-[var(--kyro-text-secondary)] truncate">
|
|
140
|
-
{
|
|
141
|
+
{getBlockDisplayLabel(child)}
|
|
141
142
|
{child.data?.text ? ` - ${child.data.text.slice(0, 30)}` : ""}
|
|
142
143
|
{child.data?.heading ? ` - ${child.data.heading.slice(0, 30)}` : ""}
|
|
143
144
|
</div>
|
|
@@ -417,7 +418,7 @@ const NestedChildBlocks: React.FC<NestedChildBlocksProps> = ({
|
|
|
417
418
|
|
|
418
419
|
<div className="flex-1 min-w-0">
|
|
419
420
|
<div className="text-xs font-medium text-[var(--kyro-text-secondary)] truncate">
|
|
420
|
-
{
|
|
421
|
+
{getBlockDisplayLabel(child)}
|
|
421
422
|
{child.data?.text ? ` - ${child.data.text.slice(0, 30)}` : ""}
|
|
422
423
|
{child.data?.heading ? ` - ${child.data.heading.slice(0, 30)}` : ""}
|
|
423
424
|
</div>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
BLOCK_COMPONENTS,
|
|
8
8
|
getBlockComponent,
|
|
9
9
|
blockIcons,
|
|
10
|
+
getBlockDisplayLabel,
|
|
10
11
|
getBlockLabel,
|
|
11
12
|
blockTheme,
|
|
12
13
|
} from "./extensions/blockComponents";
|
|
@@ -86,9 +87,27 @@ const SortableBlockComponent = ({
|
|
|
86
87
|
isDragging,
|
|
87
88
|
} = useSortable({ id: block.id });
|
|
88
89
|
|
|
89
|
-
const { removeBlock } = useBlockActions();
|
|
90
|
+
const { removeBlock, updateBlock } = useBlockActions();
|
|
90
91
|
const isEditing = editingBlockId === block.id;
|
|
91
92
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
93
|
+
const [editingName, setEditingName] = useState(false);
|
|
94
|
+
const [nameDraft, setNameDraft] = useState((block.name as string) || "");
|
|
95
|
+
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (editingName && nameInputRef.current) {
|
|
99
|
+
nameInputRef.current.focus();
|
|
100
|
+
nameInputRef.current.select();
|
|
101
|
+
}
|
|
102
|
+
}, [editingName]);
|
|
103
|
+
|
|
104
|
+
const commitName = useCallback(() => {
|
|
105
|
+
setEditingName(false);
|
|
106
|
+
const trimmed = nameDraft.trim();
|
|
107
|
+
if (trimmed !== ((block.name as string) || "").trim()) {
|
|
108
|
+
updateBlock(block.id as string, { name: trimmed || "" });
|
|
109
|
+
}
|
|
110
|
+
}, [nameDraft, block.name, block.id, updateBlock]);
|
|
92
111
|
|
|
93
112
|
const style = {
|
|
94
113
|
transform: CSS.Transform.toString(transform),
|
|
@@ -97,7 +116,7 @@ const SortableBlockComponent = ({
|
|
|
97
116
|
opacity: isDragging ? 0.8 : 1,
|
|
98
117
|
};
|
|
99
118
|
|
|
100
|
-
const itemLabel =
|
|
119
|
+
const itemLabel = getBlockDisplayLabel(block);
|
|
101
120
|
const data = (block.data || {}) as Record<string, any>;
|
|
102
121
|
const previewSnippet = getBlockPreviewSnippet(data, blockSchema);
|
|
103
122
|
|
|
@@ -126,9 +145,25 @@ const SortableBlockComponent = ({
|
|
|
126
145
|
</span>
|
|
127
146
|
)}
|
|
128
147
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
{editingName ? (
|
|
149
|
+
<input
|
|
150
|
+
ref={nameInputRef}
|
|
151
|
+
value={nameDraft}
|
|
152
|
+
onChange={(e) => setNameDraft(e.target.value)}
|
|
153
|
+
onBlur={commitName}
|
|
154
|
+
onKeyDown={(e) => { if (e.key === "Enter") commitName(); if (e.key === "Escape") { setNameDraft((block.name as string) || ""); setEditingName(false); } }}
|
|
155
|
+
onClick={(e) => e.stopPropagation()}
|
|
156
|
+
className="flex-1 min-w-0 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-primary)] rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--kyro-text-primary)] outline-none"
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<span
|
|
160
|
+
onClick={(e) => { e.stopPropagation(); setNameDraft((block.name as string) || ""); setEditingName(true); }}
|
|
161
|
+
className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px] cursor-text hover:text-[var(--kyro-text-primary)] transition-colors"
|
|
162
|
+
title="Click to rename"
|
|
163
|
+
>
|
|
164
|
+
{itemLabel}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
132
167
|
|
|
133
168
|
{showDeleteConfirm ? (
|
|
134
169
|
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
@@ -213,14 +248,31 @@ const SortableBlockComponent = ({
|
|
|
213
248
|
)}
|
|
214
249
|
|
|
215
250
|
<div className="flex-1 min-w-0">
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
251
|
+
{editingName ? (
|
|
252
|
+
<input
|
|
253
|
+
ref={nameInputRef}
|
|
254
|
+
value={nameDraft}
|
|
255
|
+
onChange={(e) => setNameDraft(e.target.value)}
|
|
256
|
+
onBlur={commitName}
|
|
257
|
+
onKeyDown={(e) => { if (e.key === "Enter") commitName(); if (e.key === "Escape") { setNameDraft((block.name as string) || ""); setEditingName(false); } }}
|
|
258
|
+
onClick={(e) => e.stopPropagation()}
|
|
259
|
+
placeholder={getBlockLabel(block.type as string)}
|
|
260
|
+
className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-primary)] rounded px-2 py-0.5 text-xs font-semibold text-[var(--kyro-text-primary)] outline-none"
|
|
261
|
+
/>
|
|
262
|
+
) : (
|
|
263
|
+
<div
|
|
264
|
+
onClick={(e) => { e.stopPropagation(); setNameDraft((block.name as string) || ""); setEditingName(true); }}
|
|
265
|
+
className="text-xs font-semibold text-[var(--kyro-text-secondary)] truncate cursor-text hover:text-[var(--kyro-text-primary)] transition-colors"
|
|
266
|
+
title="Click to rename"
|
|
267
|
+
>
|
|
268
|
+
{itemLabel}
|
|
269
|
+
{previewSnippet && typeof previewSnippet === "string" && (
|
|
270
|
+
<span className="text-[var(--kyro-text-muted)] font-normal ml-1.5">
|
|
271
|
+
- {previewSnippet.length > 40 ? `${previewSnippet.slice(0, 40)}...` : previewSnippet}
|
|
272
|
+
</span>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
224
276
|
{blockSchema?.admin?.description && (
|
|
225
277
|
<div className="text-[10px] text-[var(--kyro-text-muted)] mt-0.5 truncate opacity-80">
|
|
226
278
|
{blockSchema.admin.description}
|
|
@@ -174,3 +174,9 @@ export function getBlockLabel(type: string): string {
|
|
|
174
174
|
};
|
|
175
175
|
return labelMap[type] || type;
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
export function getBlockDisplayLabel(block: Record<string, unknown>): string {
|
|
179
|
+
const name = block.name as string | undefined;
|
|
180
|
+
if (name && name.trim()) return name.trim();
|
|
181
|
+
return getBlockLabel(block.type as string);
|
|
182
|
+
}
|