@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.
Files changed (114) hide show
  1. package/dist/index.cjs +11715 -11292
  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 +564 -0
  6. package/dist/index.d.ts +11 -10
  7. package/dist/index.js +11326 -10912
  8. package/dist/index.js.map +1 -1
  9. package/package.json +16 -12
  10. package/src/components/ActionBar.tsx +25 -161
  11. package/src/components/Admin.tsx +2 -4
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AuditLogsPage.tsx +2 -13
  14. package/src/components/AutoForm.tsx +572 -461
  15. package/src/components/BrandingHub.tsx +7 -4
  16. package/src/components/CreateView.tsx +2 -0
  17. package/src/components/DetailView.tsx +52 -65
  18. package/src/components/DeveloperCenter.tsx +8 -6
  19. package/src/components/FieldRenderer.tsx +94 -19
  20. package/src/components/ListView.tsx +57 -216
  21. package/src/components/MediaGallery.tsx +334 -367
  22. package/src/components/PluginsManager.tsx +197 -70
  23. package/src/components/RestPlayground.tsx +59 -52
  24. package/src/components/SessionsManager.tsx +1 -1
  25. package/src/components/SettingsPage.tsx +22 -0
  26. package/src/components/Sidebar.astro +13 -41
  27. package/src/components/UserManagement.tsx +153 -15
  28. package/src/components/UserMenu.tsx +30 -4
  29. package/src/components/VersionHistoryPanel.tsx +112 -119
  30. package/src/components/WebhookManager.tsx +6 -4
  31. package/src/components/blocks/ArrayBlock.tsx +6 -23
  32. package/src/components/blocks/BlockEditModal.tsx +82 -309
  33. package/src/components/blocks/CardBlock.tsx +35 -0
  34. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  35. package/src/components/blocks/GenericBlock.tsx +44 -0
  36. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  37. package/src/components/blocks/HeroBlock.tsx +5 -14
  38. package/src/components/blocks/RichTextBlock.tsx +5 -5
  39. package/src/components/blocks/index.ts +5 -3
  40. package/src/components/fields/AccordionField.tsx +2 -2
  41. package/src/components/fields/ArrayField.tsx +1 -1
  42. package/src/components/fields/ArrayLayout.tsx +120 -29
  43. package/src/components/fields/BlocksField.tsx +433 -55
  44. package/src/components/fields/CardField.tsx +73 -0
  45. package/src/components/fields/CheckboxField.tsx +7 -3
  46. package/src/components/fields/DateField.tsx +4 -1
  47. package/src/components/fields/GroupLayout.tsx +2 -2
  48. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  49. package/src/components/fields/ListField.tsx +2 -2
  50. package/src/components/fields/NumberField.tsx +4 -1
  51. package/src/components/fields/RelationshipBlockField.tsx +2 -3
  52. package/src/components/fields/RelationshipField.tsx +155 -90
  53. package/src/components/fields/RichTextField.tsx +781 -0
  54. package/src/components/fields/SecretField.tsx +102 -0
  55. package/src/components/fields/SelectField.tsx +19 -6
  56. package/src/components/fields/TabsLayout.tsx +19 -9
  57. package/src/components/fields/TextField.tsx +4 -1
  58. package/src/components/fields/UploadField.tsx +122 -56
  59. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  60. package/src/components/fields/extensions/blocksStore.ts +8 -1
  61. package/src/components/fields/index.ts +4 -2
  62. package/src/components/fix_imports.cjs +23 -0
  63. package/src/components/fix_imports2.cjs +19 -0
  64. package/src/components/replace_svgs.cjs +63 -0
  65. package/src/components/ui/Dropdown.tsx +7 -2
  66. package/src/components/ui/Modal.tsx +24 -27
  67. package/src/components/ui/PageHeader.tsx +5 -5
  68. package/src/components/ui/PromptModal.tsx +2 -10
  69. package/src/components/ui/SlidePanel.tsx +10 -13
  70. package/src/components/ui/SplitButton.tsx +107 -0
  71. package/src/components/ui/Toaster.tsx +0 -1
  72. package/src/components/ui/icons.tsx +110 -109
  73. package/src/components/users/UserDetail.tsx +79 -16
  74. package/src/components/users/UsersList.tsx +8 -85
  75. package/src/hooks/useAutoFormState.ts +187 -196
  76. package/src/hooks/useQueue.ts +60 -0
  77. package/src/integration.ts +148 -46
  78. package/src/kyro-cms.d.ts +7 -2
  79. package/src/layouts/AdminLayout.astro +22 -2
  80. package/src/layouts/AuthLayout.astro +67 -7
  81. package/src/lib/autoform-store.ts +90 -53
  82. package/src/lib/change-source.ts +9 -0
  83. package/src/lib/config.ts +104 -8
  84. package/src/lib/globals.ts +48 -11
  85. package/src/lib/normalize-upload-fields.ts +41 -0
  86. package/src/lib/paths.ts +2 -2
  87. package/src/lib/resolve-field-value.ts +110 -0
  88. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  89. package/src/lib/shim/use-sync-external-store.js +1 -0
  90. package/src/lib/stores/index.ts +1 -0
  91. package/src/lib/useResourceManager.ts +4 -4
  92. package/src/lib/vite-shim-plugin.ts +100 -0
  93. package/src/pages/[collection]/[id].astro +1 -1
  94. package/src/pages/auth/register.astro +5 -2
  95. package/src/pages/preview/[collection]/[id].astro +4 -4
  96. package/src/pages/settings/[slug].astro +2 -2
  97. package/src/styles/main.css +60 -54
  98. package/README.md +0 -46
  99. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  100. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  101. package/dist/EditorClient-T5PASFNR.js +0 -466
  102. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  103. package/dist/chunk-3BGDYKTD.cjs +0 -348
  104. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  105. package/dist/chunk-EEFXLQVT.js +0 -3
  106. package/dist/chunk-EEFXLQVT.js.map +0 -1
  107. package/src/components/blocks/ButtonBlock.tsx +0 -64
  108. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  109. package/src/components/blocks/DividerBlock.tsx +0 -43
  110. package/src/components/blocks/LinkBlock.tsx +0 -65
  111. package/src/components/blocks/VStackBlock.tsx +0 -29
  112. package/src/components/fields/EditorClient.tsx +0 -535
  113. package/src/components/fields/PortableTextField.tsx +0 -155
  114. 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
