@kyro-cms/admin 0.9.1 → 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 +1196 -1727
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +891 -1422
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar.tsx +25 -174
- package/src/components/Admin.tsx +1 -3
- package/src/components/AuditLogsPage.tsx +2 -13
- package/src/components/AutoForm.tsx +160 -265
- package/src/components/DetailView.tsx +38 -66
- package/src/components/FieldRenderer.tsx +1 -1
- package/src/components/ListView.tsx +26 -198
- package/src/components/MediaGallery.tsx +117 -175
- package/src/components/RestPlayground.tsx +54 -47
- package/src/components/fields/BlocksField.tsx +8 -10
- package/src/components/fields/RelationshipBlockField.tsx +2 -3
- package/src/components/fields/RelationshipField.tsx +2 -3
- 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/PromptModal.tsx +2 -10
- package/src/components/ui/SlidePanel.tsx +2 -10
- package/src/components/ui/SplitButton.tsx +107 -0
- package/src/components/ui/Toaster.tsx +0 -1
- package/src/components/ui/icons.tsx +1 -0
- package/src/components/users/UsersList.tsx +8 -85
- package/src/hooks/useAutoFormState.ts +89 -161
- package/src/hooks/useQueue.ts +60 -0
- package/src/layouts/AdminLayout.astro +22 -2
- package/src/layouts/AuthLayout.astro +66 -18
- package/src/lib/autoform-store.ts +6 -2
- package/src/lib/globals.ts +5 -3
- package/src/pages/auth/register.astro +5 -2
|
@@ -6,6 +6,7 @@ import { resolveUrl, fetchWithAuth } from "../lib/api";
|
|
|
6
6
|
import { normalizeUploadFields } from "../lib/normalize-upload-fields";
|
|
7
7
|
import { useUIStore } from "../lib/stores";
|
|
8
8
|
import { resolveFieldValue } from "../lib/resolve-field-value";
|
|
9
|
+
import { useQueue } from "./useQueue";
|
|
9
10
|
|
|
10
11
|
interface UseAutoFormStateProps {
|
|
11
12
|
config: Record<string, unknown>;
|
|
@@ -83,6 +84,9 @@ export function useAutoFormState({
|
|
|
83
84
|
const lastAutoSaveTimeRef = useRef<number>(0);
|
|
84
85
|
const autoSaveSkipRef = useRef<boolean>(false);
|
|
85
86
|
const restorePromptedRef = useRef<string | null>(null);
|
|
87
|
+
const previousFormDataRef = useRef<string>("");
|
|
88
|
+
const astroSyncDataRef = useRef<string>("");
|
|
89
|
+
const { queueTask } = useQueue();
|
|
86
90
|
|
|
87
91
|
const getDocumentKey = useCallback(
|
|
88
92
|
(id?: string) => {
|
|
@@ -109,30 +113,6 @@ const persistBrowserDraft = useCallback(
|
|
|
109
113
|
[lastSavedData.updatedAt, setDraftCache],
|
|
110
114
|
);
|
|
111
115
|
|
|
112
|
-
const clearDraftArtifacts = useCallback(async () => {
|
|
113
|
-
const state = useAutoFormStore.getState();
|
|
114
|
-
const documentKey = getDocumentKey(state.formData.id);
|
|
115
|
-
if (documentKey) {
|
|
116
|
-
clearDraftCache(documentKey);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const draftUrl = globalSlug
|
|
120
|
-
? resolveUrl(`/api/globals/${globalSlug}/draft`)
|
|
121
|
-
: collectionSlug && state.formData.id
|
|
122
|
-
? resolveUrl(`/api/${collectionSlug}/${state.formData.id}/draft`)
|
|
123
|
-
: null;
|
|
124
|
-
|
|
125
|
-
if (draftUrl && versionsEnabled) {
|
|
126
|
-
try {
|
|
127
|
-
await fetchWithAuth(draftUrl, {
|
|
128
|
-
method: "DELETE",
|
|
129
|
-
});
|
|
130
|
-
} catch (err) {
|
|
131
|
-
console.error("Failed to clear draft snapshot:", err);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}, [clearDraftCache, collectionSlug, globalSlug, getDocumentKey]);
|
|
135
|
-
|
|
136
116
|
const fetchVersions = useCallback(async () => {
|
|
137
117
|
const url = globalSlug
|
|
138
118
|
? resolveUrl(`/api/globals/${globalSlug}/versions`)
|
|
@@ -164,15 +144,15 @@ const persistBrowserDraft = useCallback(
|
|
|
164
144
|
}
|
|
165
145
|
}, [collectionSlug, getDocumentKey, persistBrowserDraft]);
|
|
166
146
|
|
|
167
|
-
const
|
|
147
|
+
const doAutosaveFetch = useCallback(async (options?: { keepalive?: boolean }) => {
|
|
168
148
|
const state = useAutoFormStore.getState();
|
|
169
149
|
const latestFormData = state.formData;
|
|
170
150
|
const currentLastSaved = state.lastSavedData;
|
|
171
|
-
|
|
151
|
+
|
|
172
152
|
if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
|
|
173
153
|
if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
|
|
174
154
|
if (!state.hasDirtyFields()) return;
|
|
175
|
-
|
|
155
|
+
|
|
176
156
|
const documentKey = getDocumentKey(latestFormData.id);
|
|
177
157
|
if (documentKey) {
|
|
178
158
|
persistBrowserDraft(documentKey, latestFormData);
|
|
@@ -185,72 +165,67 @@ const persistBrowserDraft = useCallback(
|
|
|
185
165
|
|
|
186
166
|
setIsAutoSaving(true);
|
|
187
167
|
setAutoSaveStatus("saving");
|
|
168
|
+
state.setBackgroundProcessing(true);
|
|
188
169
|
|
|
189
170
|
try {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
draftUrl,
|
|
200
|
-
{
|
|
201
|
-
method: "PUT",
|
|
202
|
-
headers: {
|
|
203
|
-
"Content-Type": "application/json",
|
|
204
|
-
"X-Idempotency-Key": idempotencyKey
|
|
205
|
-
},
|
|
206
|
-
keepalive: options?.keepalive,
|
|
207
|
-
body: JSON.stringify({
|
|
208
|
-
delta: normalizeUploadFields(delta),
|
|
209
|
-
baseUpdatedAt: currentLastSaved.updatedAt ?? null,
|
|
210
|
-
draftUpdatedAt,
|
|
211
|
-
}),
|
|
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",
|
|
212
180
|
},
|
|
213
|
-
|
|
181
|
+
keepalive: options?.keepalive,
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
...normalizeUploadFields(latestFormData),
|
|
184
|
+
baseUpdatedAt: currentLastSaved.updatedAt ?? null,
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
214
187
|
|
|
215
188
|
if (response.ok) {
|
|
216
|
-
const result = await response.json();
|
|
217
189
|
lastAutoSaveTimeRef.current = Date.now();
|
|
218
190
|
state.setRetryCount(0);
|
|
219
191
|
state.setLastSavedAt(Date.now());
|
|
220
192
|
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
221
|
-
|
|
193
|
+
|
|
222
194
|
if (documentKey) {
|
|
223
195
|
setDraftCache(documentKey, {
|
|
224
196
|
data: latestFormData,
|
|
225
197
|
baseUpdatedAt: currentLastSaved.updatedAt ?? null,
|
|
226
|
-
draftUpdatedAt:
|
|
227
|
-
lastSyncedAt:
|
|
198
|
+
draftUpdatedAt: new Date().toISOString(),
|
|
199
|
+
lastSyncedAt: (await response.clone().json()).data?.updatedAt || new Date().toISOString(),
|
|
228
200
|
});
|
|
229
201
|
}
|
|
230
202
|
setAutoSaveStatus("success");
|
|
231
203
|
setTimeout(() => {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
204
|
+
if (useAutoFormStore.getState().autoSaveStatus === "success") {
|
|
205
|
+
setAutoSaveStatus("idle");
|
|
206
|
+
}
|
|
235
207
|
}, 2000);
|
|
208
|
+
} else if (response.status === 409) {
|
|
209
|
+
setAutoSaveStatus("conflict");
|
|
236
210
|
} else {
|
|
237
211
|
throw new Error(`Draft auto-save failed with status ${response.status}`);
|
|
238
212
|
}
|
|
239
213
|
} catch (err) {
|
|
240
214
|
console.error("Auto-save failed:", err);
|
|
241
|
-
const
|
|
242
|
-
const currentRetryCount =
|
|
215
|
+
const currentState = useAutoFormStore.getState();
|
|
216
|
+
const currentRetryCount = currentState.retryCount;
|
|
243
217
|
if (currentRetryCount < 5) {
|
|
244
|
-
|
|
218
|
+
currentState.setRetryCount(currentRetryCount + 1);
|
|
245
219
|
setAutoSaveStatus("retrying");
|
|
246
220
|
const delay = Math.min(1000 * Math.pow(2, currentRetryCount), 60000);
|
|
247
221
|
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
248
|
-
retryTimerRef.current = setTimeout(() =>
|
|
222
|
+
retryTimerRef.current = setTimeout(() => performAutosave(options), delay);
|
|
249
223
|
} else {
|
|
250
224
|
setAutoSaveStatus("offline");
|
|
251
225
|
}
|
|
252
226
|
} finally {
|
|
253
227
|
setIsAutoSaving(false);
|
|
228
|
+
useAutoFormStore.getState().setBackgroundProcessing(false);
|
|
254
229
|
}
|
|
255
230
|
}, [
|
|
256
231
|
collectionSlug,
|
|
@@ -260,11 +235,25 @@ const persistBrowserDraft = useCallback(
|
|
|
260
235
|
setAutoSaveStatus,
|
|
261
236
|
setDraftCache,
|
|
262
237
|
setIsAutoSaving,
|
|
263
|
-
versionsEnabled
|
|
238
|
+
versionsEnabled,
|
|
264
239
|
]);
|
|
265
240
|
|
|
266
|
-
const
|
|
267
|
-
|
|
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) => {
|
|
268
257
|
const state = useAutoFormStore.getState();
|
|
269
258
|
const payload = dataOverride || state.formData;
|
|
270
259
|
|
|
@@ -276,7 +265,10 @@ const saveDocument = useCallback(
|
|
|
276
265
|
url,
|
|
277
266
|
{
|
|
278
267
|
method: "PATCH",
|
|
279
|
-
headers: {
|
|
268
|
+
headers: {
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
"X-Draft": String(isDraft),
|
|
271
|
+
},
|
|
280
272
|
body: JSON.stringify({
|
|
281
273
|
...normalizeUploadFields(payload) as Record<string, unknown>,
|
|
282
274
|
baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
|
|
@@ -293,46 +285,6 @@ const saveDocument = useCallback(
|
|
|
293
285
|
[collectionSlug, globalSlug, setAutoSaveStatus],
|
|
294
286
|
);
|
|
295
287
|
|
|
296
|
-
const publishDocument = useCallback(async () => {
|
|
297
|
-
const state = useAutoFormStore.getState();
|
|
298
|
-
const url = globalSlug
|
|
299
|
-
? resolveUrl(`/api/globals/${globalSlug}/publish`)
|
|
300
|
-
: resolveUrl(`/api/${collectionSlug}/${state.formData.id}/publish`);
|
|
301
|
-
|
|
302
|
-
const response = await fetchWithAuth(
|
|
303
|
-
url,
|
|
304
|
-
{
|
|
305
|
-
method: "POST",
|
|
306
|
-
headers: { "Content-Type": "application/json" },
|
|
307
|
-
body: JSON.stringify({
|
|
308
|
-
baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
|
|
309
|
-
}),
|
|
310
|
-
},
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
if (response.status === 409) {
|
|
314
|
-
setAutoSaveStatus("conflict");
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return response;
|
|
318
|
-
}, [collectionSlug, globalSlug, setAutoSaveStatus]);
|
|
319
|
-
|
|
320
|
-
const unpublishDocument = useCallback(async () => {
|
|
321
|
-
const state = useAutoFormStore.getState();
|
|
322
|
-
const url = globalSlug
|
|
323
|
-
? resolveUrl(`/api/globals/${globalSlug}/unpublish`)
|
|
324
|
-
: resolveUrl(`/api/${collectionSlug}/${state.formData.id}/unpublish`);
|
|
325
|
-
|
|
326
|
-
const response = await fetchWithAuth(
|
|
327
|
-
url,
|
|
328
|
-
{
|
|
329
|
-
method: "POST",
|
|
330
|
-
headers: { "Content-Type": "application/json" },
|
|
331
|
-
},
|
|
332
|
-
);
|
|
333
|
-
return response;
|
|
334
|
-
}, [collectionSlug, globalSlug]);
|
|
335
|
-
|
|
336
288
|
// Track sidebar toggle
|
|
337
289
|
useEffect(() => {
|
|
338
290
|
const handleToggle = () => {
|
|
@@ -346,14 +298,17 @@ const saveDocument = useCallback(
|
|
|
346
298
|
|
|
347
299
|
// Initial data load
|
|
348
300
|
const lastLoadedSlugRef = useRef<string | null>(null);
|
|
301
|
+
const lastInitialDataRef = useRef<string>("");
|
|
349
302
|
const initialDataLoadedRef = useRef(false);
|
|
350
303
|
useEffect(() => {
|
|
351
304
|
const currentSlug = globalSlug || initialData?.id;
|
|
352
|
-
|
|
305
|
+
const serialized = JSON.stringify(initialData);
|
|
306
|
+
if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug && lastInitialDataRef.current === serialized) return;
|
|
353
307
|
|
|
354
308
|
loadDocument(initialData || {}, initialData || {});
|
|
355
309
|
initialDataLoadedRef.current = true;
|
|
356
310
|
lastLoadedSlugRef.current = currentSlug;
|
|
311
|
+
lastInitialDataRef.current = serialized;
|
|
357
312
|
}, [collectionSlug, formData.id, globalSlug, initialData, loadDocument]);
|
|
358
313
|
|
|
359
314
|
useEffect(() => {
|
|
@@ -368,33 +323,10 @@ const saveDocument = useCallback(
|
|
|
368
323
|
const maybeRestoreDraft = async () => {
|
|
369
324
|
if (!versionsEnabled) return;
|
|
370
325
|
const browserDraft = getDraftCache(documentKey);
|
|
371
|
-
let serverDraft: Record<string, unknown> | null = null;
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
const response = await fetchWithAuth(
|
|
375
|
-
resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
|
|
376
|
-
);
|
|
377
|
-
if (response.ok) {
|
|
378
|
-
const result: { data?: Record<string, unknown> } = await response.json();
|
|
379
|
-
serverDraft = result.data || null;
|
|
380
|
-
}
|
|
381
|
-
} catch (err) {
|
|
382
|
-
console.error("Failed to fetch server draft:", err);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
386
|
-
data: Record<string, unknown>;
|
|
387
|
-
draftUpdatedAt: string;
|
|
388
|
-
}>;
|
|
389
326
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
new Date(a.draftUpdatedAt).getTime(),
|
|
394
|
-
)[0];
|
|
395
|
-
|
|
396
|
-
if (!candidate) return;
|
|
397
|
-
if (JSON.stringify(candidate.data) === JSON.stringify(initialData)) {
|
|
327
|
+
if (!browserDraft) return;
|
|
328
|
+
if (JSON.stringify(browserDraft.data) === JSON.stringify(initialData)) {
|
|
329
|
+
clearDraftCache(documentKey);
|
|
398
330
|
return;
|
|
399
331
|
}
|
|
400
332
|
|
|
@@ -409,20 +341,12 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
409
341
|
onConfirm: async () => {
|
|
410
342
|
if (cancelled) return;
|
|
411
343
|
const currentFormData = useAutoFormStore.getState().formData;
|
|
412
|
-
const mergedData = { ...currentFormData, ...
|
|
344
|
+
const mergedData = { ...currentFormData, ...browserDraft.data };
|
|
413
345
|
setFormData(mergedData);
|
|
414
346
|
onActionSuccess?.("Recovered autosaved draft");
|
|
415
347
|
},
|
|
416
348
|
onCancel: async () => {
|
|
417
349
|
clearDraftCache(documentKey);
|
|
418
|
-
try {
|
|
419
|
-
await fetchWithAuth(
|
|
420
|
-
resolveUrl(`/api/${collectionSlug}/${initialData.id}/draft`),
|
|
421
|
-
{ method: "DELETE" },
|
|
422
|
-
);
|
|
423
|
-
} catch (err) {
|
|
424
|
-
console.error("Failed to discard server draft:", err);
|
|
425
|
-
}
|
|
426
350
|
},
|
|
427
351
|
});
|
|
428
352
|
};
|
|
@@ -441,6 +365,7 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
441
365
|
initialData,
|
|
442
366
|
onActionSuccess,
|
|
443
367
|
setFormData,
|
|
368
|
+
versionsEnabled,
|
|
444
369
|
]);
|
|
445
370
|
|
|
446
371
|
// Recursively find a field by name inside tabs/group/collapsible
|
|
@@ -506,17 +431,22 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
506
431
|
|
|
507
432
|
// Only schedule/reschedule on keystroke-originated changes
|
|
508
433
|
if (getLastChangeSource() !== "keystroke") return;
|
|
509
|
-
|
|
510
|
-
// Reset source so subsequent non-keystroke changes don't re-schedule
|
|
511
434
|
setChangeSource("other");
|
|
512
435
|
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
if (
|
|
436
|
+
// Compare serialized form data to avoid scheduling for unchanged metadata
|
|
437
|
+
const serialized = JSON.stringify(formData);
|
|
438
|
+
if (serialized === previousFormDataRef.current) return;
|
|
516
439
|
|
|
440
|
+
if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
|
|
517
441
|
localSaveTimerRef.current = setTimeout(performLocalAutoSave, 1500);
|
|
518
|
-
|
|
519
|
-
|
|
442
|
+
|
|
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]);
|
|
520
450
|
|
|
521
451
|
useEffect(() => {
|
|
522
452
|
if (!globalSlug && (!collectionSlug || !formData.id)) return;
|
|
@@ -524,9 +454,8 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
524
454
|
const flushDraft = () => {
|
|
525
455
|
if (autoSaveSkipRef.current) return;
|
|
526
456
|
const state = useAutoFormStore.getState();
|
|
527
|
-
// Only flush if there are actual unsaved changes
|
|
528
457
|
if (!state.hasDirtyFields()) return;
|
|
529
|
-
void
|
|
458
|
+
void performAutosave({ keepalive: true });
|
|
530
459
|
};
|
|
531
460
|
|
|
532
461
|
const handleVisibilityChange = () => {
|
|
@@ -561,13 +490,17 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
561
490
|
window.removeEventListener("offline", handleOffline);
|
|
562
491
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
563
492
|
};
|
|
564
|
-
}, [collectionSlug, globalSlug, formData.id,
|
|
493
|
+
}, [collectionSlug, globalSlug, formData.id, performAutosave]);
|
|
565
494
|
|
|
566
|
-
// Astro sync
|
|
495
|
+
// Astro sync — avoid ping-pong loop with DetailView's setData
|
|
567
496
|
useEffect(() => {
|
|
497
|
+
const serialized = JSON.stringify(formData);
|
|
498
|
+
if (serialized === astroSyncDataRef.current) return;
|
|
499
|
+
astroSyncDataRef.current = serialized;
|
|
500
|
+
|
|
568
501
|
const hiddenInput = document.getElementById("form-data") as HTMLInputElement;
|
|
569
502
|
if (hiddenInput) {
|
|
570
|
-
hiddenInput.value =
|
|
503
|
+
hiddenInput.value = serialized;
|
|
571
504
|
}
|
|
572
505
|
onChange?.(formData);
|
|
573
506
|
}, [formData, onChange]);
|
|
@@ -581,22 +514,17 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
581
514
|
const documentStatus: 'draft' | 'published' | 'archived' | undefined = (() => {
|
|
582
515
|
if (!formData.id && !globalSlug) return 'draft';
|
|
583
516
|
if (!versionsEnabled) return 'published';
|
|
584
|
-
|
|
585
|
-
if (formData.hasDraft) return 'draft';
|
|
586
|
-
return formData.publishStatus || 'published';
|
|
517
|
+
return (formData.status as 'draft' | 'published' | undefined) || 'published';
|
|
587
518
|
})();
|
|
588
519
|
|
|
589
520
|
const hasUnpublishedChanges =
|
|
590
|
-
(!!formData.id || !!globalSlug) &&
|
|
521
|
+
(!!formData.id || !!globalSlug) && documentStatus === 'draft';
|
|
591
522
|
|
|
592
523
|
return {
|
|
593
524
|
...store,
|
|
594
525
|
fetchVersions,
|
|
595
|
-
performAutoSave:
|
|
526
|
+
performAutoSave: performAutosave,
|
|
596
527
|
saveDocument,
|
|
597
|
-
publishDocument,
|
|
598
|
-
unpublishDocument,
|
|
599
|
-
clearDraftArtifacts,
|
|
600
528
|
autoSaveSkipRef,
|
|
601
529
|
lastAutoSaveTimeRef,
|
|
602
530
|
documentStatus,
|
|
@@ -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
|
+
}
|
|
@@ -7,7 +7,7 @@ import { adminPath, apiPath } from "../lib/paths";
|
|
|
7
7
|
import { AuthBridge } from "../components/AuthBridge";
|
|
8
8
|
import { GlobalModal } from "../components/ui/GlobalModal";
|
|
9
9
|
import { Toaster } from "../components/ui/Toaster";
|
|
10
|
-
import { getSiteSettings } from "../lib/globals";
|
|
10
|
+
import { getSiteSettings, getGlobal } from "../lib/globals";
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
title: string;
|
|
@@ -15,8 +15,25 @@ interface Props {
|
|
|
15
15
|
|
|
16
16
|
const { title } = Astro.props;
|
|
17
17
|
const siteSettings = await getSiteSettings({ request: Astro.request });
|
|
18
|
+
const seoSettings = await getGlobal("seo-settings", { request: Astro.request });
|
|
19
|
+
|
|
18
20
|
const siteName = siteSettings?.siteName || "Kyro CMS";
|
|
19
21
|
const siteFavicon = siteSettings?.siteFavicon;
|
|
22
|
+
const siteDescription = siteSettings?.siteDescription || "";
|
|
23
|
+
|
|
24
|
+
// SEO Logic
|
|
25
|
+
const seoMode = seoSettings?.seoMode || "simple";
|
|
26
|
+
const includeSiteName = seoSettings?.siteNameInTitle ?? true;
|
|
27
|
+
const titleSeparator = seoSettings?.separator || " - ";
|
|
28
|
+
const defaultTitle = seoSettings?.defaultTitle || title;
|
|
29
|
+
const defaultDescription = seoSettings?.defaultDescription || siteDescription;
|
|
30
|
+
const canonicalUrl = seoMode === "advanced" ? seoSettings?.meta?.canonicalUrl : "";
|
|
31
|
+
const robots = seoMode === "advanced" && seoSettings?.meta?.robots ? seoSettings.meta.robots : "noindex, nofollow";
|
|
32
|
+
|
|
33
|
+
let displayTitle = title === "Dashboard" && defaultTitle !== "Dashboard" ? defaultTitle : title;
|
|
34
|
+
if (includeSiteName) {
|
|
35
|
+
displayTitle = `${displayTitle}${titleSeparator}${siteName}`;
|
|
36
|
+
}
|
|
20
37
|
---
|
|
21
38
|
|
|
22
39
|
<!doctype html>
|
|
@@ -24,7 +41,10 @@ const siteFavicon = siteSettings?.siteFavicon;
|
|
|
24
41
|
<head>
|
|
25
42
|
<meta charset="UTF-8" />
|
|
26
43
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
27
|
-
<title>{
|
|
44
|
+
<title>{displayTitle}</title>
|
|
45
|
+
{defaultDescription && <meta name="description" content={defaultDescription} />}
|
|
46
|
+
{robots && <meta name="robots" content={robots} />}
|
|
47
|
+
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
|
28
48
|
<link
|
|
29
49
|
rel="icon"
|
|
30
50
|
type={siteFavicon?.mimeType || "image/svg+xml"}
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
---
|
|
2
2
|
import "../styles/main.css";
|
|
3
3
|
import { adminPath, apiPath } from "../lib/paths";
|
|
4
|
+
import { getSiteSettings, getGlobal } from "../lib/globals";
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
title: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const { title } = Astro.props;
|
|
11
|
+
const siteSettings = await getSiteSettings({ request: Astro.request });
|
|
12
|
+
const seoSettings = await getGlobal("seo-settings", { request: Astro.request });
|
|
13
|
+
|
|
14
|
+
const siteName = siteSettings?.siteName || "Kyro CMS";
|
|
15
|
+
const siteFavicon = siteSettings?.siteFavicon;
|
|
16
|
+
const siteLogo = siteSettings?.siteLogo;
|
|
17
|
+
const logoWidth = siteSettings?.logo?.width;
|
|
18
|
+
const logoHeight = siteSettings?.logo?.height;
|
|
19
|
+
const logoAlt = siteSettings?.logo?.altText || siteName;
|
|
20
|
+
const siteDescription = siteSettings?.siteDescription || "";
|
|
21
|
+
|
|
22
|
+
// SEO Logic
|
|
23
|
+
const seoMode = seoSettings?.seoMode || "simple";
|
|
24
|
+
const includeSiteName = seoSettings?.siteNameInTitle ?? true;
|
|
25
|
+
const titleSeparator = seoSettings?.separator || " - ";
|
|
26
|
+
const defaultTitle = seoSettings?.defaultTitle || title;
|
|
27
|
+
const defaultDescription = seoSettings?.defaultDescription || siteDescription;
|
|
28
|
+
const canonicalUrl = seoMode === "advanced" ? seoSettings?.meta?.canonicalUrl : "";
|
|
29
|
+
const robots = seoMode === "advanced" && seoSettings?.meta?.robots ? seoSettings.meta.robots : "noindex, nofollow";
|
|
30
|
+
|
|
31
|
+
let displayTitle = title === "Login" && defaultTitle !== "Login" ? defaultTitle : title;
|
|
32
|
+
if (includeSiteName) {
|
|
33
|
+
displayTitle = `${displayTitle}${titleSeparator}${siteName}`;
|
|
34
|
+
}
|
|
10
35
|
---
|
|
11
36
|
|
|
12
37
|
<!doctype html>
|
|
@@ -14,8 +39,15 @@ const { title } = Astro.props;
|
|
|
14
39
|
<head>
|
|
15
40
|
<meta charset="UTF-8" />
|
|
16
41
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
17
|
-
<title>{
|
|
18
|
-
<
|
|
42
|
+
<title>{displayTitle}</title>
|
|
43
|
+
{defaultDescription && <meta name="description" content={defaultDescription} />}
|
|
44
|
+
{robots && <meta name="robots" content={robots} />}
|
|
45
|
+
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
|
46
|
+
<link
|
|
47
|
+
rel="icon"
|
|
48
|
+
type={siteFavicon?.mimeType || "image/svg+xml"}
|
|
49
|
+
href={siteFavicon?.url || "/favicon.svg"}
|
|
50
|
+
/>
|
|
19
51
|
<script is:inline define:vars={{ adminPath, apiPath }}>
|
|
20
52
|
(async () => {
|
|
21
53
|
try {
|
|
@@ -50,22 +82,38 @@ const { title } = Astro.props;
|
|
|
50
82
|
<!-- Logo -->
|
|
51
83
|
<div class="w-full text-center mb-8">
|
|
52
84
|
<a href="/" class="inline-flex items-center justify-center gap-2">
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
{
|
|
86
|
+
siteLogo ? (
|
|
87
|
+
<img
|
|
88
|
+
src={siteLogo.url}
|
|
89
|
+
alt={logoAlt}
|
|
90
|
+
style={{
|
|
91
|
+
width: logoWidth ? `${logoWidth}px` : "auto",
|
|
92
|
+
height: logoHeight ? `${logoHeight}px` : "40px",
|
|
93
|
+
objectFit: "contain",
|
|
94
|
+
}}
|
|
95
|
+
class="rounded-lg"
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<>
|
|
99
|
+
<svg class="w-7 h-7 text-[var(--kyro-text-primary)]" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
100
|
+
<defs>
|
|
101
|
+
<mask id="kyro-cutout-auth">
|
|
102
|
+
<rect width="100" height="100" fill="white" />
|
|
103
|
+
<path d="M 40 89.72 L 40 40 L 89.72 40 A 45 45 0 0 1 40 89.72 Z" stroke="black" stroke-width="8" fill="none" />
|
|
104
|
+
</mask>
|
|
105
|
+
</defs>
|
|
106
|
+
<g mask="url(#kyro-cutout-auth)" fill="currentColor">
|
|
107
|
+
<circle cx="45" cy="45" r="45" />
|
|
108
|
+
<rect x="40" y="40" width="60" height="60" />
|
|
109
|
+
</g>
|
|
110
|
+
</svg>
|
|
111
|
+
<span class="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
|
|
112
|
+
{siteName}
|
|
113
|
+
</span>
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
69
117
|
</a>
|
|
70
118
|
</div>
|
|
71
119
|
|
|
@@ -92,8 +92,9 @@ interface AutoFormStore {
|
|
|
92
92
|
compareSelected: string[];
|
|
93
93
|
compareDiffs: VersionDiff[];
|
|
94
94
|
loadingDiffs: boolean;
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
isAutoSaving: boolean;
|
|
96
|
+
autoSaveStatus: AutoSaveStatus;
|
|
97
|
+
backgroundProcessing: boolean;
|
|
97
98
|
|
|
98
99
|
// Auto-save
|
|
99
100
|
lastAutoSaveTime: number;
|
|
@@ -125,6 +126,7 @@ interface AutoFormStore {
|
|
|
125
126
|
setLoadingDiffs: (loading: boolean) => void;
|
|
126
127
|
setIsAutoSaving: (saving: boolean) => void;
|
|
127
128
|
setAutoSaveStatus: (status: AutoSaveStatus) => void;
|
|
129
|
+
setBackgroundProcessing: (processing: boolean) => void;
|
|
128
130
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
129
131
|
setLastSavedAt: (time: number | null) => void;
|
|
130
132
|
setRetryCount: (count: number) => void;
|
|
@@ -192,6 +194,7 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
192
194
|
loadingDiffs: false,
|
|
193
195
|
isAutoSaving: false,
|
|
194
196
|
autoSaveStatus: "idle" as AutoSaveStatus,
|
|
197
|
+
backgroundProcessing: false,
|
|
195
198
|
|
|
196
199
|
// Auto-save state
|
|
197
200
|
lastAutoSaveTime: 0,
|
|
@@ -293,6 +296,7 @@ setField: (field: string, value: unknown) => {
|
|
|
293
296
|
setLoadingDiffs: (loading: boolean) => set({ loadingDiffs: loading }),
|
|
294
297
|
setIsAutoSaving: (saving: boolean) => set({ isAutoSaving: saving }),
|
|
295
298
|
setAutoSaveStatus: (status: AutoSaveStatus) => set({ autoSaveStatus: status }),
|
|
299
|
+
setBackgroundProcessing: (processing: boolean) => set({ backgroundProcessing: processing }),
|
|
296
300
|
setSidebarCollapsed: (collapsed: boolean) =>
|
|
297
301
|
set({ sidebarCollapsed: collapsed }),
|
|
298
302
|
setLastSavedAt: (time: number | null) => set({ lastSavedAt: time }),
|