@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.9.7",
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.2",
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"] = `Bearer ${token}`;
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 extensions = [javascript()];
340
- const theme = aura;
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 renderType = (type: Record<string, unknown>): string => {
343
- if (!type) return "Unknown";
344
- if (type.name) return type.name as string;
345
- if (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>);
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
- Bearer ••••{token.slice(-4)}
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="Bearer token"
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: typeof window !== "undefined" && window.innerWidth >= 768 ? `${splitPos}%` : "100%" }}>
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: typeof window !== "undefined" && window.innerWidth >= 768 ? `${100 - splitPos}%` : "100%" }}>
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
- <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
- ))}
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
- </div>
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
- <pre className="text-[11px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap p-3 selection:bg-[var(--kyro-primary)]/20">
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
- </pre>
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
- {getBlockLabel(child.type)}
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
- {getBlockLabel(child.type)}
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 = getBlockLabel(block.type as string);
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
- <span className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px]">
130
- {itemLabel}
131
- </span>
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
- <div className="text-xs font-semibold text-[var(--kyro-text-secondary)] truncate">
217
- {itemLabel}
218
- {previewSnippet && typeof previewSnippet === "string" && (
219
- <span className="text-[var(--kyro-text-muted)] font-normal ml-1.5">
220
- - {previewSnippet.length > 40 ? `${previewSnippet.slice(0, 40)}...` : previewSnippet}
221
- </span>
222
- )}
223
- </div>
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
+ }
@@ -146,6 +146,7 @@ export function createNewBlock(type: string): BlockData {
146
146
  return {
147
147
  id: Math.random().toString(36).substr(2, 9),
148
148
  type,
149
+ name: "",
149
150
  data,
150
151
  options,
151
152
  children,
package/src/kyro-cms.d.ts CHANGED
@@ -143,6 +143,7 @@ admin?: {
143
143
  export interface BlockData {
144
144
  id: string;
145
145
  type: string;
146
+ name?: string;
146
147
  data?: Record<string, unknown>;
147
148
  children?: BlockData[];
148
149
  order?: number;