@kyro-cms/admin 0.8.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
|
@@ -65,6 +65,8 @@ const createAutoFormStorage = (): StateStorage => {
|
|
|
65
65
|
};
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
type AutoSaveStatus = "idle" | "saving" | "success" | "error" | "conflict" | "retrying" | "offline";
|
|
69
|
+
|
|
68
70
|
interface AutoFormStore {
|
|
69
71
|
// In-memory document state
|
|
70
72
|
formData: Record<string, unknown>;
|
|
@@ -72,6 +74,9 @@ interface AutoFormStore {
|
|
|
72
74
|
sidebarCollapsed: boolean;
|
|
73
75
|
draftCache: Record<string, BrowserDraftCacheEntry>;
|
|
74
76
|
|
|
77
|
+
// Dirty field tracking (not persisted)
|
|
78
|
+
dirtyFields: Set<string>;
|
|
79
|
+
|
|
75
80
|
// UI State (not persisted)
|
|
76
81
|
activeTab: number;
|
|
77
82
|
isSlugLocked: boolean;
|
|
@@ -88,13 +93,14 @@ interface AutoFormStore {
|
|
|
88
93
|
compareDiffs: VersionDiff[];
|
|
89
94
|
loadingDiffs: boolean;
|
|
90
95
|
isAutoSaving: boolean;
|
|
91
|
-
autoSaveStatus:
|
|
96
|
+
autoSaveStatus: AutoSaveStatus;
|
|
92
97
|
|
|
93
98
|
// Auto-save
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
lastAutoSaveTime: number;
|
|
100
|
+
lastSavedAt: number | null;
|
|
101
|
+
retryCount: number;
|
|
102
|
+
autoSaveSkip: boolean;
|
|
103
|
+
autoSaveTimer: NodeJS.Timeout | null;
|
|
98
104
|
// Actions - Field Updates
|
|
99
105
|
setField: (field: string, value: unknown) => void;
|
|
100
106
|
setFormData: (data: Record<string, unknown>) => void;
|
|
@@ -118,8 +124,10 @@ interface AutoFormStore {
|
|
|
118
124
|
setCompareDiffs: (diffs: VersionDiff[]) => void;
|
|
119
125
|
setLoadingDiffs: (loading: boolean) => void;
|
|
120
126
|
setIsAutoSaving: (saving: boolean) => void;
|
|
121
|
-
setAutoSaveStatus: (status:
|
|
127
|
+
setAutoSaveStatus: (status: AutoSaveStatus) => void;
|
|
122
128
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
129
|
+
setLastSavedAt: (time: number | null) => void;
|
|
130
|
+
setRetryCount: (count: number) => void;
|
|
123
131
|
|
|
124
132
|
// Auto-save actions
|
|
125
133
|
setAutoSaveSkip: (skip: boolean) => void;
|
|
@@ -127,6 +135,7 @@ interface AutoFormStore {
|
|
|
127
135
|
startAutoSaveTimer: (callback: () => void, delay: number) => void;
|
|
128
136
|
clearAutoSaveTimer: () => void;
|
|
129
137
|
|
|
138
|
+
|
|
130
139
|
// Actions - Data Management
|
|
131
140
|
markSaved: () => void;
|
|
132
141
|
resetForm: () => void;
|
|
@@ -135,10 +144,13 @@ loadDocument: (
|
|
|
135
144
|
lastSaved?: Record<string, unknown>,
|
|
136
145
|
) => void;
|
|
137
146
|
|
|
138
|
-
updateTabData: (tabName: string, newTabData: Record<string, unknown>) => void;
|
|
139
147
|
getField: (field: string) => unknown;
|
|
140
148
|
getNestedField: (path: string) => unknown;
|
|
141
149
|
getHasChanges: () => boolean;
|
|
150
|
+
hasDirtyFields: () => boolean;
|
|
151
|
+
getDirtyData: () => Record<string, unknown>;
|
|
152
|
+
clearDirtyFields: () => void;
|
|
153
|
+
pruneExpiredDrafts: () => void;
|
|
142
154
|
setDraftCache: (documentKey: string, draft: BrowserDraftCacheEntry) => void;
|
|
143
155
|
getDraftCache: (documentKey: string) => BrowserDraftCacheEntry | null;
|
|
144
156
|
clearDraftCache: (documentKey: string) => void;
|
|
@@ -160,6 +172,9 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
160
172
|
sidebarCollapsed: false,
|
|
161
173
|
draftCache: {},
|
|
162
174
|
|
|
175
|
+
// Dirty field tracking
|
|
176
|
+
dirtyFields: new Set<string>(),
|
|
177
|
+
|
|
163
178
|
// Initial UI state
|
|
164
179
|
activeTab: 0,
|
|
165
180
|
isSlugLocked: true,
|
|
@@ -176,24 +191,34 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
176
191
|
compareDiffs: [],
|
|
177
192
|
loadingDiffs: false,
|
|
178
193
|
isAutoSaving: false,
|
|
179
|
-
autoSaveStatus: "idle",
|
|
194
|
+
autoSaveStatus: "idle" as AutoSaveStatus,
|
|
180
195
|
|
|
181
196
|
// Auto-save state
|
|
182
197
|
lastAutoSaveTime: 0,
|
|
198
|
+
lastSavedAt: null,
|
|
199
|
+
retryCount: 0,
|
|
183
200
|
autoSaveSkip: false,
|
|
184
201
|
autoSaveTimer: null,
|
|
185
202
|
|
|
203
|
+
|
|
186
204
|
// Field update actions
|
|
187
205
|
setField: (field: string, value: unknown) => {
|
|
188
|
-
|
|
189
|
-
|
|
206
|
+
const state = get();
|
|
207
|
+
const newDirty = new Set(state.dirtyFields);
|
|
208
|
+
// Mark dirty if value differs from last saved baseline
|
|
209
|
+
if (JSON.stringify(value) !== JSON.stringify(state.lastSavedData[field])) {
|
|
210
|
+
newDirty.add(field);
|
|
211
|
+
} else {
|
|
212
|
+
newDirty.delete(field);
|
|
190
213
|
}
|
|
191
|
-
set(
|
|
214
|
+
set({
|
|
192
215
|
formData: {
|
|
193
216
|
...state.formData,
|
|
194
217
|
[field]: value,
|
|
195
218
|
},
|
|
196
|
-
|
|
219
|
+
dirtyFields: newDirty,
|
|
220
|
+
hasUnsavedChanges: newDirty.size > 0,
|
|
221
|
+
});
|
|
197
222
|
},
|
|
198
223
|
|
|
199
224
|
setFormData: (data: Record<string, unknown>) => {
|
|
@@ -267,9 +292,11 @@ setField: (field: string, value: unknown) => {
|
|
|
267
292
|
setCompareDiffs: (diffs: VersionDiff[]) => set({ compareDiffs: diffs }),
|
|
268
293
|
setLoadingDiffs: (loading: boolean) => set({ loadingDiffs: loading }),
|
|
269
294
|
setIsAutoSaving: (saving: boolean) => set({ isAutoSaving: saving }),
|
|
270
|
-
setAutoSaveStatus: (status) => set({ autoSaveStatus: status }),
|
|
295
|
+
setAutoSaveStatus: (status: AutoSaveStatus) => set({ autoSaveStatus: status }),
|
|
271
296
|
setSidebarCollapsed: (collapsed: boolean) =>
|
|
272
297
|
set({ sidebarCollapsed: collapsed }),
|
|
298
|
+
setLastSavedAt: (time: number | null) => set({ lastSavedAt: time }),
|
|
299
|
+
setRetryCount: (count: number) => set({ retryCount: count }),
|
|
273
300
|
|
|
274
301
|
// Auto-save actions
|
|
275
302
|
setAutoSaveSkip: (skip: boolean) => set({ autoSaveSkip: skip }),
|
|
@@ -295,7 +322,12 @@ setField: (field: string, value: unknown) => {
|
|
|
295
322
|
// Data management
|
|
296
323
|
markSaved: () => {
|
|
297
324
|
const { formData } = get();
|
|
298
|
-
set({
|
|
325
|
+
set({
|
|
326
|
+
lastSavedData: formData,
|
|
327
|
+
hasUnsavedChanges: false,
|
|
328
|
+
dirtyFields: new Set<string>(),
|
|
329
|
+
lastSavedAt: Date.now(),
|
|
330
|
+
});
|
|
299
331
|
},
|
|
300
332
|
|
|
301
333
|
setLastSavedData: (data: Record<string, unknown>) => {
|
|
@@ -307,6 +339,7 @@ setField: (field: string, value: unknown) => {
|
|
|
307
339
|
formData: {},
|
|
308
340
|
lastSavedData: {},
|
|
309
341
|
hasUnsavedChanges: false,
|
|
342
|
+
dirtyFields: new Set<string>(),
|
|
310
343
|
activeTab: 0,
|
|
311
344
|
});
|
|
312
345
|
},
|
|
@@ -319,30 +352,10 @@ setField: (field: string, value: unknown) => {
|
|
|
319
352
|
formData: data,
|
|
320
353
|
lastSavedData: lastSaved || data,
|
|
321
354
|
hasUnsavedChanges: false,
|
|
355
|
+
dirtyFields: new Set<string>(),
|
|
322
356
|
});
|
|
323
357
|
},
|
|
324
358
|
|
|
325
|
-
updateTabData: (tabName, newTabData) => {
|
|
326
|
-
const { formData } = get();
|
|
327
|
-
const tabData = formData[tabName] || {};
|
|
328
|
-
|
|
329
|
-
let updatedTab;
|
|
330
|
-
if (Array.isArray(newTabData)) {
|
|
331
|
-
updatedTab = { ...tabData, content: newTabData };
|
|
332
|
-
} else if (typeof newTabData === "object" && newTabData !== null) {
|
|
333
|
-
updatedTab = { ...tabData, ...newTabData };
|
|
334
|
-
} else {
|
|
335
|
-
updatedTab = newTabData;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
set((state) => ({
|
|
339
|
-
formData: {
|
|
340
|
-
...state.formData,
|
|
341
|
-
[tabName]: updatedTab,
|
|
342
|
-
},
|
|
343
|
-
}));
|
|
344
|
-
},
|
|
345
|
-
|
|
346
359
|
// Computed values
|
|
347
360
|
getField: (field: string) => {
|
|
348
361
|
return get().formData[field];
|
|
@@ -361,8 +374,37 @@ setField: (field: string, value: unknown) => {
|
|
|
361
374
|
},
|
|
362
375
|
|
|
363
376
|
getHasChanges: () => {
|
|
364
|
-
|
|
365
|
-
|
|
377
|
+
return get().dirtyFields.size > 0;
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
hasDirtyFields: () => {
|
|
381
|
+
return get().dirtyFields.size > 0;
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
getDirtyData: () => {
|
|
385
|
+
const { formData, dirtyFields } = get();
|
|
386
|
+
const delta: Record<string, unknown> = {};
|
|
387
|
+
for (const field of dirtyFields) {
|
|
388
|
+
delta[field] = formData[field];
|
|
389
|
+
}
|
|
390
|
+
return delta;
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
clearDirtyFields: () => {
|
|
394
|
+
set({ dirtyFields: new Set<string>(), hasUnsavedChanges: false });
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
pruneExpiredDrafts: () => {
|
|
398
|
+
const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
const { draftCache } = get();
|
|
401
|
+
const pruned: Record<string, BrowserDraftCacheEntry> = {};
|
|
402
|
+
for (const [key, entry] of Object.entries(draftCache)) {
|
|
403
|
+
if (now - new Date(entry.draftUpdatedAt).getTime() < DRAFT_TTL_MS) {
|
|
404
|
+
pruned[key] = entry;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
set({ draftCache: pruned });
|
|
366
408
|
},
|
|
367
409
|
|
|
368
410
|
setDraftCache: (documentKey, draft) =>
|
|
@@ -389,6 +431,12 @@ setField: (field: string, value: unknown) => {
|
|
|
389
431
|
sidebarCollapsed: state.sidebarCollapsed,
|
|
390
432
|
draftCache: state.draftCache,
|
|
391
433
|
}),
|
|
434
|
+
onRehydrateStorage: () => (state) => {
|
|
435
|
+
// Prune expired drafts on hydration
|
|
436
|
+
if (state) {
|
|
437
|
+
state.pruneExpiredDrafts();
|
|
438
|
+
}
|
|
439
|
+
},
|
|
392
440
|
},
|
|
393
441
|
),
|
|
394
442
|
);
|
|
@@ -418,18 +466,3 @@ export function useAutoFormField(
|
|
|
418
466
|
};
|
|
419
467
|
}
|
|
420
468
|
|
|
421
|
-
// Helper to get formData for tabs
|
|
422
|
-
export function useAutoFormTabData(tabName: string) {
|
|
423
|
-
const formData = useAutoFormStore((s) => s.formData);
|
|
424
|
-
const setNestedField = useAutoFormStore((s) => s.setNestedField);
|
|
425
|
-
const setFormData = useAutoFormStore((s) => s.setFormData);
|
|
426
|
-
|
|
427
|
-
const tabData = formData[tabName] || {};
|
|
428
|
-
const updateTabData = useAutoFormStore((s) => s.updateTabData);
|
|
429
|
-
|
|
430
|
-
const onTabDataChange = (newTabData: Record<string, unknown>) => {
|
|
431
|
-
updateTabData(tabName, newTabData);
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
return { tabData, onTabDataChange };
|
|
435
|
-
}
|
package/src/lib/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CollectionConfig, GlobalConfig } from "@kyro-cms/core/client";
|
|
1
|
+
import type { AdminConfig, CollectionConfig, GlobalConfig } from "@kyro-cms/core/client";
|
|
2
2
|
import {
|
|
3
3
|
blogCollections,
|
|
4
4
|
ecommerceCollections,
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
allSettingsGlobals,
|
|
11
11
|
coreSettingsGlobals,
|
|
12
12
|
} from "@kyro-cms/core/templates";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
|
|
15
|
+
// Injected by Vite's define config — exact path to the serialized config JSON.
|
|
16
|
+
declare const __KYRO_ADMIN_CONFIG_FILE__: string;
|
|
13
17
|
|
|
14
18
|
type ConfigCollectionInput =
|
|
15
19
|
| CollectionConfig[]
|
|
@@ -62,6 +66,83 @@ function addMissingCollections(
|
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
const defaultCollectionIcons: Record<string, string> = {
|
|
70
|
+
pages: "FileText",
|
|
71
|
+
posts: "Newspaper",
|
|
72
|
+
categories: "Tags",
|
|
73
|
+
menu: "Menu",
|
|
74
|
+
products: "ShoppingBag",
|
|
75
|
+
customers: "Users",
|
|
76
|
+
orders: "ShoppingCart",
|
|
77
|
+
coupons: "Ticket",
|
|
78
|
+
forms: "FileInput",
|
|
79
|
+
"form-entries": "Inbox",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Recursively find and update a field by its dot-notation path.
|
|
84
|
+
* Example: "menu.menu_item.internal_target" navigates through nested field structures.
|
|
85
|
+
*/
|
|
86
|
+
function updateFieldByPath(
|
|
87
|
+
fields: any[],
|
|
88
|
+
path: string,
|
|
89
|
+
updates: Record<string, any>,
|
|
90
|
+
): boolean {
|
|
91
|
+
const parts = path.split(".");
|
|
92
|
+
if (parts.length === 0) return false;
|
|
93
|
+
|
|
94
|
+
const currentPart = parts[0];
|
|
95
|
+
const remainingPath = parts.slice(1).join(".");
|
|
96
|
+
|
|
97
|
+
for (const field of fields) {
|
|
98
|
+
if (field.name === currentPart) {
|
|
99
|
+
if (remainingPath) {
|
|
100
|
+
// Continue traversing nested fields
|
|
101
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
102
|
+
return updateFieldByPath(field.fields, remainingPath, updates);
|
|
103
|
+
}
|
|
104
|
+
// For array fields, look in the nested fields
|
|
105
|
+
if (field.type === "array" && field.fields && Array.isArray(field.fields)) {
|
|
106
|
+
return updateFieldByPath(field.fields, remainingPath, updates);
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
} else {
|
|
110
|
+
// Found the target field, apply updates
|
|
111
|
+
Object.assign(field, updates);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyCollectionAdminOverrides(
|
|
120
|
+
collections: Record<string, CollectionConfig>,
|
|
121
|
+
overrides?: Record<
|
|
122
|
+
string,
|
|
123
|
+
Partial<AdminConfig> & { fields?: Record<string, any> }
|
|
124
|
+
>,
|
|
125
|
+
): void {
|
|
126
|
+
for (const [slug, col] of Object.entries(collections)) {
|
|
127
|
+
const defaultIcon = defaultCollectionIcons[slug];
|
|
128
|
+
const override = overrides?.[slug];
|
|
129
|
+
if (defaultIcon && !col.admin?.icon) {
|
|
130
|
+
col.admin = { ...col.admin, icon: defaultIcon };
|
|
131
|
+
}
|
|
132
|
+
if (override) {
|
|
133
|
+
const { fields: fieldOverrides, ...adminOverrides } = override;
|
|
134
|
+
col.admin = { ...col.admin, ...adminOverrides };
|
|
135
|
+
|
|
136
|
+
// Apply field-level overrides
|
|
137
|
+
if (fieldOverrides && col.fields && Array.isArray(col.fields)) {
|
|
138
|
+
for (const [fieldPath, fieldUpdates] of Object.entries(fieldOverrides)) {
|
|
139
|
+
updateFieldByPath(col.fields, fieldPath, fieldUpdates);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
65
146
|
export function getAdminConfig(template: AdminTemplate = "blog") {
|
|
66
147
|
const collections: CollectionConfig[] = [];
|
|
67
148
|
const globals: GlobalConfig[] = [];
|
|
@@ -126,17 +207,32 @@ function createProjectAdminConfig(config: {
|
|
|
126
207
|
};
|
|
127
208
|
}
|
|
128
209
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
210
|
+
// Read config from the JSON file whose path is injected by Vite's define.
|
|
211
|
+
let cachedConfig: { collections: any[]; globals: any[]; collectionOverrides?: Record<string, any> } | null = null;
|
|
212
|
+
|
|
213
|
+
function loadProjectConfig() {
|
|
214
|
+
if (cachedConfig) return cachedConfig;
|
|
215
|
+
try {
|
|
216
|
+
if (typeof __KYRO_ADMIN_CONFIG_FILE__ === "string" && fs.existsSync(__KYRO_ADMIN_CONFIG_FILE__)) {
|
|
217
|
+
cachedConfig = JSON.parse(fs.readFileSync(__KYRO_ADMIN_CONFIG_FILE__, "utf8"));
|
|
218
|
+
return cachedConfig;
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// fall through to kitchen-sink default
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const projectCfg = loadProjectConfig() || { collections: [], globals: [] };
|
|
227
|
+
|
|
228
|
+
const rawConfig = createProjectAdminConfig(projectCfg);
|
|
229
|
+
applyCollectionAdminOverrides(rawConfig.collections, projectCfg.collectionOverrides);
|
|
134
230
|
|
|
135
|
-
export const adminConfig =
|
|
231
|
+
export const adminConfig = rawConfig;
|
|
136
232
|
export const collections = adminConfig.collections;
|
|
137
233
|
export const globals = adminConfig.globals;
|
|
138
234
|
|
|
139
235
|
export const authCollectionSlugs = ["users", "audit_logs"];
|
|
140
236
|
export const nonAuthCollections = Object.values(collections).filter(
|
|
141
|
-
(c) => !authCollectionSlugs.includes(c.slug),
|
|
237
|
+
(c) => !authCollectionSlugs.includes(c.slug) && c.admin?.hidden !== true,
|
|
142
238
|
);
|
package/src/lib/globals.ts
CHANGED
|
@@ -12,7 +12,38 @@ export interface GlobalOptions {
|
|
|
12
12
|
* falling back to the direct adapter approach.
|
|
13
13
|
*/
|
|
14
14
|
export async function getGlobal(slug: string, options?: GlobalOptions) {
|
|
15
|
-
// Strategy 1:
|
|
15
|
+
// Strategy 1: Direct adapter access via shared kyro instance (SSR context)
|
|
16
|
+
// This avoids HTTP round-trips and auth cookie forwarding issues
|
|
17
|
+
const kyroInstance = (globalThis as any).__KYRO_INSTANCE__;
|
|
18
|
+
if (kyroInstance?.db) {
|
|
19
|
+
try {
|
|
20
|
+
const db = kyroInstance.db as BaseAdapter;
|
|
21
|
+
const doc = await db.findOne({
|
|
22
|
+
collection: `_globals_${slug}`,
|
|
23
|
+
where: {},
|
|
24
|
+
draft: options?.draft ?? false,
|
|
25
|
+
});
|
|
26
|
+
if (!doc) return null;
|
|
27
|
+
|
|
28
|
+
const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
|
|
29
|
+
for (const field of mediaFields) {
|
|
30
|
+
const val = doc[field];
|
|
31
|
+
const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
|
|
32
|
+
if (id) {
|
|
33
|
+
try {
|
|
34
|
+
const mediaDoc = await db.findByID({
|
|
35
|
+
collection: "media",
|
|
36
|
+
id,
|
|
37
|
+
});
|
|
38
|
+
if (mediaDoc) doc[field] = mediaDoc;
|
|
39
|
+
} catch { /* media field stays as-is */ }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return doc;
|
|
43
|
+
} catch { /* fall through to API strategy */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Strategy 2: Use the API endpoint (works for all dialects, adapter agnostic)
|
|
16
47
|
if (options?.request) {
|
|
17
48
|
try {
|
|
18
49
|
const apiPath = (globalThis as any).__KYRO_API_PATH__ || "/api";
|
|
@@ -27,23 +58,25 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
|
|
|
27
58
|
// Resolve media fields via the same API endpoint
|
|
28
59
|
const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
|
|
29
60
|
for (const field of mediaFields) {
|
|
30
|
-
|
|
61
|
+
const val = doc[field];
|
|
62
|
+
const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
|
|
63
|
+
if (id) {
|
|
31
64
|
try {
|
|
32
|
-
const mediaRes = await fetch(`${apiPath}/media/${
|
|
65
|
+
const mediaRes = await fetch(`${apiPath}/media/${id}`, {
|
|
33
66
|
headers: { Cookie: cookie },
|
|
34
67
|
});
|
|
35
68
|
if (mediaRes.ok) {
|
|
36
69
|
doc[field] = await mediaRes.json();
|
|
37
70
|
}
|
|
38
|
-
} catch { /* media field stays as
|
|
71
|
+
} catch { /* media field stays as-is */ }
|
|
39
72
|
}
|
|
40
73
|
}
|
|
41
74
|
return doc;
|
|
42
75
|
}
|
|
43
|
-
} catch { /* fall through to adapter */ }
|
|
76
|
+
} catch { /* fall through to legacy adapter */ }
|
|
44
77
|
}
|
|
45
78
|
|
|
46
|
-
// Strategy
|
|
79
|
+
// Strategy 3: Legacy direct adapter access (fallback for non-request contexts)
|
|
47
80
|
const global = globalThis as any;
|
|
48
81
|
const projectConfig = global.__KYRO_ADMIN_PROJECT_CONFIG__;
|
|
49
82
|
if (!projectConfig) return null;
|
|
@@ -66,14 +99,16 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
|
|
|
66
99
|
|
|
67
100
|
const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
|
|
68
101
|
for (const field of mediaFields) {
|
|
69
|
-
|
|
102
|
+
const val = doc[field];
|
|
103
|
+
const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
|
|
104
|
+
if (id) {
|
|
70
105
|
try {
|
|
71
106
|
const mediaDoc = await db.findByID({
|
|
72
107
|
collection: "media",
|
|
73
|
-
id
|
|
108
|
+
id,
|
|
74
109
|
});
|
|
75
110
|
if (mediaDoc) doc[field] = mediaDoc;
|
|
76
|
-
} catch { /* media field stays as
|
|
111
|
+
} catch { /* media field stays as-is */ }
|
|
77
112
|
}
|
|
78
113
|
}
|
|
79
114
|
return doc;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively walks the given value and converts full media upload objects
|
|
3
|
+
* back to their ID strings before sending to the server.
|
|
4
|
+
*
|
|
5
|
+
* The UploadField stores full media objects { id, url, filename, mimeType }
|
|
6
|
+
* in formData for display purposes, but the server expects just the ID string.
|
|
7
|
+
* Without normalization, the server stores the full object as a JSON string,
|
|
8
|
+
* which causes 404s on the next load when trying to fetch the media.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeUploadFields(value: unknown): unknown {
|
|
11
|
+
if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return value.map(normalizeUploadFields);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof value === "object") {
|
|
20
|
+
const obj = value as Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
// Heuristic: detect a full media object by checking for id + url/filename/mimeType
|
|
23
|
+
// and a small number of keys (media objects typically have ~6-8 keys).
|
|
24
|
+
// Also handle bare {id} objects that result from JSONB column conversion.
|
|
25
|
+
const keys = Object.keys(obj);
|
|
26
|
+
const hasId = "id" in obj && (typeof obj.id === "string" || obj.id === null);
|
|
27
|
+
const hasMediaField = "url" in obj || "filename" in obj || "mimeType" in obj;
|
|
28
|
+
if (hasId && (hasMediaField || keys.length <= 2) && keys.length <= 8) {
|
|
29
|
+
return obj.id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Recursively normalize nested objects (tabs, groups, blocks, arrays)
|
|
33
|
+
const result: Record<string, unknown> = {};
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
result[key] = normalizeUploadFields(obj[key]);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return value;
|
|
41
|
+
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -39,8 +39,8 @@ export function resolveAdmin(url: string): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
export function resolveMedia(url:
|
|
43
|
-
if (!url) return "";
|
|
42
|
+
export function resolveMedia(url: unknown): string {
|
|
43
|
+
if (!url || typeof url !== "string") return "";
|
|
44
44
|
// Absolute URLs, blob URLs, and data URLs are returned as-is
|
|
45
45
|
if (url.startsWith("http") || url.startsWith("blob:") || url.startsWith("data:")) {
|
|
46
46
|
return url;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively resolves a field value from formData regardless of
|
|
3
|
+
* whether it lives at root level, inside a tab container, group,
|
|
4
|
+
* or collapsible.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the `useAsTitle` pattern — finds where a field actually
|
|
7
|
+
* lives in the config, then reads from the correct location in formData.
|
|
8
|
+
*
|
|
9
|
+
* @param fields - The top-level field definitions from the collection/global config
|
|
10
|
+
* @param formData - The current form data object
|
|
11
|
+
* @param fieldName - The name of the field to resolve
|
|
12
|
+
* @returns The field value, or undefined if not found
|
|
13
|
+
*/
|
|
14
|
+
export function resolveFieldValue(
|
|
15
|
+
fields: Record<string, unknown>[],
|
|
16
|
+
formData: Record<string, unknown>,
|
|
17
|
+
fieldName: string,
|
|
18
|
+
): unknown {
|
|
19
|
+
// 1. Check root level first
|
|
20
|
+
if (fieldName in formData) {
|
|
21
|
+
return formData[fieldName];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. Search inside containers (tabs, groups, collapsibles)
|
|
25
|
+
return searchContainers(fields, formData, fieldName);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function searchContainers(
|
|
29
|
+
fields: Record<string, unknown>[],
|
|
30
|
+
formData: Record<string, unknown>,
|
|
31
|
+
fieldName: string,
|
|
32
|
+
): unknown {
|
|
33
|
+
for (const field of fields) {
|
|
34
|
+
if (!field.name) continue;
|
|
35
|
+
|
|
36
|
+
// Tabs container
|
|
37
|
+
if (field.type === "tabs" && "tabs" in field) {
|
|
38
|
+
const containerData = formData[field.name as string];
|
|
39
|
+
if (containerData && typeof containerData === "object") {
|
|
40
|
+
// Check if the field is directly inside the tab data
|
|
41
|
+
if (fieldName in (containerData as Record<string, unknown>)) {
|
|
42
|
+
return (containerData as Record<string, unknown>)[fieldName];
|
|
43
|
+
}
|
|
44
|
+
// Recursively search nested tabs/groups inside this tab container
|
|
45
|
+
const nested = searchContainers(
|
|
46
|
+
(field as any).tabs?.flatMap((t: any) => t.fields || []) || [],
|
|
47
|
+
containerData as Record<string, unknown>,
|
|
48
|
+
fieldName,
|
|
49
|
+
);
|
|
50
|
+
if (nested !== undefined) return nested;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Group or collapsible container
|
|
55
|
+
if ((field.type === "group" || field.type === "collapsible") && "fields" in field) {
|
|
56
|
+
const containerData = formData[field.name as string];
|
|
57
|
+
if (containerData && typeof containerData === "object") {
|
|
58
|
+
// Check if the field is directly inside the group data
|
|
59
|
+
if (fieldName in (containerData as Record<string, unknown>)) {
|
|
60
|
+
return (containerData as Record<string, unknown>)[fieldName];
|
|
61
|
+
}
|
|
62
|
+
// Recursively search nested fields inside this group
|
|
63
|
+
const nested = searchContainers(
|
|
64
|
+
(field as any).fields || [],
|
|
65
|
+
containerData as Record<string, unknown>,
|
|
66
|
+
fieldName,
|
|
67
|
+
);
|
|
68
|
+
if (nested !== undefined) return nested;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Finds the container path for a field (useful for debugging).
|
|
78
|
+
* Returns an array of container names leading to the field, or [] if at root.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveFieldPath(
|
|
81
|
+
fields: Record<string, unknown>[],
|
|
82
|
+
fieldName: string,
|
|
83
|
+
prefix: string = "",
|
|
84
|
+
): string[] {
|
|
85
|
+
for (const field of fields) {
|
|
86
|
+
if (!field.name) continue;
|
|
87
|
+
|
|
88
|
+
if (field.type === "tabs" && "tabs" in field) {
|
|
89
|
+
const tabs = (field as any).tabs || [];
|
|
90
|
+
for (const tab of tabs) {
|
|
91
|
+
if (tab.fields) {
|
|
92
|
+
const found = tab.fields.find((f: any) => f.name === fieldName);
|
|
93
|
+
if (found) return [field.name as string];
|
|
94
|
+
const nested = resolveFieldPath(tab.fields, fieldName, field.name as string);
|
|
95
|
+
if (nested.length > 0) return [field.name as string, ...nested];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ((field.type === "group" || field.type === "collapsible") && "fields" in field) {
|
|
101
|
+
const groupFields = (field as any).fields || [];
|
|
102
|
+
const found = groupFields.find((f: any) => f.name === fieldName);
|
|
103
|
+
if (found) return [field.name as string];
|
|
104
|
+
const nested = resolveFieldPath(groupFields, fieldName, field.name as string);
|
|
105
|
+
if (nested.length > 0) return [field.name as string, ...nested];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return [];
|
|
110
|
+
}
|