@kyro-cms/admin 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.cjs +1196 -1727
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +4 -3
  4. package/dist/index.d.ts +4 -3
  5. package/dist/index.js +891 -1422
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/ActionBar.tsx +25 -174
  9. package/src/components/Admin.tsx +1 -3
  10. package/src/components/AuditLogsPage.tsx +2 -13
  11. package/src/components/AutoForm.tsx +160 -265
  12. package/src/components/DetailView.tsx +38 -66
  13. package/src/components/FieldRenderer.tsx +1 -1
  14. package/src/components/ListView.tsx +26 -198
  15. package/src/components/MediaGallery.tsx +117 -175
  16. package/src/components/RestPlayground.tsx +54 -47
  17. package/src/components/fields/BlocksField.tsx +8 -10
  18. package/src/components/fields/RelationshipBlockField.tsx +2 -3
  19. package/src/components/fields/RelationshipField.tsx +2 -3
  20. package/src/components/fix_imports.cjs +23 -0
  21. package/src/components/fix_imports2.cjs +19 -0
  22. package/src/components/replace_svgs.cjs +63 -0
  23. package/src/components/ui/Dropdown.tsx +7 -2
  24. package/src/components/ui/Modal.tsx +24 -27
  25. package/src/components/ui/PromptModal.tsx +2 -10
  26. package/src/components/ui/SlidePanel.tsx +2 -10
  27. package/src/components/ui/SplitButton.tsx +107 -0
  28. package/src/components/ui/Toaster.tsx +0 -1
  29. package/src/components/ui/icons.tsx +1 -0
  30. package/src/components/users/UsersList.tsx +8 -85
  31. package/src/hooks/useAutoFormState.ts +89 -161
  32. package/src/hooks/useQueue.ts +60 -0
  33. package/src/layouts/AdminLayout.astro +22 -2
  34. package/src/layouts/AuthLayout.astro +66 -18
  35. package/src/lib/autoform-store.ts +6 -2
  36. package/src/lib/globals.ts +5 -3
  37. package/src/pages/auth/register.astro +5 -1
@@ -1,8 +1,11 @@
1
+ import { Search, Check, Server } from "./ui/icons";
1
2
  import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
3
  import { createPortal } from "react-dom";
3
4
  import { Spinner } from "./ui/Spinner";
4
5
  import { Shimmer } from "./ui/Shimmer";
5
6
  import { SlidePanel } from "./ui/SlidePanel";
7
+ import { Modal } from "./ui/Modal";
8
+ import { Pagination } from "./ui/Pagination";
6
9
  import { Badge } from "./ui/Badge";
7
10
  import { Folder } from "./ui/icons";
8
11
 
@@ -455,19 +458,7 @@ export function MediaGallery({
455
458
 
456
459
  <div className={`flex items-center gap-3 flex-wrap lg:flex-nowrap ${pickerMode ? "w-full" : ""}`}>
457
460
  <div className="relative group flex-1 min-w-[200px]">
458
- <svg
459
- className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-secondary)] opacity-40 group-focus-within:opacity-100 transition-opacity"
460
- fill="none"
461
- stroke="currentColor"
462
- viewBox="0 0 24 24"
463
- >
464
- <path
465
- strokeLinecap="round"
466
- strokeLinejoin="round"
467
- strokeWidth="2.5"
468
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
469
- />
470
- </svg>
461
+ <Search className="w-4 h-4" />
471
462
  <input
472
463
  type="text"
473
464
  placeholder="Search assets..."
