@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.
Files changed (100) hide show
  1. package/dist/index.cjs +11960 -11006
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +563 -0
  6. package/dist/index.d.ts +7 -7
  7. package/dist/index.js +12183 -11238
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -11
  10. package/src/components/ActionBar.tsx +27 -14
  11. package/src/components/Admin.tsx +1 -1
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AutoForm.tsx +585 -369
  14. package/src/components/BrandingHub.tsx +7 -4
  15. package/src/components/CreateView.tsx +2 -0
  16. package/src/components/DetailView.tsx +71 -56
  17. package/src/components/DeveloperCenter.tsx +8 -6
  18. package/src/components/FieldRenderer.tsx +94 -19
  19. package/src/components/ListView.tsx +33 -20
  20. package/src/components/MediaGallery.tsx +219 -194
  21. package/src/components/PluginsManager.tsx +197 -70
  22. package/src/components/RestPlayground.tsx +7 -7
  23. package/src/components/SessionsManager.tsx +1 -1
  24. package/src/components/SettingsPage.tsx +22 -0
  25. package/src/components/Sidebar.astro +13 -41
  26. package/src/components/UserManagement.tsx +153 -15
  27. package/src/components/UserMenu.tsx +30 -4
  28. package/src/components/VersionHistoryPanel.tsx +112 -119
  29. package/src/components/WebhookManager.tsx +6 -4
  30. package/src/components/blocks/ArrayBlock.tsx +6 -23
  31. package/src/components/blocks/BlockEditModal.tsx +82 -309
  32. package/src/components/blocks/CardBlock.tsx +35 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  34. package/src/components/blocks/GenericBlock.tsx +44 -0
  35. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  36. package/src/components/blocks/HeroBlock.tsx +5 -14
  37. package/src/components/blocks/RichTextBlock.tsx +5 -5
  38. package/src/components/blocks/index.ts +5 -3
  39. package/src/components/fields/AccordionField.tsx +2 -2
  40. package/src/components/fields/ArrayField.tsx +1 -1
  41. package/src/components/fields/ArrayLayout.tsx +120 -29
  42. package/src/components/fields/BlocksField.tsx +430 -50
  43. package/src/components/fields/CardField.tsx +73 -0
  44. package/src/components/fields/CheckboxField.tsx +7 -3
  45. package/src/components/fields/DateField.tsx +4 -1
  46. package/src/components/fields/GroupLayout.tsx +2 -2
  47. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  48. package/src/components/fields/ListField.tsx +2 -2
  49. package/src/components/fields/NumberField.tsx +4 -1
  50. package/src/components/fields/RelationshipField.tsx +153 -87
  51. package/src/components/fields/RichTextField.tsx +781 -0
  52. package/src/components/fields/SecretField.tsx +102 -0
  53. package/src/components/fields/SelectField.tsx +19 -6
  54. package/src/components/fields/TabsLayout.tsx +19 -9
  55. package/src/components/fields/TextField.tsx +4 -1
  56. package/src/components/fields/UploadField.tsx +122 -56
  57. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  58. package/src/components/fields/extensions/blocksStore.ts +8 -1
  59. package/src/components/fields/index.ts +4 -2
  60. package/src/components/ui/PageHeader.tsx +5 -5
  61. package/src/components/ui/SlidePanel.tsx +8 -3
  62. package/src/components/ui/icons.tsx +109 -109
  63. package/src/components/users/UserDetail.tsx +79 -16
  64. package/src/hooks/useAutoFormState.ts +125 -62
  65. package/src/integration.ts +148 -46
  66. package/src/kyro-cms.d.ts +7 -2
  67. package/src/layouts/AuthLayout.astro +14 -2
  68. package/src/lib/autoform-store.ts +85 -52
  69. package/src/lib/change-source.ts +9 -0
  70. package/src/lib/config.ts +104 -8
  71. package/src/lib/globals.ts +44 -9
  72. package/src/lib/normalize-upload-fields.ts +41 -0
  73. package/src/lib/paths.ts +2 -2
  74. package/src/lib/resolve-field-value.ts +110 -0
  75. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  76. package/src/lib/shim/use-sync-external-store.js +1 -0
  77. package/src/lib/stores/index.ts +1 -0
  78. package/src/lib/useResourceManager.ts +4 -4
  79. package/src/lib/vite-shim-plugin.ts +100 -0
  80. package/src/pages/[collection]/[id].astro +1 -1
  81. package/src/pages/preview/[collection]/[id].astro +4 -4
  82. package/src/pages/settings/[slug].astro +2 -2
  83. package/src/styles/main.css +60 -54
  84. package/README.md +0 -46
  85. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  86. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  87. package/dist/EditorClient-T5PASFNR.js +0 -466
  88. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  89. package/dist/chunk-3BGDYKTD.cjs +0 -348
  90. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  91. package/dist/chunk-EEFXLQVT.js +0 -3
  92. package/dist/chunk-EEFXLQVT.js.map +0 -1
  93. package/src/components/blocks/ButtonBlock.tsx +0 -64
  94. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  95. package/src/components/blocks/DividerBlock.tsx +0 -43
  96. package/src/components/blocks/LinkBlock.tsx +0 -65
  97. package/src/components/blocks/VStackBlock.tsx +0 -29
  98. package/src/components/fields/EditorClient.tsx +0 -535
  99. package/src/components/fields/PortableTextField.tsx +0 -155
  100. 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: "idle" | "saving" | "success" | "error" | "conflict";
96
+ autoSaveStatus: AutoSaveStatus;
92
97
 
93
98
  // Auto-save
94
- lastAutoSaveTime: number;
95
- autoSaveSkip: boolean;
96
- autoSaveTimer: NodeJS.Timeout | null;
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: "idle" | "saving" | "success" | "error" | "conflict") => void;
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
- if (field === "blocks") {
189
- console.log("autoform setField blocks: value=", value);
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((state) => ({
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({ lastSavedData: formData, hasUnsavedChanges: false });
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
- const { formData, lastSavedData } = get();
365
- return JSON.stringify(formData) !== JSON.stringify(lastSavedData);
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
- }
@@ -0,0 +1,9 @@
1
+ let _lastChangeSource: "keystroke" | "other" = "other";
2
+
3
+ export function getLastChangeSource(): "keystroke" | "other" {
4
+ return _lastChangeSource;
5
+ }
6
+
7
+ export function setChangeSource(source: "keystroke" | "other"): void {
8
+ _lastChangeSource = source;
9
+ }
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
- // Default to kitchen-sink during module evaluation (config loading phase).
130
- // The real config is set later by the admin integration via globalThis.
131
- const global = globalThis as any;
132
- const projectConfig: { collections?: ConfigCollectionInput; globals?: ConfigGlobalInput } =
133
- global.__KYRO_ADMIN_PROJECT_CONFIG__ || {};
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 = createProjectAdminConfig(projectConfig);
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
  );
@@ -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: Use the API endpoint (works for all dialects, adapter agnostic)
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
- if (typeof doc[field] === "string" && doc[field].length > 0) {
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/${doc[field]}`, {
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 ID string */ }
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 2: Direct adapter access (fallback for non-request contexts)
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
- if (typeof doc[field] === "string" && doc[field].length > 0) {
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: doc[field],
108
+ id,
74
109
  });
75
110
  if (mediaDoc) doc[field] = mediaDoc;
76
- } catch { /* media field stays as ID string */ }
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: string): string {
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
+ }