@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.
- package/dist/index.cjs +11715 -11292
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +67 -65
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +564 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.js +11326 -10912
- package/dist/index.js.map +1 -1
- package/package.json +16 -12
- package/src/components/ActionBar.tsx +25 -161
- package/src/components/Admin.tsx +2 -4
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AuditLogsPage.tsx +2 -13
- package/src/components/AutoForm.tsx +572 -461
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +52 -65
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +57 -216
- package/src/components/MediaGallery.tsx +334 -367
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +59 -52
- package/src/components/SessionsManager.tsx +1 -1
- package/src/components/SettingsPage.tsx +22 -0
- package/src/components/Sidebar.astro +13 -41
- package/src/components/UserManagement.tsx +153 -15
- package/src/components/UserMenu.tsx +30 -4
- package/src/components/VersionHistoryPanel.tsx +112 -119
- package/src/components/WebhookManager.tsx +6 -4
- package/src/components/blocks/ArrayBlock.tsx +6 -23
- package/src/components/blocks/BlockEditModal.tsx +82 -309
- package/src/components/blocks/CardBlock.tsx +35 -0
- package/src/components/blocks/ChildBlocksTree.tsx +57 -31
- package/src/components/blocks/GenericBlock.tsx +44 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
- package/src/components/blocks/HeroBlock.tsx +5 -14
- package/src/components/blocks/RichTextBlock.tsx +5 -5
- package/src/components/blocks/index.ts +5 -3
- package/src/components/fields/AccordionField.tsx +2 -2
- package/src/components/fields/ArrayField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +120 -29
- package/src/components/fields/BlocksField.tsx +433 -55
- package/src/components/fields/CardField.tsx +73 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/DateField.tsx +4 -1
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/HeadingSubheadingField.tsx +43 -0
- package/src/components/fields/ListField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -1
- package/src/components/fields/RelationshipBlockField.tsx +2 -3
- package/src/components/fields/RelationshipField.tsx +155 -90
- package/src/components/fields/RichTextField.tsx +781 -0
- package/src/components/fields/SecretField.tsx +102 -0
- package/src/components/fields/SelectField.tsx +19 -6
- package/src/components/fields/TabsLayout.tsx +19 -9
- package/src/components/fields/TextField.tsx +4 -1
- package/src/components/fields/UploadField.tsx +122 -56
- package/src/components/fields/extensions/blockComponents.tsx +103 -174
- package/src/components/fields/extensions/blocksStore.ts +8 -1
- package/src/components/fields/index.ts +4 -2
- package/src/components/fix_imports.cjs +23 -0
- package/src/components/fix_imports2.cjs +19 -0
- package/src/components/replace_svgs.cjs +63 -0
- package/src/components/ui/Dropdown.tsx +7 -2
- package/src/components/ui/Modal.tsx +24 -27
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/PromptModal.tsx +2 -10
- package/src/components/ui/SlidePanel.tsx +10 -13
- package/src/components/ui/SplitButton.tsx +107 -0
- package/src/components/ui/Toaster.tsx +0 -1
- package/src/components/ui/icons.tsx +110 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/components/users/UsersList.tsx +8 -85
- package/src/hooks/useAutoFormState.ts +187 -196
- package/src/hooks/useQueue.ts +60 -0
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AdminLayout.astro +22 -2
- package/src/layouts/AuthLayout.astro +67 -7
- package/src/lib/autoform-store.ts +90 -53
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +48 -11
- package/src/lib/normalize-upload-fields.ts +41 -0
- package/src/lib/paths.ts +2 -2
- package/src/lib/resolve-field-value.ts +110 -0
- package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
- package/src/lib/shim/use-sync-external-store.js +1 -0
- package/src/lib/stores/index.ts +1 -0
- package/src/lib/useResourceManager.ts +4 -4
- package/src/lib/vite-shim-plugin.ts +100 -0
- package/src/pages/[collection]/[id].astro +1 -1
- package/src/pages/auth/register.astro +5 -2
- package/src/pages/preview/[collection]/[id].astro +4 -4
- package/src/pages/settings/[slug].astro +2 -2
- package/src/styles/main.css +60 -54
- package/README.md +0 -46
- package/dist/EditorClient-Q23UXR37.cjs +0 -468
- package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
- package/dist/EditorClient-T5PASFNR.js +0 -466
- package/dist/EditorClient-T5PASFNR.js.map +0 -1
- package/dist/chunk-3BGDYKTD.cjs +0 -348
- package/dist/chunk-3BGDYKTD.cjs.map +0 -1
- package/dist/chunk-EEFXLQVT.js +0 -3
- package/dist/chunk-EEFXLQVT.js.map +0 -1
- package/src/components/blocks/ButtonBlock.tsx +0 -64
- package/src/components/blocks/ColumnsBlock.tsx +0 -55
- package/src/components/blocks/DividerBlock.tsx +0 -43
- package/src/components/blocks/LinkBlock.tsx +0 -65
- package/src/components/blocks/VStackBlock.tsx +0 -29
- package/src/components/fields/EditorClient.tsx +0 -535
- package/src/components/fields/PortableTextField.tsx +0 -155
- 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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
182
|
-
lastSyncedAt:
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
210
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
345
|
-
if (JSON.stringify(
|
|
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
|
-
|
|
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
|
|
391
|
+
const fields = config.fields as Record<string, unknown>[];
|
|
392
|
+
const metaTitleField = findFieldDeep(fields, "metaTitle");
|
|
413
393
|
if (!metaTitleField) return;
|
|
414
394
|
|
|
415
|
-
|
|
416
|
-
|
|
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 (
|
|
427
|
-
setField("metaTitle",
|
|
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
|
|
434
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
466
|
-
if (
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
521
|
-
|
|
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) &&
|
|
521
|
+
(!!formData.id || !!globalSlug) && documentStatus === 'draft';
|
|
527
522
|
|
|
528
523
|
return {
|
|
529
524
|
...store,
|
|
530
525
|
fetchVersions,
|
|
531
|
-
performAutoSave:
|
|
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
|
+
}
|