@@ -669,19 +660,7 @@ export function MediaGallery({
669
660
  onClick={(e) => handleSelectOne(item.id, e)}
670
661
  className={`kyro-btn-primary p-1.5 rounded-lg transition-all ${selectedIds.has(item.id) ? "" : "bg-white/10 text-white hover:bg-white/20"}`}
671
662
  >
672
- <svg
673
- className="w-3 h-3"
674
- fill="none"
675
- stroke="currentColor"
676
- viewBox="0 0 24 24"
677
- >
678
- <path
679
- strokeLinecap="round"
680
- strokeLinejoin="round"
681
- strokeWidth="3"
682
- d="M5 13l4 4L19 7"
683
- />
684
- </svg>
663
+ <Check className="w-4 h-4" />
685
664
  </button>
686
665
  </div>
687
666
  </div>
@@ -689,19 +668,7 @@ export function MediaGallery({
689
668
 
690
669
  {selectedIds.has(item.id) && (
691
670
  <div className="absolute top-3 left-3 w-6 h-6 rounded-lg bg-[var(--kyro-primary)] text-white flex items-center justify-center shadow-lg border-2 border-white/20 animate-in zoom-in duration-300">
692
- <svg
693
- className="w-3 h-3"
694
- fill="none"
695
- stroke="currentColor"
696
- viewBox="0 0 24 24"
697
- >
698
- <path
699
- strokeLinecap="round"
700
- strokeLinejoin="round"
701
- strokeWidth="3"
702
- d="M5 13l4 4L19 7"
703
- />
704
- </svg>
671
+ <Check className="w-4 h-4" />
705
672
  </div>
706
673
  )}
707
674
  </div>
@@ -802,30 +769,11 @@ export function MediaGallery({
802
769
  )}
803
770
  </div>
804
771
 
805
- {/* Pagination */}
806
- {totalPages > 1 && (
807
- <div className="p-6 border-t border-[var(--kyro-border)] bg-[var(--kyro-surface)]/50 backdrop-blur-md flex items-center justify-between">
808
- <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50">
809
- Page {page} of {totalPages}
810
- </span>
811
- <div className="flex gap-2">
812
- <button
813
- disabled={page === 1}
814
- onClick={() => setPage(page - 1)}
815
- className="px-4 py-2 border border-[var(--kyro-border)] rounded-xl text-xs font-bold text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] disabled:opacity-30 transition-all"
816
- >
817
- Previous
818
- </button>
819
- <button
820
- disabled={page === totalPages}
821
- onClick={() => setPage(page + 1)}
822
- className="px-6 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl text-xs font-bold shadow-lg hover:opacity-90 disabled:opacity-30 transition-all"
823
- >
824
- Next
825
- </button>
826
- </div>
827
- </div>
828
- )}
772
+ <Pagination
773
+ page={page}
774
+ totalPages={totalPages}
775
+ onPageChange={setPage}
776
+ />
829
777
  </div>
830
778
  </div>
831
779
 
@@ -1088,58 +1036,66 @@ export function MediaGallery({
1088
1036
  </SlidePanel>
1089
1037
 
1090
1038
  {/* Preview Modal */}
1091
- {showPreview &&
1092
- panelItem &&
1093
- createPortal(
1094
- <div className="fixed inset-0 z-[9999] bg-black/95 flex flex-col animate-in fade-in duration-500">
1095
- <div className="flex items-center justify-between p-6">
1096
- <div className="flex flex-col">
1097
- <span className="text-white font-bold text-lg tracking-tight">
1098
- {panelItem.filename}
1099
- </span>
1100
- <span className="text-white/40 text-[10px] font-bold tracking-widest mt-1">
1101
- {formatFileSize(panelItem.fileSize)} · {panelItem.mimeType}
1102
- </span>
1103
- </div>
1104
- <button
1105
- onClick={() => setShowPreview(false)}
1106
- className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl transition-all active:scale-90"
1107
- >
1108
- <X className="w-6 h-6" />
1109
- </button>
1110
- </div>
1111
- <div className="flex-1 w-full flex items-center justify-center p-12">
1112
- {panelItem.type === "image" ? (
1113
- <img
1114
- src={getAbsoluteUrl(panelItem.url)}
1115
- alt=""
1116
- className="max-h-full max-w-full object-contain shadow-2xl rounded-lg animate-in zoom-in-95 duration-500"
1117
- />
1118
- ) : panelItem.type === "video" ? (
1119
- <video
1120
- src={getAbsoluteUrl(panelItem.url)}
1121
- controls
1122
- autoPlay
1123
- className="max-h-full max-w-full rounded-lg shadow-2xl"
1124
- />
1125
- ) : (
1126
- <div className="text-white text-center">
1127
- <FileIcon className="w-24 h-24 mx-auto mb-6 opacity-20" />
1128
- <p className="text-xl font-bold opacity-50">
1129
- Preview not available for this file type
1130
- </p>
1131
- </div>
1132
- )}
1039
+ {showPreview && panelItem && (
1040
+ <Modal
1041
+ open={showPreview}
1042
+ onClose={() => setShowPreview(false)}
1043
+ title=""
1044
+ size="full"
1045
+ variant="lightbox"
1046
+ >
1047
+ <div className="flex items-center justify-between p-6">
1048
+ <div className="flex flex-col">
1049
+ <span className="text-white font-bold text-lg tracking-tight">
1050
+ {panelItem.filename}
1051
+ </span>
1052
+ <span className="text-white/40 text-[10px] font-bold tracking-widest mt-1">
1053
+ {formatFileSize(panelItem.fileSize)} · {panelItem.mimeType}
1054
+ </span>
1133
1055
  </div>
1134
- </div>,
1135
- document.body,
1136
- )}
1056
+ <button
1057
+ onClick={() => setShowPreview(false)}
1058
+ className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl transition-all active:scale-90"
1059
+ >
1060
+ <X className="w-6 h-6" />
1061
+ </button>
1062
+ </div>
1063
+ <div className="flex-1 w-full flex items-center justify-center p-12">
1064
+ {panelItem.type === "image" ? (
1065
+ <img
1066
+ src={getAbsoluteUrl(panelItem.url)}
1067
+ alt=""
1068
+ className="max-h-full max-w-full object-contain shadow-2xl rounded-lg animate-in zoom-in-95 duration-500"
1069
+ />
1070
+ ) : panelItem.type === "video" ? (
1071
+ <video
1072
+ src={getAbsoluteUrl(panelItem.url)}
1073
+ controls
1074
+ autoPlay
1075
+ className="max-h-full max-w-full rounded-lg shadow-2xl"
1076
+ />
1077
+ ) : (
1078
+ <div className="text-white text-center">
1079
+ <FileIcon className="w-24 h-24 mx-auto mb-6 opacity-20" />
1080
+ <p className="text-xl font-bold opacity-50">
1081
+ Preview not available for this file type
1082
+ </p>
1083
+ </div>
1084
+ )}
1085
+ </div>
1086
+ </Modal>
1087
+ )}
1137
1088
 
1138
1089
  {/* Crop Modal */}
1139
- {!pickerMode && showCrop &&
1140
- panelItem &&
1141
- createPortal(
1142
- <div className="fixed inset-0 z-[9999] bg-black/95 flex flex-col p-8">
1090
+ {!pickerMode && showCrop && panelItem && (
1091
+ <Modal
1092
+ open={showCrop}
1093
+ onClose={() => setShowCrop(false)}
1094
+ title=""
1095
+ size="full"
1096
+ variant="lightbox"
1097
+ >
1098
+ <div className="flex flex-col h-full p-8">
1143
1099
  <div className="flex items-center justify-between mb-8">
1144
1100
  <h3 className="text-white font-bold text-2xl tracking-tighter">
1145
1101
  Crop Image
@@ -1173,9 +1129,9 @@ export function MediaGallery({
1173
1129
  />
1174
1130
  </ReactCrop>
1175
1131
  </div>
1176
- </div>,
1177
- document.body,
1178
- )}
1132
+ </div>
1133
+ </Modal>
1134
+ )}
1179
1135
  {!pickerMode && (
1180
1136
  <PromptModal
1181
1137
  open={showNewFolderModal}
@@ -1185,67 +1141,53 @@ export function MediaGallery({
1185
1141
  placeholder="Folder name"
1186
1142
  />
1187
1143
  )}
1188
- {!pickerMode && showStorageConfigModal &&
1189
- createPortal(
1190
- <div className="fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-4">
1191
- <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-2xl p-8 max-w-md w-full shadow-2xl">
1192
- <div className="text-center">
1193
- <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--kyro-sidebar-active)] flex items-center justify-center">
1194
- <svg
1195
- className="w-8 h-8 text-[var(--kyro-sidebar-text-active)]"
1196
- fill="none"
1197
- stroke="currentColor"
1198
- viewBox="0 0 24 24"
1199
- >
1200
- <path
1201
- strokeLinecap="round"
1202
- strokeLinejoin="round"
1203
- strokeWidth={2}
1204
- d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2H5a2 2 0 00-2 2v1m2 2a2 2 0 11-4 0 2 2 0 014 0zm2 2h.008v.008H5v-.008z"
1205
- />
1206
- </svg>
1207
- </div>
1208
- <h3 className="text-xl font-bold text-[var(--kyro-text-primary)] mb-2">
1209
- Storage Not Configured
1210
- </h3>
1211
- <p className="text-[var(--kyro-text-secondary)] mb-6 text-sm">
1212
- Before uploading media, you need to configure your storage
1213
- settings. Choose where files should be stored and how URLs are
1214
- generated.
1215
- </p>
1216
- <div className="flex gap-3">
1217
- <a
1218
- href="/settings/storage-settings"
1219
- className="flex-1 px-4 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-center hover:opacity-90 transition-colors"
1220
- >
1221
- Configure Storage
1222
- </a>
1223
- <button
1224
- type="button"
1225
- onClick={() => {
1226
- // Set default storage config programmatically
1227
- apiPost("/api/globals/storage-settings", {
1228
- provider: "local",
1229
- local: {
1230
- uploadDir: "./public/uploads",
1231
- baseUrl: "/uploads",
1232
- },
1233
- }).then(() => {
1234
- setShowStorageConfigModal(false);
1235
- setStorageConfigured(true);
1236
- window.location.reload();
1237
- });
1238
- }}
1239
- className="flex-1 px-4 py-3 border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] rounded-xl font-bold hover:bg-[var(--kyro-surface-accent)] transition-colors"
1240
- >
1241
- Use Defaults
1242
- </button>
1243
- </div>
1244
- </div>
1144
+ {!pickerMode && (
1145
+ <Modal
1146
+ open={showStorageConfigModal}
1147
+ onClose={() => setShowStorageConfigModal(false)}
1148
+ title="Storage Not Configured"
1149
+ size="md"
1150
+ >
1151
+ <div className="text-center">
1152
+ <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--kyro-sidebar-active)] flex items-center justify-center">
1153
+ <Server className="w-8 h-8 text-[var(--kyro-sidebar-text-active)]" />
1245
1154
  </div>
1246
- </div>,
1247
- document.body,
1248
- )}
1155
+ <p className="text-[var(--kyro-text-secondary)] mb-6 text-sm">
1156
+ Before uploading media, you need to configure your storage
1157
+ settings. Choose where files should be stored and how URLs are
1158
+ generated.
1159
+ </p>
1160
+ <div className="flex gap-3">
1161
+ <a
1162
+ href="/settings/storage-settings"
1163
+ className="flex-1 px-4 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-center hover:opacity-90 transition-colors"
1164
+ >
1165
+ Configure Storage
1166
+ </a>
1167
+ <button
1168
+ type="button"
1169
+ onClick={() => {
1170
+ // Set default storage config programmatically
1171
+ apiPost("/api/globals/storage-settings", {
1172
+ provider: "local",
1173
+ local: {
1174
+ uploadDir: "./public/uploads",
1175
+ baseUrl: "/uploads",
1176
+ },
1177
+ }).then(() => {
1178
+ setShowStorageConfigModal(false);
1179
+ setStorageConfigured(true);
1180
+ window.location.reload();
1181
+ });
1182
+ }}
1183
+ className="flex-1 px-4 py-3 border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] rounded-xl font-bold hover:bg-[var(--kyro-surface-accent)] transition-colors"
1184
+ >
1185
+ Use Defaults
1186
+ </button>
1187
+ </div>
1188
+ </div>
1189
+ </Modal>
1190
+ )}
1249
1191
  {!pickerMode && (
1250
1192
  <input
1251
1193
  type="file"
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { useUIStore, toast } from "../lib/stores";
3
3
  import { apiPath } from "../lib/paths";
4
+ import { Modal } from "./ui/Modal";
4
5
 
5
6
  interface EnvVariable {
6
7
  key: string;
@@ -731,59 +732,65 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
731
732
  </div>
732
733
 
733
734
  {/* Modals */}
734
- {showFolderModal && (
735
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
736
- <div className="surface-tile w-full max-w-md p-6 rounded-2xl shadow-2xl border border-[var(--kyro-border)]">
737
- <h2 className="text-xl font-bold mb-4">Create Folder</h2>
735
+ <Modal
736
+ open={showFolderModal}
737
+ onClose={() => setShowFolderModal(false)}
738
+ title="Create Folder"
739
+ size="md"
740
+ footer={
741
+ <>
742
+ <button type="button" onClick={() => setShowFolderModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
743
+ <button type="button" onClick={createFolder} className="kyro-btn kyro-btn-md bg-pink-500 border-pink-500 text-white hover:bg-pink-600 hover:border-pink-600">Create</button>
744
+ </>
745
+ }
746
+ >
747
+ <div className="py-2">
748
+ <input
749
+ type="text"
750
+ value={newFolderName}
751
+ onChange={(e) => setNewFolderName(e.target.value)}
752
+ placeholder="Folder name..."
753
+ className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
754
+ />
755
+ </div>
756
+ </Modal>
757
+
758
+ <Modal
759
+ open={showSaveModal}
760
+ onClose={() => setShowSaveModal(false)}
761
+ title="Save Request"
762
+ size="md"
763
+ footer={
764
+ <>
765
+ <button type="button" onClick={() => setShowSaveModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
766
+ <button type="button" onClick={saveRequest} className="kyro-btn kyro-btn-md bg-pink-500 border-pink-500 text-white hover:bg-pink-600 hover:border-pink-600 disabled:opacity-50 disabled:cursor-not-allowed" disabled={!saveRequestName || !saveToFolderId}>Save</button>
767
+ </>
768
+ }
769
+ >
770
+ <div className="space-y-4 py-2">
771
+ <div>
772
+ <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)] block mb-1">Request Name</label>
738
773
  <input
739
774
  type="text"
740
- value={newFolderName}
741
- onChange={(e) => setNewFolderName(e.target.value)}
742
- placeholder="Folder name..."
743
- className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2 mb-6"
775
+ value={saveRequestName}
776
+ onChange={(e) => setSaveRequestName(e.target.value)}
777
+ placeholder="e.g. List Posts..."
778
+ className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
744
779
  />
745
- <div className="p-4 border-t border-[var(--kyro-border)] flex justify-end gap-2 bg-[var(--kyro-surface-accent)]">
746
- <button type="button" onClick={() => setShowFolderModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
747
- <button type="button" onClick={createFolder} className="kyro-btn kyro-btn-md bg-pink-500 border-pink-500 text-white hover:bg-pink-600 hover:border-pink-600">Create</button>
748
- </div>
749
780
  </div>
750
- </div>
751
- )}
752
-
753
- {showSaveModal && (
754
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
755
- <div className="surface-tile w-full max-w-md p-6 rounded-2xl shadow-2xl border border-[var(--kyro-border)]">
756
- <h2 className="text-xl font-bold mb-4">Save Request</h2>
757
- <div className="space-y-4">
758
- <div>
759
- <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)] block mb-1">Request Name</label>
760
- <input
761
- type="text"
762
- value={saveRequestName}
763
- onChange={(e) => setSaveRequestName(e.target.value)}
764
- placeholder="e.g. List Posts..."
765
- className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
766
- />
767
- </div>
768
- <div>
769
- <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)] block mb-1">Folder</label>
770
- <select
771
- value={saveToFolderId}
772
- onChange={(e) => setSaveToFolderId(e.target.value)}
773
- className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
774
- >
775
- <option value="">Select Folder...</option>
776
- {folders.map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
777
- </select>
778
- </div>
779
- </div>
780
- <div className="p-4 border-t border-[var(--kyro-border)] flex justify-end gap-2 bg-[var(--kyro-surface-accent)]">
781
- <button type="button" onClick={() => setShowSaveModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
782
- <button type="button" onClick={saveRequest} className="kyro-btn kyro-btn-md bg-pink-500 border-pink-500 text-white hover:bg-pink-600 hover:border-pink-600 disabled:opacity-50 disabled:cursor-not-allowed" disabled={!saveRequestName || !saveToFolderId}>Save</button>
783
- </div>
781
+ <div>
782
+ <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)] block mb-1">Folder</label>
783
+ <select
784
+ value={saveToFolderId}
785
+ onChange={(e) => setSaveToFolderId(e.target.value)}
786
+ className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-4 py-2"
787
+ >
788
+ <option value="">Select Folder...</option>
789
+ {folders.map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
790
+ </select>
784
791
  </div>
785
792
  </div>
786
- )}
793
+ </Modal>
787
794
  </div>
788
795
  );
789
796
  }
@@ -394,11 +394,9 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
394
394
  useEffect(() => {
395
395
  const valueArray = Array.isArray(value) ? value : [];
396
396
  const lastValueArray = lastValueRef.current || [];
397
- const valueIds = valueArray.map((b: Record<string, unknown>) => b.id).join(",");
398
- const lastValueIds = lastValueArray.map((b: Record<string, unknown>) => b.id).join(",");
399
-
400
- if (valueIds !== lastValueIds) {
401
- console.log("BlocksField sync: value=", value, "valueIds=", valueIds, "lastValueIds=", lastValueIds);
397
+
398
+ // Deep compare to catch external data changes (e.g. discard draft / auto-save restore)
399
+ if (JSON.stringify(valueArray) !== JSON.stringify(lastValueArray)) {
402
400
  const valueArrayCopy = [...valueArray];
403
401
  prevBlocksLengthRef.current = valueArrayCopy.length;
404
402
  prevBlockIdsRef.current = new Set(valueArrayCopy.map((b: Record<string, unknown>) => b.id));
@@ -407,6 +405,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
407
405
  isInitializedRef.current = true;
408
406
  } else if (valueArray.length === 0 && !isInitializedRef.current) {
409
407
  isInitializedRef.current = true;
408
+ lastValueRef.current = []; // Fix for new pages starting with empty arrays
410
409
  }
411
410
  }, [value, field.name, store]);
412
411
 
@@ -416,12 +415,11 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
416
415
  useEffect(() => {
417
416
  if (!onChangeRef.current) return;
418
417
  const lastValue = lastValueRef.current;
419
- if (!lastValue) return;
420
-
421
- const currentIds = blocks.map((b: Record<string, unknown>) => b.id).join(",");
422
- const lastIds = lastValue.map((b: Record<string, unknown>) => b.id).join(",");
418
+ if (!lastValue) return; // Wait until initialized
423
419
 
424
- if (currentIds !== lastIds) {
420
+ // Deep compare blocks vs lastValue to detect content edits, not just ID changes
421
+ if (JSON.stringify(blocks) !== JSON.stringify(lastValue)) {
422
+ lastValueRef.current = [...blocks]; // Update ref BEFORE firing onChange to prevent loops
425
423
  onChangeRef.current(blocks);
426
424
  }
427
425
  }, [blocks]);
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect } from "react";
2
2
  import { Search, Loader2, X } from "../ui/icons";
3
3
  import { apiGet, buildSearchQuery } from "../../lib/api";
4
+ import { EmptyState } from "../ui/EmptyState";
4
5
 
5
6
  interface RelationshipBlockFieldProps {
6
7
  relationTo?: string;
@@ -171,9 +172,7 @@ export const RelationshipBlockField: React.FC<RelationshipBlockFieldProps> = ({
171
172
  Loading...
172
173
  </div>
173
174
  ) : options.length === 0 ? (
174
- <div className="p-3 text-center text-sm text-[var(--kyro-text-muted)]">
175
- No results found
176
- </div>
175
+ <EmptyState title="No results found" />
177
176
  ) : (
178
177
  <div className="py-1">
179
178
  {options.map((opt) => (
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState, useRef, useCallback } from "react";
2
2
  import { Search, X, ChevronDown, Loader2 } from "../ui/icons";
3
3
  import { apiGet, buildSearchQuery } from "../../lib/api";
4
+ import { EmptyState } from "../ui/EmptyState";
4
5
 
5
6
  interface RelationshipFieldProps {
6
7
  field: {
@@ -314,9 +315,7 @@ export function RelationshipField({
314
315
  Loading...
315
316
  </div>
316
317
  ) : options.length === 0 ? (
317
- <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
318
- No results found
319
- </div>
318
+ <EmptyState title="No results found" />
320
319
  ) : (
321
320
  <div className="py-1">
322
321
  {options.map((opt) => (
@@ -0,0 +1,23 @@
1
+ const fs = require('fs');
2
+
3
+ const addImport = (file, icons, relativePath) => {
4
+ let content = fs.readFileSync(file, 'utf8');
5
+ if (!content.includes('from "' + relativePath + '"')) {
6
+ const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
7
+ content = importStatement + content;
8
+ fs.writeFileSync(file, content);
9
+ } else {
10
+ // If it exists, we might need to append to the existing import.
11
+ // To be safe and simple, let's just add a new line. TS merges imports.
12
+ const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
13
+ content = importStatement + content;
14
+ fs.writeFileSync(file, content);
15
+ }
16
+ }
17
+
18
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/MediaGallery.tsx', ['Search', 'Check', 'Server'], './ui/icons');
19
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ui/Modal.tsx', ['X'], './icons');
20
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ui/PromptModal.tsx', ['X'], './icons');
21
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ui/SlidePanel.tsx', ['X'], './icons');
22
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/users/UsersList.tsx', ['Plus', 'Lock', 'CheckCircle2', 'Edit2', 'Trash2', 'XCircle', 'X'], '../ui/icons');
23
+
@@ -0,0 +1,19 @@
1
+ const fs = require('fs');
2
+
3
+ const addImport = (file, icons, relativePath) => {
4
+ let content = fs.readFileSync(file, 'utf8');
5
+ if (!content.includes('from "' + relativePath + '"')) {
6
+ const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
7
+ content = importStatement + content;
8
+ fs.writeFileSync(file, content);
9
+ } else {
10
+ const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
11
+ content = importStatement + content;
12
+ fs.writeFileSync(file, content);
13
+ }
14
+ }
15
+
16
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/AuditLogsPage.tsx', ['Search'], './ui/icons');
17
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/AutoForm.tsx', ['ChevronRight', 'Check', 'ExternalLink', 'X'], './ui/icons');
18
+ addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ListView.tsx', ['Search', 'Filter', 'Columns3', 'X', 'Trash2', 'Archive', 'ChevronUp', 'Edit2'], './ui/icons');
19
+