@kyro-cms/admin 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +11715 -11292
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +67 -65
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +564 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.js +11326 -10912
- package/dist/index.js.map +1 -1
- package/package.json +16 -12
- package/src/components/ActionBar.tsx +25 -161
- package/src/components/Admin.tsx +2 -4
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AuditLogsPage.tsx +2 -13
- package/src/components/AutoForm.tsx +572 -461
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +52 -65
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +57 -216
- package/src/components/MediaGallery.tsx +334 -367
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +59 -52
- package/src/components/SessionsManager.tsx +1 -1
- package/src/components/SettingsPage.tsx +22 -0
- package/src/components/Sidebar.astro +13 -41
- package/src/components/UserManagement.tsx +153 -15
- package/src/components/UserMenu.tsx +30 -4
- package/src/components/VersionHistoryPanel.tsx +112 -119
- package/src/components/WebhookManager.tsx +6 -4
- package/src/components/blocks/ArrayBlock.tsx +6 -23
- package/src/components/blocks/BlockEditModal.tsx +82 -309
- package/src/components/blocks/CardBlock.tsx +35 -0
- package/src/components/blocks/ChildBlocksTree.tsx +57 -31
- package/src/components/blocks/GenericBlock.tsx +44 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
- package/src/components/blocks/HeroBlock.tsx +5 -14
- package/src/components/blocks/RichTextBlock.tsx +5 -5
- package/src/components/blocks/index.ts +5 -3
- package/src/components/fields/AccordionField.tsx +2 -2
- package/src/components/fields/ArrayField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +120 -29
- package/src/components/fields/BlocksField.tsx +433 -55
- package/src/components/fields/CardField.tsx +73 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/DateField.tsx +4 -1
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/HeadingSubheadingField.tsx +43 -0
- package/src/components/fields/ListField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -1
- package/src/components/fields/RelationshipBlockField.tsx +2 -3
- package/src/components/fields/RelationshipField.tsx +155 -90
- package/src/components/fields/RichTextField.tsx +781 -0
- package/src/components/fields/SecretField.tsx +102 -0
- package/src/components/fields/SelectField.tsx +19 -6
- package/src/components/fields/TabsLayout.tsx +19 -9
- package/src/components/fields/TextField.tsx +4 -1
- package/src/components/fields/UploadField.tsx +122 -56
- package/src/components/fields/extensions/blockComponents.tsx +103 -174
- package/src/components/fields/extensions/blocksStore.ts +8 -1
- package/src/components/fields/index.ts +4 -2
- package/src/components/fix_imports.cjs +23 -0
- package/src/components/fix_imports2.cjs +19 -0
- package/src/components/replace_svgs.cjs +63 -0
- package/src/components/ui/Dropdown.tsx +7 -2
- package/src/components/ui/Modal.tsx +24 -27
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/PromptModal.tsx +2 -10
- package/src/components/ui/SlidePanel.tsx +10 -13
- package/src/components/ui/SplitButton.tsx +107 -0
- package/src/components/ui/Toaster.tsx +0 -1
- package/src/components/ui/icons.tsx +110 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/components/users/UsersList.tsx +8 -85
- package/src/hooks/useAutoFormState.ts +187 -196
- package/src/hooks/useQueue.ts +60 -0
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AdminLayout.astro +22 -2
- package/src/layouts/AuthLayout.astro +67 -7
- package/src/lib/autoform-store.ts +90 -53
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +48 -11
- package/src/lib/normalize-upload-fields.ts +41 -0
- package/src/lib/paths.ts +2 -2
- package/src/lib/resolve-field-value.ts +110 -0
- package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
- package/src/lib/shim/use-sync-external-store.js +1 -0
- package/src/lib/stores/index.ts +1 -0
- package/src/lib/useResourceManager.ts +4 -4
- package/src/lib/vite-shim-plugin.ts +100 -0
- package/src/pages/[collection]/[id].astro +1 -1
- package/src/pages/auth/register.astro +5 -2
- package/src/pages/preview/[collection]/[id].astro +4 -4
- package/src/pages/settings/[slug].astro +2 -2
- package/src/styles/main.css +60 -54
- package/README.md +0 -46
- package/dist/EditorClient-Q23UXR37.cjs +0 -468
- package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
- package/dist/EditorClient-T5PASFNR.js +0 -466
- package/dist/EditorClient-T5PASFNR.js.map +0 -1
- package/dist/chunk-3BGDYKTD.cjs +0 -348
- package/dist/chunk-3BGDYKTD.cjs.map +0 -1
- package/dist/chunk-EEFXLQVT.js +0 -3
- package/dist/chunk-EEFXLQVT.js.map +0 -1
- package/src/components/blocks/ButtonBlock.tsx +0 -64
- package/src/components/blocks/ColumnsBlock.tsx +0 -55
- package/src/components/blocks/DividerBlock.tsx +0 -43
- package/src/components/blocks/LinkBlock.tsx +0 -65
- package/src/components/blocks/VStackBlock.tsx +0 -29
- package/src/components/fields/EditorClient.tsx +0 -535
- package/src/components/fields/PortableTextField.tsx +0 -155
- package/src/components/fields/PortableTextRenderer.tsx +0 -68
|
@@ -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;
|
|
@@ -87,14 +92,16 @@ interface AutoFormStore {
|
|
|
87
92
|
compareSelected: string[];
|
|
88
93
|
compareDiffs: VersionDiff[];
|
|
89
94
|
loadingDiffs: boolean;
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
isAutoSaving: boolean;
|
|
96
|
+
autoSaveStatus: AutoSaveStatus;
|
|
97
|
+
backgroundProcessing: boolean;
|
|
92
98
|
|
|
93
99
|
// Auto-save
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
lastAutoSaveTime: number;
|
|
101
|
+
lastSavedAt: number | null;
|
|
102
|
+
retryCount: number;
|
|
103
|
+
autoSaveSkip: boolean;
|
|
104
|
+
autoSaveTimer: NodeJS.Timeout | null;
|
|
98
105
|
// Actions - Field Updates
|
|
99
106
|
setField: (field: string, value: unknown) => void;
|
|
100
107
|
setFormData: (data: Record<string, unknown>) => void;
|
|
@@ -118,8 +125,11 @@ interface AutoFormStore {
|
|
|
118
125
|
setCompareDiffs: (diffs: VersionDiff[]) => void;
|
|
119
126
|
setLoadingDiffs: (loading: boolean) => void;
|
|
120
127
|
setIsAutoSaving: (saving: boolean) => void;
|
|
121
|
-
setAutoSaveStatus: (status:
|
|
128
|
+
setAutoSaveStatus: (status: AutoSaveStatus) => void;
|
|
129
|
+
setBackgroundProcessing: (processing: boolean) => void;
|
|
122
130
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
131
|
+
setLastSavedAt: (time: number | null) => void;
|
|
132
|
+
setRetryCount: (count: number) => void;
|
|
123
133
|
|
|
124
134
|
// Auto-save actions
|
|
125
135
|
setAutoSaveSkip: (skip: boolean) => void;
|
|
@@ -127,6 +137,7 @@ interface AutoFormStore {
|
|
|
127
137
|
startAutoSaveTimer: (callback: () => void, delay: number) => void;
|
|
128
138
|
clearAutoSaveTimer: () => void;
|
|
129
139
|
|
|
140
|
+
|
|
130
141
|
// Actions - Data Management
|
|
131
142
|
markSaved: () => void;
|
|
132
143
|
resetForm: () => void;
|
|
@@ -135,10 +146,13 @@ loadDocument: (
|
|
|
135
146
|
lastSaved?: Record<string, unknown>,
|
|
136
147
|
) => void;
|
|
137
148
|
|
|
138
|
-
updateTabData: (tabName: string, newTabData: Record<string, unknown>) => void;
|
|
139
149
|
getField: (field: string) => unknown;
|
|
140
150
|
getNestedField: (path: string) => unknown;
|
|
141
151
|
getHasChanges: () => boolean;
|
|
152
|
+
hasDirtyFields: () => boolean;
|
|
153
|
+
getDirtyData: () => Record<string, unknown>;
|
|
154
|
+
clearDirtyFields: () => void;
|
|
155
|
+
pruneExpiredDrafts: () => void;
|
|
142
156
|
setDraftCache: (documentKey: string, draft: BrowserDraftCacheEntry) => void;
|
|
143
157
|
getDraftCache: (documentKey: string) => BrowserDraftCacheEntry | null;
|
|
144
158
|
clearDraftCache: (documentKey: string) => void;
|
|
@@ -160,6 +174,9 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
160
174
|
sidebarCollapsed: false,
|
|
161
175
|
draftCache: {},
|
|
162
176
|
|
|
177
|
+
// Dirty field tracking
|
|
178
|
+
dirtyFields: new Set<string>(),
|
|
179
|
+
|
|
163
180
|
// Initial UI state
|
|
164
181
|
activeTab: 0,
|
|
165
182
|
isSlugLocked: true,
|
|
@@ -176,24 +193,35 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
176
193
|
compareDiffs: [],
|
|
177
194
|
loadingDiffs: false,
|
|
178
195
|
isAutoSaving: false,
|
|
179
|
-
autoSaveStatus: "idle",
|
|
196
|
+
autoSaveStatus: "idle" as AutoSaveStatus,
|
|
197
|
+
backgroundProcessing: false,
|
|
180
198
|
|
|
181
199
|
// Auto-save state
|
|
182
200
|
lastAutoSaveTime: 0,
|
|
201
|
+
lastSavedAt: null,
|
|
202
|
+
retryCount: 0,
|
|
183
203
|
autoSaveSkip: false,
|
|
184
204
|
autoSaveTimer: null,
|
|
185
205
|
|
|
206
|
+
|
|
186
207
|
// Field update actions
|
|
187
208
|
setField: (field: string, value: unknown) => {
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
const state = get();
|
|
210
|
+
const newDirty = new Set(state.dirtyFields);
|
|
211
|
+
// Mark dirty if value differs from last saved baseline
|
|
212
|
+
if (JSON.stringify(value) !== JSON.stringify(state.lastSavedData[field])) {
|
|
213
|
+
newDirty.add(field);
|
|
214
|
+
} else {
|
|
215
|
+
newDirty.delete(field);
|
|
190
216
|
}
|
|
191
|
-
set(
|
|
217
|
+
set({
|
|
192
218
|
formData: {
|
|
193
219
|
...state.formData,
|
|
194
220
|
[field]: value,
|
|
195
221
|
},
|
|
196
|
-
|
|
222
|
+
dirtyFields: newDirty,
|
|
223
|
+
hasUnsavedChanges: newDirty.size > 0,
|
|
224
|
+
});
|
|
197
225
|
},
|
|
198
226
|
|
|
199
227
|
setFormData: (data: Record<string, unknown>) => {
|
|
@@ -267,9 +295,12 @@ setField: (field: string, value: unknown) => {
|
|
|
267
295
|
setCompareDiffs: (diffs: VersionDiff[]) => set({ compareDiffs: diffs }),
|
|
268
296
|
setLoadingDiffs: (loading: boolean) => set({ loadingDiffs: loading }),
|
|
269
297
|
setIsAutoSaving: (saving: boolean) => set({ isAutoSaving: saving }),
|
|
270
|
-
setAutoSaveStatus: (status) => set({ autoSaveStatus: status }),
|
|
298
|
+
setAutoSaveStatus: (status: AutoSaveStatus) => set({ autoSaveStatus: status }),
|
|
299
|
+
setBackgroundProcessing: (processing: boolean) => set({ backgroundProcessing: processing }),
|
|
271
300
|
setSidebarCollapsed: (collapsed: boolean) =>
|
|
272
301
|
set({ sidebarCollapsed: collapsed }),
|
|
302
|
+
setLastSavedAt: (time: number | null) => set({ lastSavedAt: time }),
|
|
303
|
+
setRetryCount: (count: number) => set({ retryCount: count }),
|
|
273
304
|
|
|
274
305
|
// Auto-save actions
|
|
275
306
|
setAutoSaveSkip: (skip: boolean) => set({ autoSaveSkip: skip }),
|
|
@@ -295,7 +326,12 @@ setField: (field: string, value: unknown) => {
|
|
|
295
326
|
// Data management
|
|
296
327
|
markSaved: () => {
|
|
297
328
|
const { formData } = get();
|
|
298
|
-
set({
|
|
329
|
+
set({
|
|
330
|
+
lastSavedData: formData,
|
|
331
|
+
hasUnsavedChanges: false,
|
|
332
|
+
dirtyFields: new Set<string>(),
|
|
333
|
+
lastSavedAt: Date.now(),
|
|
334
|
+
});
|
|
299
335
|
},
|
|
300
336
|
|
|
301
337
|
setLastSavedData: (data: Record<string, unknown>) => {
|
|
@@ -307,6 +343,7 @@ setField: (field: string, value: unknown) => {
|
|
|
307
343
|
formData: {},
|
|
308
344
|
lastSavedData: {},
|
|
309
345
|
hasUnsavedChanges: false,
|
|
346
|
+
dirtyFields: new Set<string>(),
|
|
310
347
|
activeTab: 0,
|
|
311
348
|
});
|
|
312
349
|
},
|
|
@@ -319,30 +356,10 @@ setField: (field: string, value: unknown) => {
|
|
|
319
356
|
formData: data,
|
|
320
357
|
lastSavedData: lastSaved || data,
|
|
321
358
|
hasUnsavedChanges: false,
|
|
359
|
+
dirtyFields: new Set<string>(),
|
|
322
360
|
});
|
|
323
361
|
},
|
|
324
362
|
|
|
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
363
|
// Computed values
|
|
347
364
|
getField: (field: string) => {
|
|
348
365
|
return get().formData[field];
|
|
@@ -361,8 +378,37 @@ setField: (field: string, value: unknown) => {
|
|
|
361
378
|
},
|
|
362
379
|
|
|
363
380
|
getHasChanges: () => {
|
|
364
|
-
|
|
365
|
-
|
|
381
|
+
return get().dirtyFields.size > 0;
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
hasDirtyFields: () => {
|
|
385
|
+
return get().dirtyFields.size > 0;
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
getDirtyData: () => {
|
|
389
|
+
const { formData, dirtyFields } = get();
|
|
390
|
+
const delta: Record<string, unknown> = {};
|
|
391
|
+
for (const field of dirtyFields) {
|
|
392
|
+
delta[field] = formData[field];
|
|
393
|
+
}
|
|
394
|
+
return delta;
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
clearDirtyFields: () => {
|
|
398
|
+
set({ dirtyFields: new Set<string>(), hasUnsavedChanges: false });
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
pruneExpiredDrafts: () => {
|
|
402
|
+
const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
const { draftCache } = get();
|
|
405
|
+
const pruned: Record<string, BrowserDraftCacheEntry> = {};
|
|
406
|
+
for (const [key, entry] of Object.entries(draftCache)) {
|
|
407
|
+
if (now - new Date(entry.draftUpdatedAt).getTime() < DRAFT_TTL_MS) {
|
|
408
|
+
pruned[key] = entry;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
set({ draftCache: pruned });
|
|
366
412
|
},
|
|
367
413
|
|
|
368
414
|
setDraftCache: (documentKey, draft) =>
|
|
@@ -389,6 +435,12 @@ setField: (field: string, value: unknown) => {
|
|
|
389
435
|
sidebarCollapsed: state.sidebarCollapsed,
|
|
390
436
|
draftCache: state.draftCache,
|
|
391
437
|
}),
|
|
438
|
+
onRehydrateStorage: () => (state) => {
|
|
439
|
+
// Prune expired drafts on hydration
|
|
440
|
+
if (state) {
|
|
441
|
+
state.pruneExpiredDrafts();
|
|
442
|
+
}
|
|
443
|
+
},
|
|
392
444
|
},
|
|
393
445
|
),
|
|
394
446
|
);
|
|
@@ -418,18 +470,3 @@ export function useAutoFormField(
|
|
|
418
470
|
};
|
|
419
471
|
}
|
|
420
472
|
|
|
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,12 +12,45 @@ 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
|
-
|
|
15
|
+
const draft = options?.draft ?? !!options?.request;
|
|
16
|
+
|
|
17
|
+
// Strategy 1: Direct adapter access via shared kyro instance (SSR context)
|
|
18
|
+
// This avoids HTTP round-trips and auth cookie forwarding issues
|
|
19
|
+
const kyroInstance = (globalThis as any).__KYRO_INSTANCE__;
|
|
20
|
+
if (kyroInstance?.db) {
|
|
21
|
+
try {
|
|
22
|
+
const db = kyroInstance.db as BaseAdapter;
|
|
23
|
+
const doc = await db.findOne({
|
|
24
|
+
collection: `_globals_${slug}`,
|
|
25
|
+
where: {},
|
|
26
|
+
draft,
|
|
27
|
+
});
|
|
28
|
+
if (!doc) return null;
|
|
29
|
+
|
|
30
|
+
const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
|
|
31
|
+
for (const field of mediaFields) {
|
|
32
|
+
const val = doc[field];
|
|
33
|
+
const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
|
|
34
|
+
if (id) {
|
|
35
|
+
try {
|
|
36
|
+
const mediaDoc = await db.findByID({
|
|
37
|
+
collection: "media",
|
|
38
|
+
id,
|
|
39
|
+
});
|
|
40
|
+
if (mediaDoc) doc[field] = mediaDoc;
|
|
41
|
+
} catch { /* media field stays as-is */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return doc;
|
|
45
|
+
} catch { /* fall through to API strategy */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Strategy 2: Use the API endpoint (works for all dialects, adapter agnostic)
|
|
16
49
|
if (options?.request) {
|
|
17
50
|
try {
|
|
18
51
|
const apiPath = (globalThis as any).__KYRO_API_PATH__ || "/api";
|
|
19
52
|
const cookie = options.request.headers.get("cookie") || "";
|
|
20
|
-
const res = await fetch(`${apiPath}/globals/${slug}`, {
|
|
53
|
+
const res = await fetch(`${apiPath}/globals/${slug}${draft ? '?draft=true' : ''}`, {
|
|
21
54
|
headers: { Cookie: cookie },
|
|
22
55
|
});
|
|
23
56
|
if (res.ok) {
|
|
@@ -27,23 +60,25 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
|
|
|
27
60
|
// Resolve media fields via the same API endpoint
|
|
28
61
|
const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
|
|
29
62
|
for (const field of mediaFields) {
|
|
30
|
-
|
|
63
|
+
const val = doc[field];
|
|
64
|
+
const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
|
|
65
|
+
if (id) {
|
|
31
66
|
try {
|
|
32
|
-
const mediaRes = await fetch(`${apiPath}/media/${
|
|
67
|
+
const mediaRes = await fetch(`${apiPath}/media/${id}`, {
|
|
33
68
|
headers: { Cookie: cookie },
|
|
34
69
|
});
|
|
35
70
|
if (mediaRes.ok) {
|
|
36
71
|
doc[field] = await mediaRes.json();
|
|
37
72
|
}
|
|
38
|
-
} catch { /* media field stays as
|
|
73
|
+
} catch { /* media field stays as-is */ }
|
|
39
74
|
}
|
|
40
75
|
}
|
|
41
76
|
return doc;
|
|
42
77
|
}
|
|
43
|
-
} catch { /* fall through to adapter */ }
|
|
78
|
+
} catch { /* fall through to legacy adapter */ }
|
|
44
79
|
}
|
|
45
80
|
|
|
46
|
-
// Strategy
|
|
81
|
+
// Strategy 3: Legacy direct adapter access (fallback for non-request contexts)
|
|
47
82
|
const global = globalThis as any;
|
|
48
83
|
const projectConfig = global.__KYRO_ADMIN_PROJECT_CONFIG__;
|
|
49
84
|
if (!projectConfig) return null;
|
|
@@ -60,20 +95,22 @@ export async function getGlobal(slug: string, options?: GlobalOptions) {
|
|
|
60
95
|
const doc = await db.findOne({
|
|
61
96
|
collection: `_globals_${slug}`,
|
|
62
97
|
where: {},
|
|
63
|
-
draft
|
|
98
|
+
draft,
|
|
64
99
|
});
|
|
65
100
|
if (!doc) return null;
|
|
66
101
|
|
|
67
102
|
const mediaFields = ["siteLogo", "siteFavicon", "siteOgImage"];
|
|
68
103
|
for (const field of mediaFields) {
|
|
69
|
-
|
|
104
|
+
const val = doc[field];
|
|
105
|
+
const id = typeof val === "string" ? val : (val && typeof val === "object" && typeof val.id === "string" ? val.id : null);
|
|
106
|
+
if (id) {
|
|
70
107
|
try {
|
|
71
108
|
const mediaDoc = await db.findByID({
|
|
72
109
|
collection: "media",
|
|
73
|
-
id
|
|
110
|
+
id,
|
|
74
111
|
});
|
|
75
112
|
if (mediaDoc) doc[field] = mediaDoc;
|
|
76
|
-
} catch { /* media field stays as
|
|
113
|
+
} catch { /* media field stays as-is */ }
|
|
77
114
|
}
|
|
78
115
|
}
|
|
79
116
|
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
|
+
}
|