@kyro-cms/admin 0.9.0 → 0.9.2

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 (114) hide show
  1. package/dist/index.cjs +11715 -11292
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +564 -0
  6. package/dist/index.d.ts +11 -10
  7. package/dist/index.js +11326 -10912
  8. package/dist/index.js.map +1 -1
  9. package/package.json +16 -12
  10. package/src/components/ActionBar.tsx +25 -161
  11. package/src/components/Admin.tsx +2 -4
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AuditLogsPage.tsx +2 -13
  14. package/src/components/AutoForm.tsx +572 -461
  15. package/src/components/BrandingHub.tsx +7 -4
  16. package/src/components/CreateView.tsx +2 -0
  17. package/src/components/DetailView.tsx +52 -65
  18. package/src/components/DeveloperCenter.tsx +8 -6
  19. package/src/components/FieldRenderer.tsx +94 -19
  20. package/src/components/ListView.tsx +57 -216
  21. package/src/components/MediaGallery.tsx +334 -367
  22. package/src/components/PluginsManager.tsx +197 -70
  23. package/src/components/RestPlayground.tsx +59 -52
  24. package/src/components/SessionsManager.tsx +1 -1
  25. package/src/components/SettingsPage.tsx +22 -0
  26. package/src/components/Sidebar.astro +13 -41
  27. package/src/components/UserManagement.tsx +153 -15
  28. package/src/components/UserMenu.tsx +30 -4
  29. package/src/components/VersionHistoryPanel.tsx +112 -119
  30. package/src/components/WebhookManager.tsx +6 -4
  31. package/src/components/blocks/ArrayBlock.tsx +6 -23
  32. package/src/components/blocks/BlockEditModal.tsx +82 -309
  33. package/src/components/blocks/CardBlock.tsx +35 -0
  34. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  35. package/src/components/blocks/GenericBlock.tsx +44 -0
  36. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  37. package/src/components/blocks/HeroBlock.tsx +5 -14
  38. package/src/components/blocks/RichTextBlock.tsx +5 -5
  39. package/src/components/blocks/index.ts +5 -3
  40. package/src/components/fields/AccordionField.tsx +2 -2
  41. package/src/components/fields/ArrayField.tsx +1 -1
  42. package/src/components/fields/ArrayLayout.tsx +120 -29
  43. package/src/components/fields/BlocksField.tsx +433 -55
  44. package/src/components/fields/CardField.tsx +73 -0
  45. package/src/components/fields/CheckboxField.tsx +7 -3
  46. package/src/components/fields/DateField.tsx +4 -1
  47. package/src/components/fields/GroupLayout.tsx +2 -2
  48. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  49. package/src/components/fields/ListField.tsx +2 -2
  50. package/src/components/fields/NumberField.tsx +4 -1
  51. package/src/components/fields/RelationshipBlockField.tsx +2 -3
  52. package/src/components/fields/RelationshipField.tsx +155 -90
  53. package/src/components/fields/RichTextField.tsx +781 -0
  54. package/src/components/fields/SecretField.tsx +102 -0
  55. package/src/components/fields/SelectField.tsx +19 -6
  56. package/src/components/fields/TabsLayout.tsx +19 -9
  57. package/src/components/fields/TextField.tsx +4 -1
  58. package/src/components/fields/UploadField.tsx +122 -56
  59. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  60. package/src/components/fields/extensions/blocksStore.ts +8 -1
  61. package/src/components/fields/index.ts +4 -2
  62. package/src/components/fix_imports.cjs +23 -0
  63. package/src/components/fix_imports2.cjs +19 -0
  64. package/src/components/replace_svgs.cjs +63 -0
  65. package/src/components/ui/Dropdown.tsx +7 -2
  66. package/src/components/ui/Modal.tsx +24 -27
  67. package/src/components/ui/PageHeader.tsx +5 -5
  68. package/src/components/ui/PromptModal.tsx +2 -10
  69. package/src/components/ui/SlidePanel.tsx +10 -13
  70. package/src/components/ui/SplitButton.tsx +107 -0
  71. package/src/components/ui/Toaster.tsx +0 -1
  72. package/src/components/ui/icons.tsx +110 -109
  73. package/src/components/users/UserDetail.tsx +79 -16
  74. package/src/components/users/UsersList.tsx +8 -85
  75. package/src/hooks/useAutoFormState.ts +187 -196
  76. package/src/hooks/useQueue.ts +60 -0
  77. package/src/integration.ts +148 -46
  78. package/src/kyro-cms.d.ts +7 -2
  79. package/src/layouts/AdminLayout.astro +22 -2
  80. package/src/layouts/AuthLayout.astro +67 -7
  81. package/src/lib/autoform-store.ts +90 -53
  82. package/src/lib/change-source.ts +9 -0
  83. package/src/lib/config.ts +104 -8
  84. package/src/lib/globals.ts +48 -11
  85. package/src/lib/normalize-upload-fields.ts +41 -0
  86. package/src/lib/paths.ts +2 -2
  87. package/src/lib/resolve-field-value.ts +110 -0
  88. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  89. package/src/lib/shim/use-sync-external-store.js +1 -0
  90. package/src/lib/stores/index.ts +1 -0
  91. package/src/lib/useResourceManager.ts +4 -4
  92. package/src/lib/vite-shim-plugin.ts +100 -0
  93. package/src/pages/[collection]/[id].astro +1 -1
  94. package/src/pages/auth/register.astro +5 -2
  95. package/src/pages/preview/[collection]/[id].astro +4 -4
  96. package/src/pages/settings/[slug].astro +2 -2
  97. package/src/styles/main.css +60 -54
  98. package/README.md +0 -46
  99. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  100. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  101. package/dist/EditorClient-T5PASFNR.js +0 -466
  102. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  103. package/dist/chunk-3BGDYKTD.cjs +0 -348
  104. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  105. package/dist/chunk-EEFXLQVT.js +0 -3
  106. package/dist/chunk-EEFXLQVT.js.map +0 -1
  107. package/src/components/blocks/ButtonBlock.tsx +0 -64
  108. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  109. package/src/components/blocks/DividerBlock.tsx +0 -43
  110. package/src/components/blocks/LinkBlock.tsx +0 -65
  111. package/src/components/blocks/VStackBlock.tsx +0 -29
  112. package/src/components/fields/EditorClient.tsx +0 -535
  113. package/src/components/fields/PortableTextField.tsx +0 -155
  114. package/src/components/fields/PortableTextRenderer.tsx +0 -68
