@kyro-cms/admin 0.11.5 → 0.12.0

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.11.5",
3
+ "version": "0.12.0",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -43,6 +43,7 @@ import { AutoFormHeader } from "./autoform/AutoFormHeader";
43
43
  import { AutoFormEditView } from "./autoform/AutoFormEditView";
44
44
  import { AutoFormVersionView } from "./autoform/AutoFormVersionView";
45
45
  import { AutoFormApiView } from "./autoform/AutoFormApiView";
46
+ import { ErrorBoundary } from "./autoform/ErrorBoundary";
46
47
 
47
48
  interface AutoFormProps {
48
49
  config: CollectionConfig | GlobalConfig;
@@ -147,6 +148,7 @@ export function AutoForm({
147
148
  setAutoSaveStatus,
148
149
  fetchVersions,
149
150
  saveDocument,
151
+ forceSave,
150
152
  autoSaveSkipRef,
151
153
  lastAutoSaveTimeRef,
152
154
  documentStatus,
@@ -460,11 +462,14 @@ export function AutoForm({
460
462
  message: "Delete this document? This cannot be undone. Are you absolutely sure?",
461
463
  variant: "danger",
462
464
  onConfirm: async () => {
465
+ autoSaveSkipRef.current = true;
463
466
  try {
464
467
  await apiDelete(`/api/${collectionSlug}/${formData.id}`);
465
468
  window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
466
469
  } catch (err) {
467
470
  toast.error((err as Error).message || "Failed to delete document");
471
+ } finally {
472
+ autoSaveSkipRef.current = false;
468
473
  }
469
474
  },
470
475
  });
@@ -475,6 +480,7 @@ export function AutoForm({
475
480
  title: "Unpublish Document",
476
481
  message: "Unpublish this document?",
477
482
  onConfirm: async () => {
483
+ autoSaveSkipRef.current = true;
478
484
  try {
479
485
  const response = await saveDocument(
480
486
  { ...formData, status: 'draft' } as Record<string, unknown>,
@@ -493,6 +499,8 @@ export function AutoForm({
493
499
  }
494
500
  } catch (err) {
495
501
  toast.error("Failed to unpublish");
502
+ } finally {
503
+ autoSaveSkipRef.current = false;
496
504
  }
497
505
  },
498
506
  });
@@ -533,7 +541,7 @@ export function AutoForm({
533
541
  );
534
542
  if (isPost) {
535
543
  setTimeout(() => {
536
- window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
544
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}/${result.data.id}`;
537
545
  }, 800);
538
546
  }
539
547
  } else {
@@ -593,7 +601,22 @@ export function AutoForm({
593
601
  if (response?.ok) {
594
602
  setLocalSaveStatus("saved");
595
603
  onActionSuccess?.("Published successfully");
604
+ if (isNewDoc && !globalSlug && dataToPublish.id) {
605
+ setTimeout(() => {
606
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
607
+ }, 800);
608
+ }
596
609
  } else {
610
+ // Two-step publish: if POST succeeded but PATCH failed,
611
+ // document exists as draft — navigate and inform user
612
+ if (isNewDoc && !globalSlug && dataToPublish.id) {
613
+ const error = await response?.json().catch(() => ({}));
614
+ toast.warning("Document saved as draft. Publishing failed: " + (error?.error || "Unknown error"));
615
+ setTimeout(() => {
616
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
617
+ }, 1200);
618
+ return;
619
+ }
597
620
  const error = await response?.json().catch(() => ({}));
598
621
  if (response?.status === 409) setAutoSaveStatus("conflict");
599
622
  setLocalSaveStatus("error");
@@ -1012,7 +1035,9 @@ export function AutoForm({
1012
1035
  handleUnpublish={handleUnpublish}
1013
1036
  handleDelete={handleDelete}
1014
1037
  handlePublish={handlePublish}
1038
+ handleSaveDraft={handleSaveDraft}
1015
1039
  handleSchedulePublish={handleSchedulePublish}
1040
+ handleConflictOverride={() => forceSave()}
1016
1041
  />
1017
1042
  )}
1018
1043
  {layout === "single" && (
@@ -1068,7 +1093,9 @@ export function AutoForm({
1068
1093
  type="button"
1069
1094
  style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
1070
1095
  onClick={async () => {
1096
+ autoSaveSkipRef.current = true;
1071
1097
  try {
1098
+ window.dispatchEvent(new Event("kyro:global-save-start"));
1072
1099
  const response = await saveDocument(formData);
1073
1100
  if (response.ok) {
1074
1101
  const result = await response.json();
@@ -1080,33 +1107,38 @@ export function AutoForm({
1080
1107
  } catch (e) {
1081
1108
  console.error("Save error exception:", e);
1082
1109
  onActionError?.("Save failed: " + (e as Error).message);
1110
+ } finally {
1111
+ autoSaveSkipRef.current = false;
1112
+ window.dispatchEvent(new Event("kyro:global-save-end"));
1083
1113
  }
1084
1114
  }}
1085
1115
  />
1086
1116
  </>
1087
1117
  )}
1088
1118
  <main className="w-full pt-6 md:pt-0">
1089
- {view === "edit" && (
1090
- <AutoFormEditView
1091
- config={config}
1092
- layout={layout}
1093
- collectionSlug={collectionSlug}
1094
- renderField={renderField}
1095
- />
1096
- )}
1097
- {view === "version" && (
1098
- <AutoFormVersionView
1099
- handleRestoreVersion={handleRestoreVersion}
1100
- handleCompareVersions={handleCompareVersions}
1101
- toggleCompareSelection={toggleCompareSelection}
1102
- />
1103
- )}
1104
- {view === "api" && (
1105
- <AutoFormApiView
1106
- collectionSlug={collectionSlug}
1107
- globalSlug={globalSlug}
1108
- />
1109
- )}
1119
+ <ErrorBoundary>
1120
+ {view === "edit" && (
1121
+ <AutoFormEditView
1122
+ config={config}
1123
+ layout={layout}
1124
+ collectionSlug={collectionSlug}
1125
+ renderField={renderField}
1126
+ />
1127
+ )}
1128
+ {view === "version" && (
1129
+ <AutoFormVersionView
1130
+ handleRestoreVersion={handleRestoreVersion}
1131
+ handleCompareVersions={handleCompareVersions}
1132
+ toggleCompareSelection={toggleCompareSelection}
1133
+ />
1134
+ )}
1135
+ {view === "api" && (
1136
+ <AutoFormApiView
1137
+ collectionSlug={collectionSlug}
1138
+ globalSlug={globalSlug}
1139
+ />
1140
+ )}
1141
+ </ErrorBoundary>
1110
1142
  </main>
1111
1143
  </div>
1112
1144
  );
@@ -23,6 +23,7 @@ import {
23
23
  Play,
24
24
  Clock,
25
25
  Terminal,
26
+ Search,
26
27
  } from "./ui/icons";
27
28
 
28
29
  function ChevronDown({ className }: { className?: string }) {
@@ -246,14 +247,42 @@ function generateSkeletonQuery(field: FieldInfo, schema: SchemaInfo, isMutation:
246
247
  const returnTypeName = resolveTypeName(field.type);
247
248
  const returnType = schema.types.find((t) => t.name === returnTypeName);
248
249
 
249
- function collectFields(fields: FieldInfo[], indent: number): string[] {
250
+ function buildTypeMap(): Map<string, TypeInfo> {
251
+ const m = new Map<string, TypeInfo>();
252
+ for (const t of schema.types) m.set(t.name, t);
253
+ return m;
254
+ }
255
+ const localTypeMap = buildTypeMap();
256
+
257
+ function collectFieldsWithUnions(fields: FieldInfo[], indent: number, _typeMap: Map<string, TypeInfo>): string[] {
250
258
  const lines: string[] = [];
251
259
  for (const f of fields) {
252
260
  if (f.isDeprecated) continue;
253
261
  const typeName = resolveTypeName(f.type);
262
+ const returnInfo = _typeMap.get(typeName);
254
263
  const pad = " ".repeat(indent);
255
264
  if (isScalarType(typeName) || typeName === "JSON") {
256
265
  lines.push(`${pad}${f.name}`);
266
+ } else if (returnInfo && (returnInfo.kind === "UNION" || returnInfo.kind === "INTERFACE")) {
267
+ lines.push(`${pad}${f.name} {`);
268
+ lines.push(`${pad} __typename`);
269
+ for (const pt of returnInfo.possibleTypes || []) {
270
+ const ptInfo = _typeMap.get(pt.name);
271
+ if (!ptInfo?.fields) continue;
272
+ lines.push(`${pad} ... on ${pt.name} {`);
273
+ for (const pf of ptInfo.fields) {
274
+ if (pf.isDeprecated) continue;
275
+ const pfTypeName = resolveTypeName(pf.type);
276
+ const pfPad = " ".repeat(indent + 2);
277
+ if (isScalarType(pfTypeName) || pfTypeName === "JSON") {
278
+ lines.push(`${pfPad}${pf.name}`);
279
+ } else {
280
+ lines.push(`${pfPad}${pf.name} { id }`);
281
+ }
282
+ }
283
+ lines.push(`${pad} }`);
284
+ }
285
+ lines.push(`${pad}}`);
257
286
  } else {
258
287
  lines.push(`${pad}${f.name} { id }`);
259
288
  }
@@ -269,12 +298,12 @@ function generateSkeletonQuery(field: FieldInfo, schema: SchemaInfo, isMutation:
269
298
  if (docField && isMutation) {
270
299
  const docTypeName = resolveTypeName(docField.type);
271
300
  const docType = schema.types.find((t) => t.name === docTypeName);
272
- const docLines = collectFields(docType?.fields?.filter((f) => !f.isDeprecated) || [], 1);
301
+ const docLines = collectFieldsWithUnions(docType?.fields?.filter((f) => !f.isDeprecated) || [], 1, localTypeMap);
273
302
  selection = [" doc {", ...docLines, " }"]
274
303
  .concat(messageField ? [" message"] : [])
275
304
  .join("\n");
276
305
  } else {
277
- selection = collectFields(returnType?.fields || [], 1).join("\n");
306
+ selection = collectFieldsWithUnions(returnType?.fields || [], 1, localTypeMap).join("\n");
278
307
  }
279
308
 
280
309
  const args = field.args
@@ -358,6 +387,8 @@ interface TypeInfo {
358
387
  fields?: FieldInfo[];
359
388
  inputFields?: FieldInfo[];
360
389
  enumValues?: { name: string; description?: string; isDeprecated: boolean }[];
390
+ possibleTypes?: { name: string; kind: string }[];
391
+ interfaces?: { name: string; kind: string }[];
361
392
  isDeprecated?: boolean;
362
393
  }
363
394
 
@@ -502,6 +533,8 @@ export function GraphQLPlayground({
502
533
  description
503
534
  isDeprecated
504
535
  }
536
+ possibleTypes { name kind }
537
+ interfaces { name kind }
505
538
  }
506
539
  }
507
540
  }
@@ -524,6 +557,93 @@ export function GraphQLPlayground({
524
557
  if (showDocs && !schema) fetchSchema();
525
558
  }, [showDocs, schema, fetchSchema]);
526
559
 
560
+ const [searchQuery, setSearchQuery] = useState("");
561
+
562
+ const typeMap = useMemo(() => {
563
+ const m = new Map<string, TypeInfo>();
564
+ if (!schema) return m;
565
+ for (const t of schema.types) {
566
+ m.set(t.name, t);
567
+ }
568
+ return m;
569
+ }, [schema]);
570
+
571
+ interface SearchResult {
572
+ typeName: string;
573
+ fieldPath: string;
574
+ typeInfo: TypeInfo;
575
+ fieldInfo?: FieldInfo;
576
+ }
577
+
578
+ const searchResults = useMemo(() => {
579
+ if (!searchQuery.trim() || !schema) return [];
580
+ const q = searchQuery.toLowerCase();
581
+ const results: SearchResult[] = [];
582
+ const seenLabels = new Set<string>();
583
+
584
+ for (const type of schema.types) {
585
+ if (type.name.startsWith("__")) continue;
586
+ const typeMatch = type.name.toLowerCase().includes(q);
587
+
588
+ if (typeMatch && !seenLabels.has(type.name)) {
589
+ seenLabels.add(type.name);
590
+ results.push({ typeName: type.name, fieldPath: type.name, typeInfo: type });
591
+ }
592
+
593
+ if (type.fields) {
594
+ for (const field of type.fields) {
595
+ const fieldMatch = field.name.toLowerCase().includes(q);
596
+ if (fieldMatch) {
597
+ const label = `${type.name} > ${field.name}`;
598
+ if (!seenLabels.has(label)) {
599
+ seenLabels.add(label);
600
+ results.push({ typeName: type.name, fieldPath: label, typeInfo: type, fieldInfo: field });
601
+ }
602
+ }
603
+
604
+ if (q.length >= 2) {
605
+ const rtName = resolveTypeName(field.type);
606
+ const rt = typeMap.get(rtName);
607
+ if (rt?.fields && (fieldMatch || results.some(r => r.fieldInfo === field))) {
608
+ for (const sub of rt.fields) {
609
+ if (sub.name.toLowerCase().includes(q)) {
610
+ const label = `${type.name} > ${field.name} > ${sub.name}`;
611
+ if (!seenLabels.has(label)) {
612
+ seenLabels.add(label);
613
+ results.push({ typeName: type.name, fieldPath: label, typeInfo: type, fieldInfo: field });
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ return results;
624
+ }, [searchQuery, schema, typeMap]);
625
+
626
+ const handleSearchSelect = useCallback((result: SearchResult) => {
627
+ const targetType = result.fieldInfo
628
+ ? typeMap.get(resolveTypeName(result.fieldInfo.type)) || result.typeInfo
629
+ : result.typeInfo;
630
+ setSelectedType(targetType);
631
+ setSearchQuery("");
632
+ }, [typeMap]);
633
+
634
+ function highlightMatch(text: string, query: string) {
635
+ if (!query.trim()) return text;
636
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
637
+ if (idx === -1) return text;
638
+ return (
639
+ <>
640
+ {text.slice(0, idx)}
641
+ <mark className="bg-[var(--kyro-primary)]/20 text-[var(--kyro-primary)] rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
642
+ {text.slice(idx + query.length)}
643
+ </>
644
+ );
645
+ }
646
+
527
647
  const handleRun = useCallback(async () => {
528
648
  setIsLoading(true);
529
649
  setError(null);
@@ -850,9 +970,23 @@ export function GraphQLPlayground({
850
970
  <div className="flex-1 overflow-hidden">
851
971
  {rightTab === "docs" ? (
852
972
  <div className="h-full flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
853
- <div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--kyro-border)]">
854
- <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Schema Explorer</span>
855
- <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
973
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)]">
974
+ <div className="relative flex-1">
975
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--kyro-text-muted)]" />
976
+ <input
977
+ type="text"
978
+ value={searchQuery}
979
+ onChange={(e) => setSearchQuery(e.target.value)}
980
+ placeholder="Search types and fields..."
981
+ className="w-full pl-7 pr-6 py-1 text-[10px] 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)]"
982
+ />
983
+ {searchQuery && (
984
+ <button onClick={() => setSearchQuery("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
985
+ <X className="w-3 h-3" />
986
+ </button>
987
+ )}
988
+ </div>
989
+ <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] shrink-0">
856
990
  <X className="w-3.5 h-3.5" />
857
991
  </button>
858
992
  </div>
@@ -863,7 +997,39 @@ export function GraphQLPlayground({
863
997
  </div>
864
998
  ) : schema ? (
865
999
  <div className="space-y-4">
866
- {selectedType ? (
1000
+ {searchQuery ? (
1001
+ <div className="space-y-1">
1002
+ {searchResults.length === 0 ? (
1003
+ <p className="text-[10px] text-[var(--kyro-text-muted)] py-4 text-center">No results for "{searchQuery}"</p>
1004
+ ) : (
1005
+ searchResults.map((r, i) => {
1006
+ const targetType = r.fieldInfo ? typeMap.get(resolveTypeName(r.fieldInfo.type)) || r.typeInfo : r.typeInfo;
1007
+ return (
1008
+ <button
1009
+ key={r.fieldPath + i}
1010
+ onClick={() => handleSearchSelect(r)}
1011
+ 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 group"
1012
+ >
1013
+ <span className="text-[10px] font-mono text-[var(--kyro-text-muted)]">
1014
+ {r.fieldPath.split(" > ").map((part, j) => (
1015
+ <span key={j}>
1016
+ {j > 0 && <span className="mx-1 opacity-40">›</span>}
1017
+ <span className={j === r.fieldPath.split(" > ").length - 1 ? "text-[var(--kyro-text-primary)] font-semibold" : "text-[var(--kyro-text-muted)]"}>
1018
+ {highlightMatch(part, searchQuery)}
1019
+ </span>
1020
+ </span>
1021
+ ))}
1022
+ {targetType && (targetType.kind === "UNION" || targetType.kind === "INTERFACE") && (
1023
+ <span className="ml-2 text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1 py-0.5 rounded">⧉</span>
1024
+ )}
1025
+ </span>
1026
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
1027
+ </button>
1028
+ );
1029
+ })
1030
+ )}
1031
+ </div>
1032
+ ) : selectedType ? (
867
1033
  <div className="space-y-3">
868
1034
  <button
869
1035
  onClick={() => setSelectedType(null)}
@@ -878,6 +1044,46 @@ export function GraphQLPlayground({
878
1044
  <p className="mt-1.5 text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">{selectedType.description}</p>
879
1045
  )}
880
1046
  </div>
1047
+ {selectedType.kind === "UNION" && selectedType.possibleTypes && (
1048
+ <div className="space-y-2">
1049
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Possible Types <span className="text-[8px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1 py-0.5 rounded ml-1">⧉ fragment</span></h4>
1050
+ <div className="space-y-1">
1051
+ {selectedType.possibleTypes.map(pt => (
1052
+ <button
1053
+ key={pt.name}
1054
+ onClick={() => {
1055
+ const t = typeMap.get(pt.name);
1056
+ if (t) setSelectedType(t);
1057
+ }}
1058
+ 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 group"
1059
+ >
1060
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{pt.name}</span>
1061
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
1062
+ </button>
1063
+ ))}
1064
+ </div>
1065
+ </div>
1066
+ )}
1067
+ {selectedType.kind === "INTERFACE" && selectedType.possibleTypes && (
1068
+ <div className="space-y-2">
1069
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Implementing Types <span className="text-[8px] font-mono text-[var(--kyro-text-muted)] bg-[var(--kyro-surface)] px-1 py-0.5 rounded border border-[var(--kyro-border)] ml-1">interface</span></h4>
1070
+ <div className="space-y-1">
1071
+ {selectedType.possibleTypes.map(pt => (
1072
+ <button
1073
+ key={pt.name}
1074
+ onClick={() => {
1075
+ const t = typeMap.get(pt.name);
1076
+ if (t) setSelectedType(t);
1077
+ }}
1078
+ 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 group"
1079
+ >
1080
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{pt.name}</span>
1081
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
1082
+ </button>
1083
+ ))}
1084
+ </div>
1085
+ </div>
1086
+ )}
881
1087
  {selectedType.fields && (
882
1088
  <div className="space-y-2">
883
1089
  <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Fields</h4>
@@ -892,9 +1098,46 @@ export function GraphQLPlayground({
892
1098
  >
893
1099
  <div className="flex items-center justify-between gap-2">
894
1100
  <span className="font-semibold text-[11px] text-[var(--kyro-text-primary)]">{f.name}</span>
895
- <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>
1101
+ <span className="flex items-center gap-1">
1102
+ {(() => {
1103
+ const rtName = resolveTypeName(f.type);
1104
+ const rt = typeMap.get(rtName);
1105
+ const isUnionish = rt && (rt.kind === "UNION" || rt.kind === "INTERFACE");
1106
+ return isUnionish ? (
1107
+ <span className="text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1.5 py-0.5 rounded flex items-center gap-1">
1108
+
1109
+ <span className="text-[8px] opacity-70">fragment</span>
1110
+ </span>
1111
+ ) : null;
1112
+ })()}
1113
+ <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>
1114
+ </span>
896
1115
  </div>
897
1116
  {f.description && <p className="text-[10px] text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
1117
+ {(() => {
1118
+ const rtName = resolveTypeName(f.type);
1119
+ const rt = typeMap.get(rtName);
1120
+ if (rt && (rt.kind === "UNION" || rt.kind === "INTERFACE") && rt.possibleTypes?.length) {
1121
+ return (
1122
+ <div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-primary)]/30 space-y-0.5">
1123
+ <span className="text-[8px] font-semibold text-[var(--kyro-text-muted)] uppercase tracking-wider">Possible Types</span>
1124
+ <div className="flex flex-wrap gap-1 mt-0.5">
1125
+ {rt.possibleTypes.map(pt => {
1126
+ const ptInfo = typeMap.get(pt.name);
1127
+ const hasFragment = ptInfo && (ptInfo.kind === "OBJECT" || ptInfo.kind === "INTERFACE");
1128
+ return (
1129
+ <span key={pt.name} className="text-[9px] font-mono text-[var(--kyro-text-muted)] bg-[var(--kyro-surface)] px-1.5 py-0.5 rounded border border-[var(--kyro-border)]">
1130
+ {pt.name}
1131
+ {hasFragment && <span className="ml-1 text-[8px] text-[var(--kyro-primary)]">⧉</span>}
1132
+ </span>
1133
+ );
1134
+ })}
1135
+ </div>
1136
+ </div>
1137
+ );
1138
+ }
1139
+ return null;
1140
+ })()}
898
1141
  {f.args && f.args.length > 0 && (
899
1142
  <div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-border)] space-y-0.5">
900
1143
  {f.args.map(a => (
@@ -934,14 +1177,18 @@ export function GraphQLPlayground({
934
1177
  </div>
935
1178
  <div className="space-y-1.5">
936
1179
  <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">All Types</h4>
937
- {schema.types.filter(t => !t.name.startsWith("__") && t.kind === "OBJECT").map(t => (
1180
+ {schema.types.filter(t => !t.name.startsWith("__") && (t.kind === "OBJECT" || t.kind === "UNION" || t.kind === "INTERFACE")).map(t => (
938
1181
  <button
939
1182
  key={t.name}
940
1183
  onClick={() => setSelectedType(t)}
941
- 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"
1184
+ 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 group"
942
1185
  >
943
- <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
944
- <ChevronRight className="w-3 h-3 opacity-30" />
1186
+ <div className="flex items-center gap-2">
1187
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
1188
+ {t.kind === "UNION" && <span className="text-[8px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1 py-0.5 rounded">union</span>}
1189
+ {t.kind === "INTERFACE" && <span className="text-[8px] font-mono text-[var(--kyro-text-muted)] bg-[var(--kyro-surface)] px-1 py-0.5 rounded border border-[var(--kyro-border)]">interface</span>}
1190
+ </div>
1191
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
945
1192
  </button>
946
1193
  ))}
947
1194
  </div>
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
2
2
  import { apiGet, apiDelete } from "../lib/api";
3
3
  import { Shield, Monitor, Trash2, Clock, AlertTriangle, Info, LogOut, Globe, Activity, RefreshCcw, Smartphone, Laptop } from "./ui/icons";
4
4
  import { PageHeader } from "./ui/PageHeader";
5
- import toast from "react-hot-toast";
5
+ import { toast } from "../lib/stores";
6
6
  import { Badge } from "./ui/Badge";
7
7
 
8
8
  interface Session {