@kyro-cms/admin 0.9.0 → 0.9.1
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 +11960 -11006
- 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 +563 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +12183 -11238
- package/dist/index.js.map +1 -1
- package/package.json +15 -11
- package/src/components/ActionBar.tsx +27 -14
- package/src/components/Admin.tsx +1 -1
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AutoForm.tsx +585 -369
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +71 -56
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +33 -20
- package/src/components/MediaGallery.tsx +219 -194
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +7 -7
- 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 +430 -50
- 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/RelationshipField.tsx +153 -87
- 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/ui/PageHeader.tsx +5 -5
- package/src/components/ui/SlidePanel.tsx +8 -3
- package/src/components/ui/icons.tsx +109 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/hooks/useAutoFormState.ts +125 -62
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AuthLayout.astro +14 -2
- package/src/lib/autoform-store.ts +85 -52
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +44 -9
- 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/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,11 @@
|
|
|
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";
|
|
6
9
|
|
|
7
10
|
interface UseAutoFormStateProps {
|
|
8
11
|
config: Record<string, unknown>;
|
|
@@ -24,7 +27,7 @@ export function useAutoFormState({
|
|
|
24
27
|
onActionError,
|
|
25
28
|
}: UseAutoFormStateProps) {
|
|
26
29
|
const store = useAutoFormStore();
|
|
27
|
-
const { confirm
|
|
30
|
+
const { confirm } = useUIStore();
|
|
28
31
|
const {
|
|
29
32
|
formData,
|
|
30
33
|
setFormData,
|
|
@@ -43,12 +46,40 @@ export function useAutoFormState({
|
|
|
43
46
|
getDraftCache,
|
|
44
47
|
setDraftCache,
|
|
45
48
|
clearDraftCache,
|
|
49
|
+
resetForm,
|
|
46
50
|
} = store;
|
|
47
51
|
|
|
48
52
|
const versionsEnabled = !!config.versions;
|
|
49
53
|
|
|
54
|
+
// Guard: clear stale formData from a previous page context.
|
|
55
|
+
// For collections, if the loaded document's id doesn't match the expected
|
|
56
|
+
// context key, the singleton zustand store is holding stale data.
|
|
57
|
+
// Globals have no id field and are singleton per slug, so skip this check.
|
|
58
|
+
// Must run in useEffect — calling resetForm() during render triggers
|
|
59
|
+
// "Cannot update a component while rendering" React error.
|
|
60
|
+
const currentContextKey = globalSlug || (initialData?.id as string | undefined) || collectionSlug;
|
|
61
|
+
const needsResetRef = useRef(false);
|
|
62
|
+
if (
|
|
63
|
+
!globalSlug &&
|
|
64
|
+
currentContextKey &&
|
|
65
|
+
formData &&
|
|
66
|
+
Object.keys(formData).length > 0 &&
|
|
67
|
+
formData.id !== currentContextKey
|
|
68
|
+
) {
|
|
69
|
+
needsResetRef.current = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (needsResetRef.current) {
|
|
74
|
+
needsResetRef.current = false;
|
|
75
|
+
resetForm();
|
|
76
|
+
}
|
|
77
|
+
}, [resetForm]);
|
|
78
|
+
|
|
50
79
|
const localSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
51
80
|
const serverSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
81
|
+
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
82
|
+
const isOnlineRef = useRef(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
|
52
83
|
const lastAutoSaveTimeRef = useRef<number>(0);
|
|
53
84
|
const autoSaveSkipRef = useRef<boolean>(false);
|
|
54
85
|
const restorePromptedRef = useRef<string | null>(null);
|
|
@@ -125,9 +156,8 @@ const persistBrowserDraft = useCallback(
|
|
|
125
156
|
const performLocalAutoSave = useCallback(() => {
|
|
126
157
|
const state = useAutoFormStore.getState();
|
|
127
158
|
const latestFormData = state.formData;
|
|
128
|
-
const currentLastSaved = state.lastSavedData;
|
|
129
159
|
if (autoSaveSkipRef.current || !collectionSlug || !latestFormData.id) return;
|
|
130
|
-
if (
|
|
160
|
+
if (!state.hasDirtyFields()) return;
|
|
131
161
|
const documentKey = getDocumentKey(latestFormData.id);
|
|
132
162
|
if (documentKey) {
|
|
133
163
|
persistBrowserDraft(documentKey, latestFormData);
|
|
@@ -141,13 +171,18 @@ const persistBrowserDraft = useCallback(
|
|
|
141
171
|
|
|
142
172
|
if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
|
|
143
173
|
if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
|
|
144
|
-
if (
|
|
174
|
+
if (!state.hasDirtyFields()) return;
|
|
145
175
|
|
|
146
176
|
const documentKey = getDocumentKey(latestFormData.id);
|
|
147
177
|
if (documentKey) {
|
|
148
178
|
persistBrowserDraft(documentKey, latestFormData);
|
|
149
179
|
}
|
|
150
180
|
|
|
181
|
+
if (!isOnlineRef.current) {
|
|
182
|
+
setAutoSaveStatus("offline");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
151
186
|
setIsAutoSaving(true);
|
|
152
187
|
setAutoSaveStatus("saving");
|
|
153
188
|
|
|
@@ -157,14 +192,20 @@ const persistBrowserDraft = useCallback(
|
|
|
157
192
|
? resolveUrl(`/api/globals/${globalSlug}/draft`)
|
|
158
193
|
: resolveUrl(`/api/${collectionSlug}/${latestFormData.id}/draft`);
|
|
159
194
|
|
|
195
|
+
const delta = state.getDirtyData();
|
|
196
|
+
const idempotencyKey = `${documentKey || "global"}:${Date.now()}`;
|
|
197
|
+
|
|
160
198
|
const response = await fetchWithAuth(
|
|
161
199
|
draftUrl,
|
|
162
200
|
{
|
|
163
201
|
method: "PUT",
|
|
164
|
-
headers: {
|
|
202
|
+
headers: {
|
|
203
|
+
"Content-Type": "application/json",
|
|
204
|
+
"X-Idempotency-Key": idempotencyKey
|
|
205
|
+
},
|
|
165
206
|
keepalive: options?.keepalive,
|
|
166
207
|
body: JSON.stringify({
|
|
167
|
-
|
|
208
|
+
delta: normalizeUploadFields(delta),
|
|
168
209
|
baseUpdatedAt: currentLastSaved.updatedAt ?? null,
|
|
169
210
|
draftUpdatedAt,
|
|
170
211
|
}),
|
|
@@ -174,6 +215,10 @@ const persistBrowserDraft = useCallback(
|
|
|
174
215
|
if (response.ok) {
|
|
175
216
|
const result = await response.json();
|
|
176
217
|
lastAutoSaveTimeRef.current = Date.now();
|
|
218
|
+
state.setRetryCount(0);
|
|
219
|
+
state.setLastSavedAt(Date.now());
|
|
220
|
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
221
|
+
|
|
177
222
|
if (documentKey) {
|
|
178
223
|
setDraftCache(documentKey, {
|
|
179
224
|
data: latestFormData,
|
|
@@ -183,27 +228,39 @@ const persistBrowserDraft = useCallback(
|
|
|
183
228
|
});
|
|
184
229
|
}
|
|
185
230
|
setAutoSaveStatus("success");
|
|
186
|
-
setTimeout(() =>
|
|
231
|
+
setTimeout(() => {
|
|
232
|
+
if (useAutoFormStore.getState().autoSaveStatus === "success") {
|
|
233
|
+
setAutoSaveStatus("idle");
|
|
234
|
+
}
|
|
235
|
+
}, 2000);
|
|
187
236
|
} else {
|
|
188
|
-
|
|
189
|
-
console.error("Draft auto-save failed:", error);
|
|
190
|
-
setAutoSaveStatus("error");
|
|
191
|
-
setTimeout(() => setAutoSaveStatus("idle"), 5000);
|
|
237
|
+
throw new Error(`Draft auto-save failed with status ${response.status}`);
|
|
192
238
|
}
|
|
193
239
|
} catch (err) {
|
|
194
240
|
console.error("Auto-save failed:", err);
|
|
195
|
-
|
|
196
|
-
|
|
241
|
+
const state = useAutoFormStore.getState();
|
|
242
|
+
const currentRetryCount = state.retryCount;
|
|
243
|
+
if (currentRetryCount < 5) {
|
|
244
|
+
state.setRetryCount(currentRetryCount + 1);
|
|
245
|
+
setAutoSaveStatus("retrying");
|
|
246
|
+
const delay = Math.min(1000 * Math.pow(2, currentRetryCount), 60000);
|
|
247
|
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
248
|
+
retryTimerRef.current = setTimeout(() => performServerAutoSave(options), delay);
|
|
249
|
+
} else {
|
|
250
|
+
setAutoSaveStatus("offline");
|
|
251
|
+
}
|
|
197
252
|
} finally {
|
|
198
253
|
setIsAutoSaving(false);
|
|
199
254
|
}
|
|
200
255
|
}, [
|
|
201
256
|
collectionSlug,
|
|
202
257
|
getDocumentKey,
|
|
258
|
+
globalSlug,
|
|
203
259
|
persistBrowserDraft,
|
|
204
260
|
setAutoSaveStatus,
|
|
205
261
|
setDraftCache,
|
|
206
262
|
setIsAutoSaving,
|
|
263
|
+
versionsEnabled
|
|
207
264
|
]);
|
|
208
265
|
|
|
209
266
|
const saveDocument = useCallback(
|
|
@@ -221,7 +278,7 @@ const saveDocument = useCallback(
|
|
|
221
278
|
method: "PATCH",
|
|
222
279
|
headers: { "Content-Type": "application/json" },
|
|
223
280
|
body: JSON.stringify({
|
|
224
|
-
...payload,
|
|
281
|
+
...normalizeUploadFields(payload) as Record<string, unknown>,
|
|
225
282
|
baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
|
|
226
283
|
}),
|
|
227
284
|
},
|
|
@@ -285,11 +342,7 @@ const saveDocument = useCallback(
|
|
|
285
342
|
return () => window.removeEventListener("toggle-sidebar", handleToggle);
|
|
286
343
|
}, [sidebarCollapsed, setSidebarCollapsed]);
|
|
287
344
|
|
|
288
|
-
// Track unsaved changes
|
|
289
|
-
useEffect(() => {
|
|
290
|
-
const isDifferent = JSON.stringify(formData) !== JSON.stringify(lastSavedData);
|
|
291
|
-
setHasUnsavedChanges(isDifferent);
|
|
292
|
-
}, [formData, lastSavedData, setHasUnsavedChanges]);
|
|
345
|
+
// Track unsaved changes (handled by setField / dirtyFields now)
|
|
293
346
|
|
|
294
347
|
// Initial data load
|
|
295
348
|
const lastLoadedSlugRef = useRef<string | null>(null);
|
|
@@ -298,11 +351,10 @@ const saveDocument = useCallback(
|
|
|
298
351
|
const currentSlug = globalSlug || initialData?.id;
|
|
299
352
|
if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug) return;
|
|
300
353
|
|
|
301
|
-
setFormData(initialData || {});
|
|
302
354
|
loadDocument(initialData || {}, initialData || {});
|
|
303
355
|
initialDataLoadedRef.current = true;
|
|
304
356
|
lastLoadedSlugRef.current = currentSlug;
|
|
305
|
-
}, [formData.id, globalSlug, initialData, loadDocument
|
|
357
|
+
}, [collectionSlug, formData.id, globalSlug, initialData, loadDocument]);
|
|
306
358
|
|
|
307
359
|
useEffect(() => {
|
|
308
360
|
if (!collectionSlug || !initialData?.id) return;
|
|
@@ -356,7 +408,9 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
356
408
|
cancelLabel: "Discard draft",
|
|
357
409
|
onConfirm: async () => {
|
|
358
410
|
if (cancelled) return;
|
|
359
|
-
|
|
411
|
+
const currentFormData = useAutoFormStore.getState().formData;
|
|
412
|
+
const mergedData = { ...currentFormData, ...candidate.data };
|
|
413
|
+
setFormData(mergedData);
|
|
360
414
|
onActionSuccess?.("Recovered autosaved draft");
|
|
361
415
|
},
|
|
362
416
|
onCancel: async () => {
|
|
@@ -409,45 +463,28 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
|
|
|
409
463
|
|
|
410
464
|
// Auto-generate metaTitle
|
|
411
465
|
useEffect(() => {
|
|
412
|
-
const
|
|
466
|
+
const fields = config.fields as Record<string, unknown>[];
|
|
467
|
+
const metaTitleField = findFieldDeep(fields, "metaTitle");
|
|
413
468
|
if (!metaTitleField) return;
|
|
414
469
|
|
|
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
|
-
}
|
|
470
|
+
const titleValue = resolveFieldValue(fields, formData, "title");
|
|
471
|
+
const titleStr = titleValue ? String(titleValue) : "";
|
|
425
472
|
|
|
426
|
-
if (
|
|
427
|
-
setField("metaTitle",
|
|
473
|
+
if (titleStr && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
|
|
474
|
+
setField("metaTitle", titleStr);
|
|
428
475
|
}
|
|
429
476
|
}, [formData, config.fields, setField]);
|
|
430
477
|
|
|
431
478
|
// Auto-generate slug
|
|
432
479
|
useEffect(() => {
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
480
|
+
const fields = config.fields as Record<string, unknown>[];
|
|
481
|
+
const slugField = fields.find(
|
|
482
|
+
(f: Record<string, unknown>) => f.name === "slug" && f.admin?.autoGenerate,
|
|
483
|
+
);
|
|
436
484
|
if (!slugField?.admin?.autoGenerate) return;
|
|
437
485
|
const sourceField: string = slugField.admin.autoGenerate;
|
|
438
486
|
|
|
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
|
-
}
|
|
487
|
+
const sourceValue = resolveFieldValue(fields, formData, sourceField);
|
|
451
488
|
|
|
452
489
|
if (isSlugLocked && sourceValue) {
|
|
453
490
|
const newSlug = slugifyText(sourceValue);
|
|
@@ -457,21 +494,28 @@ const slugField = config.fields.find(
|
|
|
457
494
|
}
|
|
458
495
|
}, [formData, isSlugLocked, config.fields, setField]);
|
|
459
496
|
|
|
460
|
-
// Auto-save effect
|
|
497
|
+
// Auto-save effect — only starts timers on keystroke-originated changes.
|
|
498
|
+
// Local save fires after 1.5s of inactivity, server save after 8s.
|
|
499
|
+
// Non-keystroke changes (block add/drag, select, checkbox, etc.) do NOT restart auto-save.
|
|
461
500
|
useEffect(() => {
|
|
462
501
|
if (sidebarCollapsed) return;
|
|
463
502
|
if (!globalSlug && (!collectionSlug || !formData.id)) return;
|
|
464
503
|
|
|
504
|
+
const state = useAutoFormStore.getState();
|
|
505
|
+
if (!state.hasDirtyFields()) return;
|
|
506
|
+
|
|
507
|
+
// Only schedule/reschedule on keystroke-originated changes
|
|
508
|
+
if (getLastChangeSource() !== "keystroke") return;
|
|
509
|
+
|
|
510
|
+
// Reset source so subsequent non-keystroke changes don't re-schedule
|
|
511
|
+
setChangeSource("other");
|
|
512
|
+
|
|
513
|
+
// Clear existing timers and start fresh
|
|
465
514
|
if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
|
|
466
515
|
if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
|
|
467
516
|
|
|
468
517
|
localSaveTimerRef.current = setTimeout(performLocalAutoSave, 1500);
|
|
469
|
-
serverSaveTimerRef.current = setTimeout(performServerAutoSave,
|
|
470
|
-
|
|
471
|
-
return () => {
|
|
472
|
-
if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
|
|
473
|
-
if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
|
|
474
|
-
};
|
|
518
|
+
serverSaveTimerRef.current = setTimeout(performServerAutoSave, 8000);
|
|
475
519
|
}, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performServerAutoSave]);
|
|
476
520
|
|
|
477
521
|
useEffect(() => {
|
|
@@ -479,6 +523,9 @@ const slugField = config.fields.find(
|
|
|
479
523
|
|
|
480
524
|
const flushDraft = () => {
|
|
481
525
|
if (autoSaveSkipRef.current) return;
|
|
526
|
+
const state = useAutoFormStore.getState();
|
|
527
|
+
// Only flush if there are actual unsaved changes
|
|
528
|
+
if (!state.hasDirtyFields()) return;
|
|
482
529
|
void performServerAutoSave({ keepalive: true });
|
|
483
530
|
};
|
|
484
531
|
|
|
@@ -488,13 +535,30 @@ const slugField = config.fields.find(
|
|
|
488
535
|
}
|
|
489
536
|
};
|
|
490
537
|
|
|
538
|
+
const handleOnline = () => {
|
|
539
|
+
isOnlineRef.current = true;
|
|
540
|
+
flushDraft();
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const handleOffline = () => {
|
|
544
|
+
isOnlineRef.current = false;
|
|
545
|
+
const state = useAutoFormStore.getState();
|
|
546
|
+
if (state.hasDirtyFields()) {
|
|
547
|
+
state.setAutoSaveStatus("offline");
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
491
551
|
window.addEventListener("blur", flushDraft);
|
|
492
552
|
window.addEventListener("pagehide", flushDraft);
|
|
553
|
+
window.addEventListener("online", handleOnline);
|
|
554
|
+
window.addEventListener("offline", handleOffline);
|
|
493
555
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
494
556
|
|
|
495
557
|
return () => {
|
|
496
558
|
window.removeEventListener("blur", flushDraft);
|
|
497
559
|
window.removeEventListener("pagehide", flushDraft);
|
|
560
|
+
window.removeEventListener("online", handleOnline);
|
|
561
|
+
window.removeEventListener("offline", handleOffline);
|
|
498
562
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
499
563
|
};
|
|
500
564
|
}, [collectionSlug, globalSlug, formData.id, performServerAutoSave]);
|
|
@@ -515,15 +579,15 @@ const slugField = config.fields.find(
|
|
|
515
579
|
|
|
516
580
|
// Derived status values the UI can use for badges and button state
|
|
517
581
|
const documentStatus: 'draft' | 'published' | 'archived' | undefined = (() => {
|
|
518
|
-
if (!versionsEnabled) return 'published';
|
|
519
582
|
if (!formData.id && !globalSlug) return 'draft';
|
|
583
|
+
if (!versionsEnabled) return 'published';
|
|
520
584
|
// If it has a pending draft version, effectively it's in a draft state for the editor
|
|
521
|
-
if (formData.
|
|
522
|
-
return formData.
|
|
585
|
+
if (formData.hasDraft) return 'draft';
|
|
586
|
+
return formData.publishStatus || 'published';
|
|
523
587
|
})();
|
|
524
588
|
|
|
525
589
|
const hasUnpublishedChanges =
|
|
526
|
-
(!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData.
|
|
590
|
+
(!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData.hasDraft);
|
|
527
591
|
|
|
528
592
|
return {
|
|
529
593
|
...store,
|
|
@@ -535,7 +599,6 @@ const slugField = config.fields.find(
|
|
|
535
599
|
clearDraftArtifacts,
|
|
536
600
|
autoSaveSkipRef,
|
|
537
601
|
lastAutoSaveTimeRef,
|
|
538
|
-
alert,
|
|
539
602
|
documentStatus,
|
|
540
603
|
hasUnpublishedChanges,
|
|
541
604
|
versionsEnabled,
|
package/src/integration.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { AstroIntegration } from "astro";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import fs from "fs";
|
|
4
|
-
import { execSync } from "child_process";
|
|
5
4
|
import { pathToFileURL } from "url";
|
|
5
|
+
import { build } from "esbuild";
|
|
6
6
|
import { config as loadDotEnv } from "dotenv";
|
|
7
|
-
import {
|
|
7
|
+
import { Worker } from "worker_threads";
|
|
8
|
+
|
|
9
|
+
const _shimDir = path.resolve(new URL(".", import.meta.url).pathname, "lib/shim");
|
|
10
|
+
const _shimUses = path.join(_shimDir, "use-sync-external-store.js");
|
|
11
|
+
const _shimUsesWs = path.join(_shimDir, "use-sync-external-store-with-selector.js");
|
|
8
12
|
|
|
9
13
|
export interface KyroAdminOptions {
|
|
10
14
|
basePath?: string;
|
|
@@ -46,81 +50,178 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
46
50
|
logger.warn(`Config file not found. Using defaults.`);
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
// Load .env first since Vite hasn't processed it yet at this point
|
|
53
|
-
// in the lifecycle, and the config module evaluates eagerly.
|
|
54
|
-
// Use esbuild to transpile TS to ESM, then evaluate in a child
|
|
55
|
-
// process to completely bypass Vite's module runner interception.
|
|
53
|
+
// Transpile the user's config and write a serialized JSON copy.
|
|
54
|
+
// The JSON path is injected via Vite's define so the admin can read it reliably.
|
|
55
|
+
const configFile = path.join(path.dirname(resolvedConfig), ".kyro-admin-config.json");
|
|
56
56
|
let tmpFile = "";
|
|
57
57
|
try {
|
|
58
58
|
const envPath = path.join(path.dirname(resolvedConfig), ".env");
|
|
59
59
|
if (fs.existsSync(envPath)) {
|
|
60
60
|
loadDotEnv({ path: envPath });
|
|
61
61
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
const result = await build({
|
|
63
|
+
entryPoints: [resolvedConfig],
|
|
64
|
+
bundle: true,
|
|
65
65
|
format: "esm",
|
|
66
|
+
platform: "node",
|
|
66
67
|
target: "es2022",
|
|
68
|
+
write: false,
|
|
67
69
|
sourcemap: false,
|
|
70
|
+
loader: { '.ts': 'ts', '.tsx': 'tsx' },
|
|
71
|
+
resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'],
|
|
72
|
+
external: ['@kyro-cms/*'],
|
|
68
73
|
});
|
|
69
|
-
// Write transpiled config alongside original so Node.js can
|
|
70
|
-
// resolve @kyro-cms/core from the project's node_modules
|
|
71
74
|
tmpFile = resolvedConfig.replace(/\.ts$/, ".admin.mjs");
|
|
72
|
-
fs.writeFileSync(tmpFile, result.
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
75
|
+
fs.writeFileSync(tmpFile, result.outputFiles[0].text, "utf8");
|
|
76
|
+
|
|
77
|
+
// Use a Worker thread to load the config in an isolated context.
|
|
78
|
+
const workerCode = `
|
|
79
|
+
import { parentPort } from 'worker_threads';
|
|
80
|
+
import('${pathToFileURL(tmpFile).href}').then(mod => {
|
|
81
|
+
const cfg = mod.default || mod;
|
|
82
|
+
const serialize = (obj) => {
|
|
83
|
+
if (obj === null || obj === undefined) return obj;
|
|
84
|
+
if (typeof obj === 'function') return undefined;
|
|
85
|
+
if (Array.isArray(obj)) return obj.map(serialize);
|
|
86
|
+
if (typeof obj === 'object') {
|
|
87
|
+
const result = {};
|
|
88
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
89
|
+
const sv = serialize(v);
|
|
90
|
+
if (sv !== undefined) result[k] = sv;
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
return obj;
|
|
95
|
+
};
|
|
96
|
+
parentPort.postMessage({
|
|
97
|
+
collections: serialize(cfg?.collections) || [],
|
|
98
|
+
globals: serialize(cfg?.globals) || [],
|
|
99
|
+
collectionOverrides: serialize(cfg?.admin?.collectionOverrides) || {},
|
|
100
|
+
});
|
|
101
|
+
}).catch(err => {
|
|
102
|
+
parentPort.postMessage({ error: err.message });
|
|
103
|
+
});
|
|
104
|
+
`;
|
|
105
|
+
const worker = new Worker(workerCode, { eval: true, env: { ...process.env, NODE_OPTIONS: '' } });
|
|
106
|
+
const configResult = await new Promise<any>((resolve, reject) => {
|
|
107
|
+
worker.on("message", resolve);
|
|
108
|
+
worker.on("error", reject);
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
worker.terminate();
|
|
111
|
+
reject(new Error("Config loading timed out"));
|
|
112
|
+
}, 30000);
|
|
113
|
+
});
|
|
114
|
+
worker.terminate();
|
|
115
|
+
|
|
116
|
+
if (configResult.error) {
|
|
117
|
+
throw new Error(configResult.error);
|
|
93
118
|
}
|
|
94
|
-
(
|
|
95
|
-
collections: configModule.collections,
|
|
96
|
-
globals: configModule.globals,
|
|
97
|
-
adapter: configModule.adapter || null,
|
|
98
|
-
};
|
|
119
|
+
fs.writeFileSync(configFile, JSON.stringify(configResult, null, 2), "utf8");
|
|
99
120
|
logger.info("Project config loaded for admin");
|
|
100
121
|
} catch (e: any) {
|
|
101
|
-
logger.
|
|
122
|
+
logger.error(`Could not load project config: ${e.message}`);
|
|
102
123
|
} finally {
|
|
103
|
-
|
|
104
|
-
const f = resolvedConfig.replace(/\.ts$/, suffix);
|
|
105
|
-
if (fs.existsSync(f)) { try { fs.unlinkSync(f); } catch { /* ignore */ } }
|
|
106
|
-
}
|
|
124
|
+
if (tmpFile && fs.existsSync(tmpFile)) { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } }
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
// Set up Vite aliases and
|
|
127
|
+
// Set up Vite aliases, defines, and plugins for runtime use
|
|
110
128
|
updateConfig({
|
|
111
129
|
vite: {
|
|
130
|
+
plugins: [
|
|
131
|
+
{
|
|
132
|
+
name: "kyro-admin-tsx-loader",
|
|
133
|
+
enforce: "pre" as const,
|
|
134
|
+
config(_config: any) {
|
|
135
|
+
const existingEsbuild = _config.esbuild;
|
|
136
|
+
const existingExclude = existingEsbuild?.exclude;
|
|
137
|
+
const adminInclude = /\/node_modules\/(?!.*@kyro-cms\/(admin|core))/;
|
|
138
|
+
return {
|
|
139
|
+
esbuild: {
|
|
140
|
+
...(existingEsbuild || {}),
|
|
141
|
+
exclude: existingExclude
|
|
142
|
+
? Array.isArray(existingExclude)
|
|
143
|
+
? [...existingExclude, adminInclude]
|
|
144
|
+
: [existingExclude, adminInclude]
|
|
145
|
+
: adminInclude,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "kyro-cjs-shim",
|
|
152
|
+
enforce: "pre" as const,
|
|
153
|
+
resolveId(id: string) {
|
|
154
|
+
if (id.includes('react/compiler-runtime')) {
|
|
155
|
+
return "\0react-compiler-runtime";
|
|
156
|
+
}
|
|
157
|
+
if (id === 'debug' || id.includes('debug/src/browser.js')) {
|
|
158
|
+
return "\0debug-browser";
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
load(id: string) {
|
|
162
|
+
if (id === "\0react-compiler-runtime") {
|
|
163
|
+
return `
|
|
164
|
+
import React from "react";
|
|
165
|
+
export function c(size) {
|
|
166
|
+
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
167
|
+
if (!internals || !internals.H) {
|
|
168
|
+
return new Array(size);
|
|
169
|
+
}
|
|
170
|
+
return internals.H.useMemoCache(size);
|
|
171
|
+
}
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
if (id === "\0debug-browser") {
|
|
175
|
+
return `
|
|
176
|
+
function debug(namespace) {
|
|
177
|
+
function d(...args) {
|
|
178
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("DEBUG")) {
|
|
179
|
+
console.log(namespace, ...args);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
d.enabled = false;
|
|
183
|
+
return d;
|
|
184
|
+
}
|
|
185
|
+
debug.enable = function() {};
|
|
186
|
+
debug.disable = function() {};
|
|
187
|
+
debug.enabled = function() { return false; };
|
|
188
|
+
export default debug;
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
],
|
|
112
194
|
resolve: {
|
|
113
195
|
alias: {
|
|
114
196
|
"kyro:config": resolvedConfig,
|
|
115
197
|
},
|
|
116
198
|
},
|
|
199
|
+
optimizeDeps: {
|
|
200
|
+
include: [
|
|
201
|
+
'use-sync-external-store',
|
|
202
|
+
],
|
|
203
|
+
exclude: ['debug', 'react/compiler-runtime'],
|
|
204
|
+
},
|
|
117
205
|
define: {
|
|
118
206
|
__KYRO_ADMIN_PATH__: JSON.stringify(basePath),
|
|
119
207
|
__KYRO_API_PATH__: JSON.stringify(apiPath),
|
|
208
|
+
__KYRO_ADMIN_CONFIG_FILE__: JSON.stringify(configFile),
|
|
209
|
+
},
|
|
210
|
+
ssr: {
|
|
211
|
+
noExternal: ['@kyro-cms/admin', '@kyro-cms/core'],
|
|
120
212
|
},
|
|
121
213
|
},
|
|
122
214
|
});
|
|
123
215
|
|
|
216
|
+
// Load the user's config at runtime via the kyro:config alias.
|
|
217
|
+
// We set a placeholder here; the actual config is loaded by
|
|
218
|
+
// admin/lib/config.ts via dynamic import of kyro:config.
|
|
219
|
+
(globalThis as any).__KYRO_ADMIN_PROJECT_CONFIG__ = {
|
|
220
|
+
collections: [],
|
|
221
|
+
globals: [],
|
|
222
|
+
adapter: null,
|
|
223
|
+
};
|
|
224
|
+
|
|
124
225
|
// Inject Admin UI Routes
|
|
125
226
|
const pages = [
|
|
126
227
|
{ pattern: "", entrypoint: "./pages/index.astro" },
|
|
@@ -128,6 +229,7 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
128
229
|
{ pattern: "/register", entrypoint: "./pages/auth/register.astro" },
|
|
129
230
|
{ pattern: "/media", entrypoint: "./pages/media.astro" },
|
|
130
231
|
{ pattern: "/users", entrypoint: "./pages/users/index.astro" },
|
|
232
|
+
{ pattern: "/users/new", entrypoint: "./pages/users/new.astro" },
|
|
131
233
|
{ pattern: "/users/[id]", entrypoint: "./pages/users/[id].astro" },
|
|
132
234
|
{ pattern: "/roles", entrypoint: "./pages/roles/index.astro" },
|
|
133
235
|
{ pattern: "/settings", entrypoint: "./pages/settings/index.astro" },
|
package/src/kyro-cms.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ admin?: {
|
|
|
57
57
|
export interface SelectField extends FieldConfig { type: 'select'; options?: { label: string; value: string | number }[] }
|
|
58
58
|
export interface TextareaField extends FieldConfig { type: 'textarea' }
|
|
59
59
|
export interface MarkdownField extends FieldConfig { type: 'markdown' }
|
|
60
|
-
export interface RichTextField extends FieldConfig { type: '
|
|
60
|
+
export interface RichTextField extends FieldConfig { type: 'richtext' }
|
|
61
61
|
export interface CodeField extends FieldConfig { type: 'code'; language?: string }
|
|
62
62
|
export interface JSONField extends FieldConfig { type: 'json' }
|
|
63
63
|
export interface ImageField extends FieldConfig { type: 'image' }
|
|
@@ -208,4 +208,9 @@ declare module 'kyro:config' {
|
|
|
208
208
|
import type { KyroConfig } from '@kyro-cms/core';
|
|
209
209
|
const config: KyroConfig;
|
|
210
210
|
export default config;
|
|
211
|
-
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Injected by Vite's define config during admin integration setup
|
|
214
|
+
declare const __KYRO_ADMIN_CONFIG_FILE__: string;
|
|
215
|
+
declare const __KYRO_ADMIN_PATH__: string;
|
|
216
|
+
declare const __KYRO_API_PATH__: string;
|
|
@@ -49,10 +49,22 @@ const { title } = Astro.props;
|
|
|
49
49
|
<div class="w-full max-w-md flex flex-col items-center">
|
|
50
50
|
<!-- Logo -->
|
|
51
51
|
<div class="w-full text-center mb-8">
|
|
52
|
-
<a href="/" class="inline-
|
|
52
|
+
<a href="/" class="inline-flex items-center justify-center gap-2">
|
|
53
|
+
<svg class="w-7 h-7 text-[var(--kyro-text-primary)]" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
54
|
+
<defs>
|
|
55
|
+
<mask id="kyro-cutout-auth">
|
|
56
|
+
<rect width="100" height="100" fill="white" />
|
|
57
|
+
<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" />
|
|
58
|
+
</mask>
|
|
59
|
+
</defs>
|
|
60
|
+
<g mask="url(#kyro-cutout-auth)" fill="currentColor">
|
|
61
|
+
<circle cx="45" cy="45" r="45" />
|
|
62
|
+
<rect x="40" y="40" width="60" height="60" />
|
|
63
|
+
</g>
|
|
64
|
+
</svg>
|
|
53
65
|
<span
|
|
54
66
|
class="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]"
|
|
55
|
-
>
|
|
67
|
+
>Kyro CMS</span
|
|
56
68
|
>
|
|
57
69
|
</a>
|
|
58
70
|
</div>
|