- isAutoSaving: boolean;
91
- autoSaveStatus: "idle" | "saving" | "success" | "error" | "conflict";
95
+ isAutoSaving: boolean;
96
+ autoSaveStatus: AutoSaveStatus;
97
+ backgroundProcessing: boolean;
92
98
 
93
99
  // Auto-save
94
- lastAutoSaveTime: number;
95
- autoSaveSkip: boolean;
96
- autoSaveTimer: NodeJS.Timeout | null;
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: "idle" | "saving" | "success" | "error" | "conflict") => void;
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
- if (field === "blocks") {
189
- console.log("autoform setField blocks: value=", value);
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((state) => ({
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({ lastSavedData: formData, hasUnsavedChanges: false });
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
- const { formData, lastSavedData } = get();
365
- return JSON.stringify(formData) !== JSON.stringify(lastSavedData);
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
- }
@@ -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,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
- // Strategy 1: Use the API endpoint (works for all dialects, adapter agnostic)
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
- if (typeof doc[field] === "string" && doc[field].length > 0) {
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/${doc[field]}`, {
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 ID string */ }
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 2: Direct adapter access (fallback for non-request contexts)
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: options?.draft ?? false,
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
- if (typeof doc[field] === "string" && doc[field].length > 0) {
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: doc[field],
110
+ id,
74
111
  });
75
112
  if (mediaDoc) doc[field] = mediaDoc;
76
- } catch { /* media field stays as ID string */ }
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: 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
+ }