@@ -1,8 +1,12 @@
1
1
  import { useEffect, useRef, useCallback } from "react";
2
2
  import { useAutoFormStore } from "../lib/autoform-store";
3
+ import { getLastChangeSource, setChangeSource } from "../lib/change-source";
3
4
  import { slugifyText } from "../lib/slugify";
4
5
  import { resolveUrl, fetchWithAuth } from "../lib/api";
6
+ import { normalizeUploadFields } from "../lib/normalize-upload-fields";
5
7
  import { useUIStore } from "../lib/stores";
8
+ import { resolveFieldValue } from "../lib/resolve-field-value";
9
+ import { useQueue } from "./useQueue";
6
10
 
7
11
  interface UseAutoFormStateProps {
8
12
  config: Record<string, unknown>;
@@ -24,7 +28,7 @@ export function useAutoFormState({
24
28
  onActionError,
25
29
  }: UseAutoFormStateProps) {
26
30
  const store = useAutoFormStore();
27
- const { confirm, alert } = useUIStore();
31
+ const { confirm } = useUIStore();
28
32
  const {
29
33
  formData,
30
34
  setFormData,
@@ -43,15 +47,46 @@ export function useAutoFormState({
43
47
  getDraftCache,
44
48
  setDraftCache,
45
49
  clearDraftCache,
50
+ resetForm,
46
51
  } = store;
47
52
 
48
53
  const versionsEnabled = !!config.versions;
49
54
 
55
+ // Guard: clear stale formData from a previous page context.
56
+ // For collections, if the loaded document's id doesn't match the expected
57
+ // context key, the singleton zustand store is holding stale data.
58
+ // Globals have no id field and are singleton per slug, so skip this check.
59
+ // Must run in useEffect — calling resetForm() during render triggers
60
+ // "Cannot update a component while rendering" React error.
61
+ const currentContextKey = globalSlug || (initialData?.id as string | undefined) || collectionSlug;
62
+ const needsResetRef = useRef(false);
63
+ if (
64
+ !globalSlug &&
65
+ currentContextKey &&
66
+ formData &&
67
+ Object.keys(formData).length > 0 &&
68
+ formData.id !== currentContextKey
69
+ ) {
70
+ needsResetRef.current = true;
71
+ }
72
+
73
+ useEffect(() => {
74
+ if (needsResetRef.current) {
75
+ needsResetRef.current = false;
76
+ resetForm();
77
+ }
78
+ }, [resetForm]);
79
+
50
80
  const localSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
51
81
  const serverSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
82
+ const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
83
+ const isOnlineRef = useRef(typeof navigator !== 'undefined' ? navigator.onLine : true);
52
84
  const lastAutoSaveTimeRef = useRef<number>(0);
53
85
  const autoSaveSkipRef = useRef<boolean>(false);
54
86
  const restorePromptedRef = useRef<string | null>(null);
87
+ const previousFormDataRef = useRef<string>("");
88
+ const astroSyncDataRef = useRef<string>("");
89
+ const { queueTask } = useQueue();
55
90
 
56
91
  const getDocumentKey = useCallback(
57
92
  (id?: string) => {
@@ -78,30 +113,6 @@ const persistBrowserDraft = useCallback(
78
113
  [lastSavedData.updatedAt, setDraftCache],
79
114
  );
80
115
 
81
- const clearDraftArtifacts = useCallback(async () => {
82
- const state = useAutoFormStore.getState();
83
- const documentKey = getDocumentKey(state.formData.id);
84
- if (documentKey) {
85
- clearDraftCache(documentKey);
86
- }
87
-
88
- const draftUrl = globalSlug
89
- ? resolveUrl(`/api/globals/${globalSlug}/draft`)
90
- : collectionSlug && state.formData.id
91
- ? resolveUrl(`/api/${collectionSlug}/${state.formData.id}/draft`)
92
- : null;
93
-
94
- if (draftUrl && versionsEnabled) {
95
- try {
96
- await fetchWithAuth(draftUrl, {
97
- method: "DELETE",
98
- });
99
- } catch (err) {
100
- console.error("Failed to clear draft snapshot:", err);
101
- }
102
- }
103
- }, [clearDraftCache, collectionSlug, globalSlug, getDocumentKey]);
104
-
105
116
  const fetchVersions = useCallback(async () => {
106
117
  const url = globalSlug
107
118
  ? resolveUrl(`/api/globals/${globalSlug}/versions`)
@@ -125,89 +136,124 @@ const persistBrowserDraft = useCallback(
125
136
  const performLocalAutoSave = useCallback(() => {
126
137
  const state = useAutoFormStore.getState();
127
138
  const latestFormData = state.formData;
128
- const currentLastSaved = state.lastSavedData;
129
139
  if (autoSaveSkipRef.current || !collectionSlug || !latestFormData.id) return;
130
- if (JSON.stringify(latestFormData) === JSON.stringify(currentLastSaved)) return;
140
+ if (!state.hasDirtyFields()) return;
131
141
  const documentKey = getDocumentKey(latestFormData.id);
132
142
  if (documentKey) {
133
143
  persistBrowserDraft(documentKey, latestFormData);
134
144
  }
135
145
  }, [collectionSlug, getDocumentKey, persistBrowserDraft]);
136
146
 
137
- const performServerAutoSave = useCallback(async (options?: { keepalive?: boolean }) => {
147
+ const doAutosaveFetch = useCallback(async (options?: { keepalive?: boolean }) => {
138
148
  const state = useAutoFormStore.getState();
139
149
  const latestFormData = state.formData;
140
150
  const currentLastSaved = state.lastSavedData;
141
-
151
+
142
152
  if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
143
153
  if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
144
- if (JSON.stringify(latestFormData) === JSON.stringify(currentLastSaved)) return;
145
-
154
+ if (!state.hasDirtyFields()) return;
155
+
146
156
  const documentKey = getDocumentKey(latestFormData.id);
147
157
  if (documentKey) {
148
158
  persistBrowserDraft(documentKey, latestFormData);
149
159
  }
150
160
 
161
+ if (!isOnlineRef.current) {
162
+ setAutoSaveStatus("offline");
163
+ return;
164
+ }
165
+
151
166
  setIsAutoSaving(true);
152
167
  setAutoSaveStatus("saving");
168
+ state.setBackgroundProcessing(true);
153
169
 
154
170
  try {
155
- const draftUpdatedAt = new Date().toISOString();
156
- const draftUrl = globalSlug
157
- ? resolveUrl(`/api/globals/${globalSlug}/draft`)
158
- : resolveUrl(`/api/${collectionSlug}/${latestFormData.id}/draft`);
159
-
160
- const response = await fetchWithAuth(
161
- draftUrl,
162
- {
163
- method: "PUT",
164
- headers: { "Content-Type": "application/json" },
165
- keepalive: options?.keepalive,
166
- body: JSON.stringify({
167
- data: latestFormData,
168
- baseUpdatedAt: currentLastSaved.updatedAt ?? null,
169
- draftUpdatedAt,
170
- }),
171
+ const url = globalSlug
172
+ ? resolveUrl(`/api/globals/${globalSlug}?autosave=true`)
173
+ : resolveUrl(`/api/${collectionSlug}/${latestFormData.id}?autosave=true`);
174
+
175
+ const response = await fetchWithAuth(url, {
176
+ method: "PATCH",
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ "X-Draft": "true",
171
180
  },
172
- );
181
+ keepalive: options?.keepalive,
182
+ body: JSON.stringify({
183
+ ...normalizeUploadFields(latestFormData),
184
+ baseUpdatedAt: currentLastSaved.updatedAt ?? null,
185
+ }),
186
+ });
173
187
 
174
188
  if (response.ok) {
175
- const result = await response.json();
176
189
  lastAutoSaveTimeRef.current = Date.now();
190
+ state.setRetryCount(0);
191
+ state.setLastSavedAt(Date.now());
192
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
193
+
177
194
  if (documentKey) {
178
195
  setDraftCache(documentKey, {
179
196
  data: latestFormData,
180
197
  baseUpdatedAt: currentLastSaved.updatedAt ?? null,
181
- draftUpdatedAt: result.data?.draftUpdatedAt || draftUpdatedAt,
182
- lastSyncedAt: result.data?.updatedAt || new Date().toISOString(),
198
+ draftUpdatedAt: new Date().toISOString(),
199
+ lastSyncedAt: (await response.clone().json()).data?.updatedAt || new Date().toISOString(),
183
200
  });
184
201
  }
185
202
  setAutoSaveStatus("success");
186
- setTimeout(() => setAutoSaveStatus("idle"), 2000);
203
+ setTimeout(() => {
204
+ if (useAutoFormStore.getState().autoSaveStatus === "success") {
205
+ setAutoSaveStatus("idle");
206
+ }
207
+ }, 2000);
208
+ } else if (response.status === 409) {
209
+ setAutoSaveStatus("conflict");
187
210
  } else {
188
- const error = await response.json().catch(() => ({}));
189
- console.error("Draft auto-save failed:", error);
190
- setAutoSaveStatus("error");
191
- setTimeout(() => setAutoSaveStatus("idle"), 5000);
211
+ throw new Error(`Draft auto-save failed with status ${response.status}`);
192
212
  }
193
213
  } catch (err) {
194
214
  console.error("Auto-save failed:", err);
195
- setAutoSaveStatus("error");
196
- setTimeout(() => setAutoSaveStatus("idle"), 5000);
215
+ const currentState = useAutoFormStore.getState();
216
+ const currentRetryCount = currentState.retryCount;
217
+ if (currentRetryCount < 5) {
218
+ currentState.setRetryCount(currentRetryCount + 1);
219
+ setAutoSaveStatus("retrying");
220
+ const delay = Math.min(1000 * Math.pow(2, currentRetryCount), 60000);
221
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
222
+ retryTimerRef.current = setTimeout(() => performAutosave(options), delay);
223
+ } else {
224
+ setAutoSaveStatus("offline");
225
+ }
197
226
  } finally {
198
227
  setIsAutoSaving(false);
228
+ useAutoFormStore.getState().setBackgroundProcessing(false);
199
229
  }
200
230
  }, [
201
231
  collectionSlug,
202
232
  getDocumentKey,
233
+ globalSlug,
203
234
  persistBrowserDraft,
204
235
  setAutoSaveStatus,
205
236
  setDraftCache,
206
237
  setIsAutoSaving,
238
+ versionsEnabled,
207
239
  ]);
208
240
 
209
- const saveDocument = useCallback(
210
- async (dataOverride?: Record<string, unknown>) => {
241
+ const performAutosave = useCallback((options?: { keepalive?: boolean }) => {
242
+ queueTask(
243
+ () => doAutosaveFetch(options),
244
+ {
245
+ beforeProcess: () => {
246
+ return true;
247
+ },
248
+ afterProcess: () => {
249
+ // Background processing complete
250
+ },
251
+ },
252
+ );
253
+ }, [doAutosaveFetch, queueTask]);
254
+
255
+ const saveDocument = useCallback(
256
+ async (dataOverride?: Record<string, unknown>, isDraft = true) => {
211
257
  const state = useAutoFormStore.getState();
212
258
  const payload = dataOverride || state.formData;
213
259
 
@@ -219,9 +265,12 @@ const saveDocument = useCallback(
219
265
  url,
220
266
  {
221
267
  method: "PATCH",
222
- headers: { "Content-Type": "application/json" },
268
+ headers: {
269
+ "Content-Type": "application/json",
270
+ "X-Draft": String(isDraft),
271
+ },
223
272
  body: JSON.stringify({
224
- ...payload,
273
+ ...normalizeUploadFields(payload) as Record<string, unknown>,
225
274
  baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
226
275
  }),
227
276
  },
@@ -236,46 +285,6 @@ const saveDocument = useCallback(
236
285
  [collectionSlug, globalSlug, setAutoSaveStatus],
237
286
  );
238
287
 
239
- const publishDocument = useCallback(async () => {
240
- const state = useAutoFormStore.getState();
241
- const url = globalSlug
242
- ? resolveUrl(`/api/globals/${globalSlug}/publish`)
243
- : resolveUrl(`/api/${collectionSlug}/${state.formData.id}/publish`);
244
-
245
- const response = await fetchWithAuth(
246
- url,
247
- {
248
- method: "POST",
249
- headers: { "Content-Type": "application/json" },
250
- body: JSON.stringify({
251
- baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
252
- }),
253
- },
254
- );
255
-
256
- if (response.status === 409) {
257
- setAutoSaveStatus("conflict");
258
- }
259
-
260
- return response;
261
- }, [collectionSlug, globalSlug, setAutoSaveStatus]);
262
-
263
- const unpublishDocument = useCallback(async () => {
264
- const state = useAutoFormStore.getState();
265
- const url = globalSlug
266
- ? resolveUrl(`/api/globals/${globalSlug}/unpublish`)
267
- : resolveUrl(`/api/${collectionSlug}/${state.formData.id}/unpublish`);
268
-
269
- const response = await fetchWithAuth(
270
- url,
271
- {
272
- method: "POST",
273
- headers: { "Content-Type": "application/json" },
274
- },
275
- );
276
- return response;
277
- }, [collectionSlug, globalSlug]);
278
-
279
288
  // Track sidebar toggle
280
289
  useEffect(() => {
281
290
  const handleToggle = () => {
@@ -285,24 +294,22 @@ const saveDocument = useCallback(
285
294
  return () => window.removeEventListener("toggle-sidebar", handleToggle);
286
295
  }, [sidebarCollapsed, setSidebarCollapsed]);
287
296
 
288
- // Track unsaved changes
289
- useEffect(() => {
290
- const isDifferent = JSON.stringify(formData) !== JSON.stringify(lastSavedData);
291
- setHasUnsavedChanges(isDifferent);
292
- }, [formData, lastSavedData, setHasUnsavedChanges]);
297
+ // Track unsaved changes (handled by setField / dirtyFields now)
293
298
 
294
299
  // Initial data load
295
300
  const lastLoadedSlugRef = useRef<string | null>(null);
301
+ const lastInitialDataRef = useRef<string>("");
296
302
  const initialDataLoadedRef = useRef(false);
297
303
  useEffect(() => {
298
304
  const currentSlug = globalSlug || initialData?.id;
299
- if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug) return;
305
+ const serialized = JSON.stringify(initialData);
306
+ if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug && lastInitialDataRef.current === serialized) return;
300
307
 
301
- setFormData(initialData || {});
302
308
  loadDocument(initialData || {}, initialData || {});
303
309
  initialDataLoadedRef.current = true;
304
310
  lastLoadedSlugRef.current = currentSlug;
305
- }, [formData.id, globalSlug, initialData, loadDocument, setFormData]);
311
+ lastInitialDataRef.current = serialized;
312
+ }, [collectionSlug, formData.id, globalSlug, initialData, loadDocument]);
306
313
 
307
314
  useEffect(() => {
308
315
  if (!collectionSlug || !initialData?.id) return;
@@ -316,33 +323,10 @@ const saveDocument = useCallback(
316
323
  const maybeRestoreDraft = async () => {
317
324
  if (!versionsEnabled) return;
318
325
  const browserDraft = getDraftCache(documentKey);
319
- let serverDraft: Record<string, unknown> | null = null;
320
-
321
- try {
322
- const response = await fetchWithAuth(
323
- resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
324
- );
325
- if (response.ok) {
326
- const result: { data?: Record<string, unknown> } = await response.json();
327
- serverDraft = result.data || null;
328
- }
329
- } catch (err) {
330
- console.error("Failed to fetch server draft:", err);
331
- }
332
-
333
- const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
334
- data: Record<string, unknown>;
335
- draftUpdatedAt: string;
336
- }>;
337
-
338
- const candidate = drafts.sort(
339
- (a, b) =>
340
- new Date(b.draftUpdatedAt).getTime() -
341
- new Date(a.draftUpdatedAt).getTime(),
342
- )[0];
343
326
 
344
- if (!candidate) return;
345
- if (JSON.stringify(candidate.data) === JSON.stringify(initialData)) {
327
+ if (!browserDraft) return;
328
+ if (JSON.stringify(browserDraft.data) === JSON.stringify(initialData)) {
329
+ clearDraftCache(documentKey);
346
330
  return;
347
331
  }
348
332
 
@@ -356,19 +340,13 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
356
340
  cancelLabel: "Discard draft",
357
341
  onConfirm: async () => {
358
342
  if (cancelled) return;
359
- setFormData(candidate.data);
343
+ const currentFormData = useAutoFormStore.getState().formData;
344
+ const mergedData = { ...currentFormData, ...browserDraft.data };
345
+ setFormData(mergedData);
360
346
  onActionSuccess?.("Recovered autosaved draft");
361
347
  },
362
348
  onCancel: async () => {
363
349
  clearDraftCache(documentKey);
364
- try {
365
- await fetchWithAuth(
366
- resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
367
- { method: "DELETE" },
368
- );
369
- } catch (err) {
370
- console.error("Failed to discard server draft:", err);
371
- }
372
350
  },
373
351
  });
374
352
  };
@@ -387,6 +365,7 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
387
365
  initialData,
388
366
  onActionSuccess,
389
367
  setFormData,
368
+ versionsEnabled,
390
369
  ]);
391
370
 
392
371
  // Recursively find a field by name inside tabs/group/collapsible
@@ -409,45 +388,28 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
409
388
 
410
389
  // Auto-generate metaTitle
411
390
  useEffect(() => {
412
- const metaTitleField = findFieldDeep(config.fields, "metaTitle");
391
+ const fields = config.fields as Record<string, unknown>[];
392
+ const metaTitleField = findFieldDeep(fields, "metaTitle");
413
393
  if (!metaTitleField) return;
414
394
 
415
- let titleValue = "";
416
- for (const field of config.fields) {
417
- if (field.type === "tabs" && "tabs" in field && field.name) {
418
- const tabData = formData[field.name as string];
419
- if (tabData && typeof tabData === "object" && tabData.title) {
420
- titleValue = tabData.title;
421
- break;
422
- }
423
- }
424
- }
395
+ const titleValue = resolveFieldValue(fields, formData, "title");
396
+ const titleStr = titleValue ? String(titleValue) : "";
425
397
 
426
- if (titleValue && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
427
- setField("metaTitle", titleValue);
398
+ if (titleStr && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
399
+ setField("metaTitle", titleStr);
428
400
  }
429
401
  }, [formData, config.fields, setField]);
430
402
 
431
403
  // Auto-generate slug
432
404
  useEffect(() => {
433
- const slugField = config.fields.find(
434
- (f: Record<string, unknown>) => f.name === "slug" && f.admin?.autoGenerate,
435
- );
405
+ const fields = config.fields as Record<string, unknown>[];
406
+ const slugField = fields.find(
407
+ (f: Record<string, unknown>) => f.name === "slug" && f.admin?.autoGenerate,
408
+ );
436
409
  if (!slugField?.admin?.autoGenerate) return;
437
410
  const sourceField: string = slugField.admin.autoGenerate;
438
411
 
439
- let sourceValue = formData[sourceField];
440
- if (!sourceValue) {
441
- for (const field of config.fields) {
442
- if (field.type === "tabs" && "tabs" in field && field.name) {
443
- const tabData = formData[field.name as string];
444
- if (tabData && typeof tabData === "object" && tabData[sourceField]) {
445
- sourceValue = tabData[sourceField];
446
- break;
447
- }
448
- }
449
- }
450
- }
412
+ const sourceValue = resolveFieldValue(fields, formData, sourceField);
451
413
 
452
414
  if (isSlugLocked && sourceValue) {
453
415
  const newSlug = slugifyText(sourceValue);
@@ -457,29 +419,43 @@ const slugField = config.fields.find(
457
419
  }
458
420
  }, [formData, isSlugLocked, config.fields, setField]);
459
421
 
460
- // Auto-save effect (Split-timer: 1.5s local, 10s server)
422
+ // Auto-save effect only starts timers on keystroke-originated changes.
423
+ // Local save fires after 1.5s of inactivity, server save after 8s.
424
+ // Non-keystroke changes (block add/drag, select, checkbox, etc.) do NOT restart auto-save.
461
425
  useEffect(() => {
462
426
  if (sidebarCollapsed) return;
463
427
  if (!globalSlug && (!collectionSlug || !formData.id)) return;
464
428
 
465
- if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
466
- if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
429
+ const state = useAutoFormStore.getState();
430
+ if (!state.hasDirtyFields()) return;
431
+
432
+ // Only schedule/reschedule on keystroke-originated changes
433
+ if (getLastChangeSource() !== "keystroke") return;
434
+ setChangeSource("other");
435
+
436
+ // Compare serialized form data to avoid scheduling for unchanged metadata
437
+ const serialized = JSON.stringify(formData);
438
+ if (serialized === previousFormDataRef.current) return;
467
439
 
440
+ if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
468
441
  localSaveTimerRef.current = setTimeout(performLocalAutoSave, 1500);
469
- serverSaveTimerRef.current = setTimeout(performServerAutoSave, 10000);
470
442
 
471
- return () => {
472
- if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
473
- if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
474
- };
475
- }, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performServerAutoSave]);
443
+ // Queue autosave via the queue (debounced at 8s)
444
+ if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
445
+ serverSaveTimerRef.current = setTimeout(() => {
446
+ previousFormDataRef.current = serialized;
447
+ performAutosave();
448
+ }, 8000);
449
+ }, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performAutosave]);
476
450
 
477
451
  useEffect(() => {
478
452
  if (!globalSlug && (!collectionSlug || !formData.id)) return;
479
453
 
480
454
  const flushDraft = () => {
481
455
  if (autoSaveSkipRef.current) return;
482
- void performServerAutoSave({ keepalive: true });
456
+ const state = useAutoFormStore.getState();
457
+ if (!state.hasDirtyFields()) return;
458
+ void performAutosave({ keepalive: true });
483
459
  };
484
460
 
485
461
  const handleVisibilityChange = () => {
@@ -488,22 +464,43 @@ const slugField = config.fields.find(
488
464
  }
489
465
  };
490
466
 
467
+ const handleOnline = () => {
468
+ isOnlineRef.current = true;
469
+ flushDraft();
470
+ };
471
+
472
+ const handleOffline = () => {
473
+ isOnlineRef.current = false;
474
+ const state = useAutoFormStore.getState();
475
+ if (state.hasDirtyFields()) {
476
+ state.setAutoSaveStatus("offline");
477
+ }
478
+ };
479
+
491
480
  window.addEventListener("blur", flushDraft);
492
481
  window.addEventListener("pagehide", flushDraft);
482
+ window.addEventListener("online", handleOnline);
483
+ window.addEventListener("offline", handleOffline);
493
484
  document.addEventListener("visibilitychange", handleVisibilityChange);
494
485
 
495
486
  return () => {
496
487
  window.removeEventListener("blur", flushDraft);
497
488
  window.removeEventListener("pagehide", flushDraft);
489
+ window.removeEventListener("online", handleOnline);
490
+ window.removeEventListener("offline", handleOffline);
498
491
  document.removeEventListener("visibilitychange", handleVisibilityChange);
499
492
  };
500
- }, [collectionSlug, globalSlug, formData.id, performServerAutoSave]);
493
+ }, [collectionSlug, globalSlug, formData.id, performAutosave]);
501
494
 
502
- // Astro sync
495
+ // Astro sync — avoid ping-pong loop with DetailView's setData
503
496
  useEffect(() => {
497
+ const serialized = JSON.stringify(formData);
498
+ if (serialized === astroSyncDataRef.current) return;
499
+ astroSyncDataRef.current = serialized;
500
+
504
501
  const hiddenInput = document.getElementById("form-data") as HTMLInputElement;
505
502
  if (hiddenInput) {
506
- hiddenInput.value = JSON.stringify(formData);
503
+ hiddenInput.value = serialized;
507
504
  }
508
505
  onChange?.(formData);
509
506
  }, [formData, onChange]);
@@ -515,27 +512,21 @@ const slugField = config.fields.find(
515
512
 
516
513
  // Derived status values the UI can use for badges and button state
517
514
  const documentStatus: 'draft' | 'published' | 'archived' | undefined = (() => {
518
- if (!versionsEnabled) return 'published';
519
515
  if (!formData.id && !globalSlug) return 'draft';
520
- // If it has a pending draft version, effectively it's in a draft state for the editor
521
- if (formData._has_draft) return 'draft';
522
- return formData._status || 'published';
516
+ if (!versionsEnabled) return 'published';
517
+ return (formData.status as 'draft' | 'published' | undefined) || 'published';
523
518
  })();
524
519
 
525
520
  const hasUnpublishedChanges =
526
- (!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData._has_draft);
521
+ (!!formData.id || !!globalSlug) && documentStatus === 'draft';
527
522
 
528
523
  return {
529
524
  ...store,
530
525
  fetchVersions,
531
- performAutoSave: performServerAutoSave,
526
+ performAutoSave: performAutosave,
532
527
  saveDocument,
533
- publishDocument,
534
- unpublishDocument,
535
- clearDraftArtifacts,
536
528
  autoSaveSkipRef,
537
529
  lastAutoSaveTimeRef,
538
- alert,
539
530
  documentStatus,
540
531
  hasUnpublishedChanges,
541
532
  versionsEnabled,
@@ -0,0 +1,60 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ type QueuedFunction = () => Promise<void>;
4
+
5
+ type QueueTaskOptions = {
6
+ afterProcess?: () => void;
7
+ beforeProcess?: () => boolean;
8
+ };
9
+
10
+ type QueueTask = (fn: QueuedFunction, options?: QueueTaskOptions) => void;
11
+
12
+ /**
13
+ * A hook that queues async tasks for sequential execution.
14
+ * Only the last task in the queue is ever processed; all prior pending tasks are discarded.
15
+ * This prevents race conditions where multiple autosave fetches could run in parallel.
16
+ *
17
+ * Inspired by Payload's useQueues hook.
18
+ *
19
+ * @returns {queueTask} A function used to queue a task for execution.
20
+ */
21
+ export function useQueue(): { queueTask: QueueTask } {
22
+ const queue = useRef<QueuedFunction[]>([]);
23
+ const isProcessing = useRef(false);
24
+
25
+ const queueTask = useCallback<QueueTask>((fn, options) => {
26
+ queue.current.push(fn);
27
+
28
+ async function processQueue() {
29
+ if (isProcessing.current) return;
30
+
31
+ if (typeof options?.beforeProcess === "function") {
32
+ const shouldContinue = options.beforeProcess();
33
+ if (shouldContinue === false) return;
34
+ }
35
+
36
+ while (queue.current.length > 0) {
37
+ const latestTask = queue.current.pop();
38
+ queue.current = [];
39
+
40
+ isProcessing.current = true;
41
+
42
+ try {
43
+ await latestTask();
44
+ } catch (err) {
45
+ console.error("Error in queued function:", err);
46
+ } finally {
47
+ isProcessing.current = false;
48
+
49
+ if (typeof options?.afterProcess === "function") {
50
+ options.afterProcess();
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ processQueue();
57
+ }, []);
58
+
59
+ return { queueTask };
60
+ }