@kyro-cms/admin 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -18,14 +18,14 @@ import TextField from "./fields/TextField";
18
18
  import { globals, collections } from "../lib/config";
19
19
  import { slugifyText } from "../lib/slugify";
20
20
  import { resolveUrl, apiDelete, fetchWithAuth } from "../lib/api";
21
+ import { normalizeUploadFields } from "../lib/normalize-upload-fields";
21
22
  import { useAutoFormStore } from "../lib/autoform-store";
22
23
  import { useAutoFormState } from "../hooks/useAutoFormState";
23
- import { useUIStore } from "../lib/stores";
24
+ import { useUIStore, toast } from "../lib/stores";
24
25
 
25
26
  import { adminPath as ADMIN_BASE, apiPath as API_BASE } from "../lib/paths";
26
27
 
27
28
  import { BlocksField } from "./fields/BlocksField";
28
- import PortableTextField from "./fields/PortableTextField";
29
29
  import { ConfirmModal, Modal as UIModal } from "./ui/Modal";
30
30
  import { ListField } from "./fields/ListField";
31
31
  import { RelationshipBlockField } from "./fields/RelationshipBlockField";
@@ -73,7 +73,7 @@ export function AutoForm({
73
73
  const config = activeConfig || propConfig;
74
74
 
75
75
 
76
- const { confirm, alert } = useUIStore();
76
+ const { confirm } = useUIStore();
77
77
 
78
78
  const {
79
79
  formData,
@@ -81,6 +81,8 @@ export function AutoForm({
81
81
  hasUnsavedChanges,
82
82
  isAutoSaving,
83
83
  autoSaveStatus,
84
+ lastSavedAt,
85
+ retryCount,
84
86
  sidebarCollapsed,
85
87
  setSidebarCollapsed,
86
88
  activeTab,
@@ -132,8 +134,28 @@ export function AutoForm({
132
134
  });
133
135
 
134
136
  const menuRef = useRef<HTMLDivElement>(null);
137
+ const scheduleRef = useRef<HTMLDivElement>(null);
138
+ const [showSchedulePicker, setShowSchedulePicker] = useState(false);
135
139
  const disabled = propDisabled;
136
140
 
141
+ const resolveAdminFlag = (
142
+ value: boolean | ((
143
+ data: Record<string, unknown>,
144
+ siblingData: Record<string, unknown>,
145
+ ) => boolean) | undefined,
146
+ currentData: Record<string, unknown>,
147
+ ): boolean => {
148
+ if (typeof value === "function") {
149
+ try {
150
+ return value(formData, currentData);
151
+ } catch (error) {
152
+ console.warn("Error evaluating admin runtime flag:", error);
153
+ return false;
154
+ }
155
+ }
156
+ return Boolean(value);
157
+ };
158
+
137
159
  const handleRestoreVersion = (versionId: string) => {
138
160
  confirm({
139
161
  title: "Restore Version",
@@ -161,17 +183,18 @@ export function AutoForm({
161
183
 
162
184
  const result = await resp.json();
163
185
  if (result.data) {
164
- setFormData(result.data);
165
- useAutoFormStore.getState().loadDocument(result.data, result.data);
186
+ const restoredData = { ...formData, ...result.data };
187
+ setFormData(restoredData);
188
+ useAutoFormStore.getState().loadDocument(restoredData, restoredData);
166
189
  onActionSuccess?.("Version restored successfully");
167
190
  fetchVersions();
168
191
  setView("edit");
169
192
  } else {
170
- alert({ title: "Error", message: result.error || "Failed to restore version" });
193
+ toast.error(result.error || "Failed to restore version");
171
194
  }
172
195
  } catch (err) {
173
196
  console.error("Failed to restore version:", err);
174
- alert({ title: "Error", message: "Failed to restore version" });
197
+ toast.error("Failed to restore version");
175
198
  }
176
199
  }
177
200
  });
@@ -208,13 +231,18 @@ export function AutoForm({
208
231
 
209
232
  useEffect(() => {
210
233
  const handleShortcuts = (e: KeyboardEvent) => {
211
- // Cmd/Ctrl + S = Publish
234
+ // Cmd/Ctrl + S = Save Draft
212
235
  if ((e.metaKey || e.ctrlKey) && e.key === "s") {
213
236
  e.preventDefault();
214
- (document.getElementById("btn-save") as HTMLButtonElement | null)?.click();
237
+ handleSaveDraft();
238
+ }
239
+ // Cmd/Ctrl + Shift + P = Publish Changes
240
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "P" || e.key === "p")) {
241
+ e.preventDefault();
242
+ (document.getElementById("btn-publish") as HTMLButtonElement | null)?.click();
215
243
  }
216
- // Cmd/Ctrl + P = Toggle Preview
217
- if ((e.metaKey || e.ctrlKey) && e.key === "p") {
244
+ // Cmd/Ctrl + P (no shift) = Toggle Preview
245
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "p") {
218
246
  e.preventDefault();
219
247
  setShowPreview((prev) => !prev);
220
248
  }
@@ -248,17 +276,30 @@ export function AutoForm({
248
276
  if (isMenuOpen) {
249
277
  document.addEventListener("mousedown", handleClickOutside);
250
278
  return () =>
251
- document.addEventListener("mousedown", handleClickOutside);
279
+ document.removeEventListener("mousedown", handleClickOutside);
252
280
  }
253
281
  }, [isMenuOpen]);
254
282
 
283
+ useEffect(() => {
284
+ const handleClickOutside = (e: MouseEvent) => {
285
+ if (scheduleRef.current && !scheduleRef.current.contains(e.target as Node)) {
286
+ setShowSchedulePicker(false);
287
+ }
288
+ };
289
+ if (showSchedulePicker) {
290
+ document.addEventListener("mousedown", handleClickOutside);
291
+ return () =>
292
+ document.removeEventListener("mousedown", handleClickOutside);
293
+ }
294
+ }, [showSchedulePicker]);
295
+
255
296
  const handleCreateNew = () => {
256
297
  if (hasUnsavedChanges) {
257
298
  confirm({
258
299
  title: "Unsaved Changes",
259
300
  message: "You have unsaved changes. Save before creating new?",
260
301
  onConfirm: async () => {
261
- (document.getElementById("btn-save") as HTMLButtonElement | null)?.click();
302
+ await handleSaveDraft();
262
303
  await new Promise((r) => setTimeout(r, 1000));
263
304
  window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
264
305
  },
@@ -275,11 +316,12 @@ export function AutoForm({
275
316
  onConfirm: async () => {
276
317
  try {
277
318
  const { id, createdAt, updatedAt, ...duplicateData } = formData;
319
+ const normalizedData = normalizeUploadFields(duplicateData) as Record<string, unknown>;
278
320
  const response = await fetchWithAuth(`/api/${collectionSlug}`, {
279
321
  method: "POST",
280
322
  headers: { "Content-Type": "application/json" },
281
323
  body: JSON.stringify({
282
- ...duplicateData,
324
+ ...normalizedData,
283
325
  title: `${duplicateData.title || duplicateData.name || "Copy"} (Copy)`,
284
326
  slug: `${duplicateData.slug || "copy"}-${Date.now()}`,
285
327
  status: "draft",
@@ -294,16 +336,10 @@ export function AutoForm({
294
336
  }
295
337
  } else {
296
338
  const error = await response.json();
297
- alert({
298
- title: "Error",
299
- message: error.error || "Failed to duplicate document",
300
- });
339
+ toast.error(error.error || "Failed to duplicate document");
301
340
  }
302
341
  } catch (err) {
303
- alert({
304
- title: "Error",
305
- message: "Failed to duplicate document",
306
- });
342
+ toast.error("Failed to duplicate document");
307
343
  }
308
344
  },
309
345
  });
@@ -319,10 +355,7 @@ export function AutoForm({
319
355
  await apiDelete(`/api/${collectionSlug}/${formData.id}`);
320
356
  window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
321
357
  } catch (err) {
322
- alert({
323
- title: "Error",
324
- message: (err as Error).message || "Failed to delete document",
325
- });
358
+ toast.error((err as Error).message || "Failed to delete document");
326
359
  }
327
360
  },
328
361
  });
@@ -345,21 +378,183 @@ export function AutoForm({
345
378
  location.reload();
346
379
  } else {
347
380
  const error = await response.json();
348
- alert({
349
- title: "Error",
350
- message: error.error || "Failed to unpublish",
351
- });
381
+ toast.error(error.error || "Failed to unpublish");
352
382
  }
353
383
  } catch (err) {
354
- alert({
355
- title: "Error",
356
- message: "Failed to unpublish",
357
- });
384
+ toast.error("Failed to unpublish");
358
385
  }
359
386
  },
360
387
  });
361
388
  };
362
389
 
390
+ const handleSaveDraft = async () => {
391
+ const isNewDoc = !formData.id;
392
+ autoSaveSkipRef.current = true;
393
+
394
+ const btn = document.getElementById("btn-publish") as HTMLButtonElement | null;
395
+ const originalText = btn?.textContent || "";
396
+ if (btn) {
397
+ btn.textContent = "Saving...";
398
+ btn.setAttribute("disabled", "true");
399
+ }
400
+
401
+ try {
402
+ const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
403
+ const isPost = isNewDoc && !globalSlug;
404
+
405
+ const response = isPost
406
+ ? await fetchWithAuth(`/api/${collectionSlug}`, {
407
+ method: "POST",
408
+ headers: { "Content-Type": "application/json" },
409
+ body: JSON.stringify(data),
410
+ })
411
+ : await saveDocument(data);
412
+
413
+ if (response.ok) {
414
+ const result = await response.json();
415
+ const savedData = result.data || data;
416
+ setFormData({ ...formData, ...savedData });
417
+ setLastSavedData({ ...formData, ...savedData });
418
+ lastAutoSaveTimeRef.current = Date.now();
419
+ setAutoSaveStatus("success");
420
+ await clearDraftArtifacts();
421
+ if (versionsEnabled) fetchVersions();
422
+ setTimeout(() => setAutoSaveStatus("idle"), 5000);
423
+ onActionSuccess?.(
424
+ isPost ? "Document created successfully" : "Changes saved",
425
+ );
426
+ if (isPost) {
427
+ setTimeout(() => {
428
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
429
+ }, 800);
430
+ }
431
+ } else {
432
+ const error = await response.json();
433
+ if (response.status === 409) {
434
+ setAutoSaveStatus("conflict");
435
+ }
436
+ toast.error(error.error || "Failed to save");
437
+ }
438
+ } catch (err) {
439
+ toast.error("Failed to save document");
440
+ } finally {
441
+ autoSaveSkipRef.current = false;
442
+ if (btn) {
443
+ btn.textContent = originalText;
444
+ btn.removeAttribute("disabled");
445
+ }
446
+ }
447
+ };
448
+
449
+ const handlePublish = async () => {
450
+ const isNewDoc = !formData.id;
451
+ autoSaveSkipRef.current = true;
452
+
453
+ const btn = document.getElementById("btn-publish") as HTMLButtonElement | null;
454
+ const originalText = btn?.textContent || "";
455
+ if (btn) {
456
+ btn.textContent = "Publishing...";
457
+ btn.setAttribute("disabled", "true");
458
+ }
459
+
460
+ try {
461
+ // Step 1: Create or save the document
462
+ if (isNewDoc && !globalSlug) {
463
+ const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
464
+ const response = await fetchWithAuth(`/api/${collectionSlug}`, {
465
+ method: "POST",
466
+ headers: { "Content-Type": "application/json" },
467
+ body: JSON.stringify(data),
468
+ });
469
+ if (!response.ok) {
470
+ const error = await response.json().catch(() => ({}));
471
+ if (response.status === 409) setAutoSaveStatus("conflict");
472
+ toast.error(error.error || "Failed to create document");
473
+ return;
474
+ }
475
+ const result = await response.json();
476
+ const savedData = result.data || data;
477
+ setFormData({ ...formData, ...savedData });
478
+ setLastSavedData({ ...formData, ...savedData });
479
+ } else if (hasUnsavedChanges) {
480
+ const response = await saveDocument(formData);
481
+ if (!response.ok) {
482
+ const error = await response.json().catch(() => ({}));
483
+ if (response.status === 409) setAutoSaveStatus("conflict");
484
+ toast.error(error.error || "Failed to save before publishing");
485
+ return;
486
+ }
487
+ const result = await response.json();
488
+ if (result.data) {
489
+ setFormData({ ...formData, ...result.data });
490
+ setLastSavedData({ ...formData, ...result.data });
491
+ }
492
+ }
493
+
494
+ // Step 2: Publish
495
+ const response = await publishDocument();
496
+
497
+ if (response.ok) {
498
+ await clearDraftArtifacts();
499
+ onActionSuccess?.("Published successfully");
500
+ await new Promise((r) => setTimeout(r, 1500));
501
+ location.reload();
502
+ } else {
503
+ const error = await response.json();
504
+ if (response.status === 409) setAutoSaveStatus("conflict");
505
+ toast.error(error.error || "Failed to publish");
506
+ }
507
+ } catch (err) {
508
+ toast.error("Failed to publish");
509
+ } finally {
510
+ autoSaveSkipRef.current = false;
511
+ if (btn) {
512
+ btn.textContent = originalText;
513
+ btn.removeAttribute("disabled");
514
+ }
515
+ }
516
+ };
517
+
518
+ const handleSchedulePublish = async (scheduledFor: string) => {
519
+ const isNewDoc = !formData.id;
520
+ // Save the document first with _schedulePublishAt metadata
521
+ autoSaveSkipRef.current = true;
522
+
523
+ try {
524
+ const data = {
525
+ ...normalizeUploadFields({ ...formData }) as Record<string, unknown>,
526
+ _schedulePublishAt: scheduledFor,
527
+ };
528
+
529
+ if (isNewDoc && !globalSlug) {
530
+ const response = await fetchWithAuth(`/api/${collectionSlug}`, {
531
+ method: "POST",
532
+ headers: { "Content-Type": "application/json" },
533
+ body: JSON.stringify(data),
534
+ });
535
+ if (!response.ok) {
536
+ const err = await response.json().catch(() => ({}));
537
+ toast.error(err.error || "Failed to schedule publish");
538
+ return;
539
+ }
540
+ } else {
541
+ const response = await saveDocument(data);
542
+ if (!response.ok) {
543
+ const err = await response.json().catch(() => ({}));
544
+ toast.error(err.error || "Failed to schedule publish");
545
+ return;
546
+ }
547
+ }
548
+
549
+ onActionSuccess?.(`Scheduled publish for ${new Date(scheduledFor).toLocaleString()}`);
550
+ setShowSchedulePicker(false);
551
+ } catch {
552
+ toast.error("Failed to schedule publish");
553
+ } finally {
554
+ autoSaveSkipRef.current = false;
555
+ }
556
+ };
557
+
363
558
  const handleFieldChange = (fieldName: string, value: unknown) => {
364
559
  setField(fieldName, value);
365
560
  };
@@ -369,22 +564,54 @@ export function AutoForm({
369
564
  parentData?: Record<string, unknown>,
370
565
  onParentChange?: (val: unknown) => void,
371
566
  ): React.ReactNode => {
372
- if (field.admin?.hidden) return null;
373
-
374
567
  const currentData = parentData !== undefined ? parentData : formData;
568
+ const isHidden = resolveAdminFlag((field.hidden !== undefined ? field.hidden : field.admin?.hidden) as any, currentData);
569
+ if (isHidden) return null;
570
+
571
+ const isReadOnly = resolveAdminFlag((field.readOnly !== undefined ? field.readOnly : field.admin?.readOnly) as any, currentData);
572
+ const effectiveDisabled = Boolean(disabled || isReadOnly);
375
573
 
376
574
  // Evaluate display condition if present
377
575
  // For conditional fields, pass formData as the root context (first arg)
378
576
  // and currentData as the sibling context (second arg)
379
- if (field.admin?.condition && typeof field.admin.condition === "function") {
380
- try {
381
- const shouldShow = field.admin.condition(formData, currentData);
382
- if (!shouldShow) {
383
- return null;
577
+ if (field.admin?.condition) {
578
+ if (typeof field.admin.condition === "function") {
579
+ try {
580
+ // Compatibility wrapper: pass { values: formData, ...formData } to support both old and new signatures
581
+ const evalData = { values: formData || {}, ...(formData || {}) };
582
+ const shouldShow = field.admin.condition(evalData, currentData);
583
+ if (!shouldShow) {
584
+ return null;
585
+ }
586
+ } catch (e) {
587
+ console.warn(`Condition error for field ${field.name}:`, e);
588
+ // Show the field if there's an error evaluating the condition
589
+ }
590
+ } else if (typeof field.admin.condition === "object") {
591
+ try {
592
+ const cond = field.admin.condition as any;
593
+ const targetField = cond.field;
594
+
595
+ // Get target field value, prioritizing sibling context (currentData) then root context (formData)
596
+ const val = (currentData && currentData[targetField] !== undefined)
597
+ ? currentData[targetField]
598
+ : (formData && formData[targetField] !== undefined ? formData[targetField] : undefined);
599
+
600
+ let shouldShow = true;
601
+ if ("equals" in cond) {
602
+ shouldShow = val === cond.equals;
603
+ } else if ("notEquals" in cond) {
604
+ shouldShow = val !== cond.notEquals;
605
+ } else if ("in" in cond && Array.isArray(cond.in)) {
606
+ shouldShow = cond.in.includes(val);
607
+ }
608
+
609
+ if (!shouldShow) {
610
+ return null;
611
+ }
612
+ } catch (e) {
613
+ console.warn(`Declarative condition error for field ${field.name}:`, e);
384
614
  }
385
- } catch (e) {
386
- console.warn(`Condition error for field ${field.name}:`, e);
387
- // Show the field if there's an error evaluating the condition
388
615
  }
389
616
  }
390
617
 
@@ -399,7 +626,7 @@ export function AutoForm({
399
626
  }
400
627
  };
401
628
 
402
- if (field.type === "row" && "fields" in field) {
629
+ if (field.type === "row" && "fields" in field) {
403
630
  const rowFields = (field as Field & { fields?: Field[] }).fields;
404
631
  return (
405
632
  <div
@@ -410,7 +637,7 @@ if (field.type === "row" && "fields" in field) {
410
637
  const fAdmin = f.admin || {};
411
638
  const actionUrl = fAdmin?.action as string | undefined;
412
639
 
413
- if (f.type === "button" && actionUrl) {
640
+ if ((f.type === "button" || f.type === "action") && actionUrl) {
414
641
  const siblingEmailField = rowFields?.find(
415
642
  (ff: Field) => ff.type === "email",
416
643
  );
@@ -465,8 +692,8 @@ if (field.type === "row" && "fields" in field) {
465
692
  }
466
693
  }}
467
694
  //@ts-ignore
468
- disabled={loadingFields[f.name as string] || disabled}
469
- className="bg-[var(--kyro-primary)] text-white px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
695
+ disabled={loadingFields[f.name as string] || effectiveDisabled}
696
+ className="kyro-btn kyro-btn-primary px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
470
697
  >
471
698
  {loadingFields[f.name as string] ? "Sending..." : f.label || "Click"}
472
699
  </button>
@@ -477,7 +704,7 @@ if (field.type === "row" && "fields" in field) {
477
704
  return (
478
705
  <div
479
706
  key={f.name}
480
- className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
707
+ className={f.type === "button" || f.type === "action" ? "flex-shrink-0" : "flex-1"}
481
708
  style={
482
709
  fAdmin?.width ? { width: fAdmin.width as string, flex: "none" } : {}
483
710
  }
@@ -497,11 +724,10 @@ if (field.type === "row" && "fields" in field) {
497
724
  key={field.name || `tabs-${Math.random()}`}
498
725
  field={field}
499
726
  formData={formData}
500
- onTabDataChange={(newTabData) => {
501
- const updateTabData = useAutoFormStore.getState().updateTabData;
502
- updateTabData(field.name as string, newTabData);
727
+ onTabDataChange={(tabData) => {
728
+ setField(field.name!, tabData);
503
729
  }}
504
- renderField={renderField}
730
+ renderField={(f, parentData, onChange) => renderField(f, parentData, onChange)}
505
731
  />
506
732
  );
507
733
 
@@ -524,19 +750,20 @@ if (field.type === "row" && "fields" in field) {
524
750
  value={value as unknown[]}
525
751
  onChange={onFieldChange}
526
752
  renderField={renderField}
527
- disabled={disabled}
753
+ disabled={effectiveDisabled}
528
754
  />
529
755
  );
530
756
 
531
757
 
532
- case "button": {
758
+ case "button":
759
+ case "action": {
533
760
  const fieldName = field.name as string;
534
761
  const isLoading = loadingFields[fieldName];
535
762
  return (
536
763
  <div key={fieldName} className="kyro-form-field">
537
764
  <button
538
765
  type="button"
539
- disabled={isLoading || disabled}
766
+ disabled={isLoading || effectiveDisabled}
540
767
  onClick={async () => {
541
768
  const action = (field.admin?.action || (field as Record<string, unknown>).action) as string | undefined;
542
769
  const method =
@@ -630,7 +857,7 @@ if (field.type === "row" && "fields" in field) {
630
857
  value={value}
631
858
  onChange={onFieldChange}
632
859
  error={error}
633
- disabled={disabled}
860
+ disabled={effectiveDisabled}
634
861
  />
635
862
  );
636
863
  }
@@ -638,13 +865,13 @@ if (field.type === "row" && "fields" in field) {
638
865
 
639
866
  const renderHeader = () => {
640
867
  const docTitle = String(
641
- (formData.mainTabs as { title?: string })?.title ||
642
- formData.title ||
643
- formData.name ||
868
+ (formData.mainTabs as { title?: string })?.title ||
869
+ (typeof formData.title === "object" ? "" : formData.title) ||
870
+ (typeof formData.name === "object" ? "" : formData.name) ||
644
871
  "Untitled",
645
- );
646
- // Use _status from the document (set by the new draft/publish system)
647
- const docStatus = documentStatus ?? formData._status ?? formData.status ?? 'draft';
872
+ );
873
+ // Use publishStatus from the document (set by the new draft/publish system)
874
+ const docStatus = documentStatus ?? formData.publishStatus ?? formData.status ?? 'draft';
648
875
  const isNew = !formData.id;
649
876
  const lastModified = formData.updatedAt
650
877
  ? new Date(formData.updatedAt as string).toLocaleString()
@@ -653,6 +880,8 @@ if (field.type === "row" && "fields" in field) {
653
880
  ? new Date(formData.createdAt as string).toLocaleString()
654
881
  : "Just now";
655
882
 
883
+ const isDraftMode = !formData.id || documentStatus === 'draft' || !!formData.hasDraft;
884
+
656
885
  // Status label shown in the header
657
886
  const statusLabel = hasUnpublishedChanges
658
887
  ? docStatus === 'draft' && !formData._prevStatus
@@ -662,16 +891,22 @@ if (field.type === "row" && "fields" in field) {
662
891
  ? 'Published'
663
892
  : 'Draft';
664
893
 
665
- const statusColor = docStatus === 'published' && !hasUnpublishedChanges
894
+ const statusColor = docStatus === 'published' && !hasUnsavedChanges
666
895
  ? 'bg-[var(--kyro-success)]'
667
896
  : hasUnpublishedChanges
668
897
  ? 'bg-[var(--kyro-warning)]'
669
898
  : 'bg-[var(--kyro-text-muted)]';
670
899
 
900
+ const statusBadgeBg = docStatus === 'published' && !hasUnpublishedChanges
901
+ ? 'bg-[var(--kyro-success)]/10 text-[var(--kyro-success)] border-[var(--kyro-success)]/20'
902
+ : hasUnpublishedChanges
903
+ ? 'bg-[var(--kyro-warning)]/10 text-[var(--kyro-warning)] border-[var(--kyro-warning)]/20'
904
+ : 'bg-[var(--kyro-text-muted)]/10 text-[var(--kyro-text-muted)] border-[var(--kyro-text-muted)]/20';
905
+
671
906
  return (
672
907
  <header className="surface-tile px-8 py-6 flex items-center justify-between sticky top-0 z-50 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] backdrop-blur-md">
673
- <div className="flex flex-col gap-1">
674
- <div className="flex items-center gap-4">
908
+ <div className="flex flex-col gap-2">
909
+ <div className="flex items-center gap-3">
675
910
  <a
676
911
  href={`/${collectionSlug}`}
677
912
  className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
@@ -691,14 +926,12 @@ if (field.type === "row" && "fields" in field) {
691
926
  </svg>
692
927
  </a>
693
928
  <h1 className="text-xl font-bold tracking-tighter">{docTitle}</h1>
694
- </div>
695
- <div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
696
- <span className="flex items-center gap-1.5 capitalize">
697
- <span
698
- className={`h-1.5 w-1.5 rounded-full ${statusColor}`}
699
- />
929
+ <span className={`inline-flex items-center gap-1.5 px-2 rounded-full text-[10px] font-regular border ${statusBadgeBg}`}>
930
+ <span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
700
931
  {statusLabel}
701
932
  </span>
933
+ </div>
934
+ <div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
702
935
  {autoSaveStatus === "saving" && (
703
936
  <span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
704
937
  <svg
@@ -735,16 +968,62 @@ if (field.type === "row" && "fields" in field) {
735
968
  >
736
969
  <path d="M20 6L9 17l-5-5" />
737
970
  </svg>
738
- Draft saved
971
+ {lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Draft saved"}
972
+ </span>
973
+ )}
974
+ {autoSaveStatus === "retrying" && (
975
+ <span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
976
+ <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
977
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
978
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
979
+ </svg>
980
+ Retrying save ({retryCount}/5)
981
+ </span>
982
+ )}
983
+ {autoSaveStatus === "offline" && (
984
+ <span className="text-[var(--kyro-text-muted)] flex items-center gap-1.5">
985
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
986
+ <path d="M10.61 10.61a3 3 0 0 0 4.24 4.24" />
987
+ <path d="M13.36 13.36a3 3 0 0 0-4.24-4.24" />
988
+ <path d="m2 2 20 20" />
989
+ <path d="M18.36 5.64a9 9 0 0 0-12.72 0" />
990
+ <path d="M22.61 1.39a15 15 0 0 0-21.22 0" />
991
+ </svg>
992
+ Offline — cached locally
739
993
  </span>
740
994
  )}
741
995
  {autoSaveStatus === "error" && (
742
996
  <span className="text-[var(--kyro-danger)]">Draft save failed</span>
743
997
  )}
744
998
  {autoSaveStatus === "conflict" && (
745
- <span className="text-[var(--kyro-danger)]">Conflict detected</span>
999
+ <div className="flex items-center gap-3">
1000
+ <span className="text-[var(--kyro-danger)] font-semibold">Conflict detected</span>
1001
+ <span className="opacity-30">—</span>
1002
+ <button
1003
+ type="button"
1004
+ onClick={async () => {
1005
+ // Keep mine: force save and overwrite server
1006
+ await saveDocument(formData);
1007
+ setAutoSaveStatus("success");
1008
+ }}
1009
+ className="text-[var(--kyro-primary)] hover:underline"
1010
+ >
1011
+ Keep my changes
1012
+ </button>
1013
+ <span className="opacity-30">|</span>
1014
+ <button
1015
+ type="button"
1016
+ onClick={() => {
1017
+ // Reload server version
1018
+ window.location.reload();
1019
+ }}
1020
+ className="text-[var(--kyro-danger)] hover:underline"
1021
+ >
1022
+ Reload server version
1023
+ </button>
1024
+ </div>
746
1025
  )}
747
- {hasUnsavedChanges && autoSaveStatus !== "saving" && (
1026
+ {hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
748
1027
  <>
749
1028
  <span className="opacity-30">—</span>
750
1029
  <button
@@ -789,7 +1068,7 @@ if (field.type === "row" && "fields" in field) {
789
1068
  <button
790
1069
  type="button"
791
1070
  onClick={() => setShowPreview(!showPreview)}
792
- className={`p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "bg-[var(--kyro-primary)] text-white shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
1071
+ className={`kyro-btn p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
793
1072
  title="Live Preview"
794
1073
  >
795
1074
  <svg
@@ -813,7 +1092,7 @@ if (field.type === "row" && "fields" in field) {
813
1092
  onClick={() => {
814
1093
  window.dispatchEvent(new CustomEvent("toggle-sidebar"));
815
1094
  }}
816
- className={`p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
1095
+ className={`kyro-btn p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
817
1096
  title="Toggle Sidebar"
818
1097
  >
819
1098
  <svg
@@ -829,282 +1108,184 @@ if (field.type === "row" && "fields" in field) {
829
1108
  </svg>
830
1109
  </button>
831
1110
 
832
- <button
833
- id="btn-save"
834
- type="button"
835
- onClick={async () => {
836
- autoSaveSkipRef.current = true;
837
- const hiddenInput = document.getElementById(
838
- "form-data",
839
- ) as HTMLInputElement;
840
- if (!hiddenInput || !hiddenInput.value) return;
841
-
842
- const btn = document.getElementById(
843
- "btn-save",
844
- ) as HTMLButtonElement;
845
- const originalText = btn?.textContent || "";
846
- if (btn) {
847
- btn.textContent = "Saving...";
848
- btn.setAttribute("disabled", "true");
849
- }
850
-
851
- try {
852
- const data = JSON.parse(hiddenInput.value);
853
- const isPost = isNew && !globalSlug;
854
-
855
- const response = isPost
856
- ? await fetchWithAuth(`/api/${collectionSlug}`, {
857
- method: "POST",
858
- headers: { "Content-Type": "application/json" },
859
- body: JSON.stringify(data),
860
- })
861
- : await saveDocument(data);
862
-
863
- if (response.ok) {
864
- const result = await response.json();
865
- setFormData(result.data || data);
866
- setLastSavedData(result.data || data);
867
- lastAutoSaveTimeRef.current = Date.now();
868
- setAutoSaveStatus("success");
869
- await clearDraftArtifacts();
870
- if (versionsEnabled) fetchVersions();
871
- setTimeout(() => setAutoSaveStatus("idle"), 2000);
872
- onActionSuccess?.(
873
- isPost ? "Document created successfully" : "Changes saved",
874
- );
875
- if (globalSlug) {
876
- setTimeout(() => {
877
- window.location.reload();
878
- }, 1000);
879
- }
880
- if (isPost) {
881
- setTimeout(() => {
882
- window.location.href = `/${collectionSlug}`;
883
- }, 800);
884
- }
885
- } else {
886
- const error = await response.json();
887
- if (response.status === 409) {
888
- setAutoSaveStatus("conflict");
889
- }
890
- alert({
891
- title: response.status === 409 ? "Conflict detected" : "Error",
892
- message: error.error || "Failed to save",
893
- });
894
- }
895
- } catch (err) {
896
- alert({
897
- title: "Error",
898
- message: "Failed to save document",
899
- });
900
- } finally {
901
- autoSaveSkipRef.current = false;
902
- if (btn) {
903
- btn.textContent = originalText;
904
- btn.removeAttribute("disabled");
905
- }
906
- }
907
- }}
908
- className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
909
- >
910
- {isNew ? (globalSlug ? "Save" : "Create") : hasUnsavedChanges ? (versionsEnabled ? "Save Draft" : "Save") : "Saved"}
911
- </button>
912
-
913
- {!isNew && versionsEnabled && documentStatus === "draft" && (
914
- <button
915
- id="btn-publish"
916
- type="button"
917
- onClick={async () => {
918
- autoSaveSkipRef.current = true;
919
- const btn = document.getElementById(
920
- "btn-publish",
921
- ) as HTMLButtonElement;
922
- const originalText = btn?.textContent || "";
923
- if (btn) {
924
- btn.textContent = "Publishing...";
925
- btn.setAttribute("disabled", "true");
926
- }
927
-
928
- try {
929
- if (hasUnsavedChanges) {
930
- const saveResponse = await saveDocument(formData);
931
- if (!saveResponse.ok) {
932
- const saveError = await saveResponse.json().catch(() => ({}));
933
- if (saveResponse.status === 409) {
934
- setAutoSaveStatus("conflict");
935
- }
936
- alert({
937
- title:
938
- saveResponse.status === 409
939
- ? "Conflict detected"
940
- : "Error",
941
- message: saveError.error || "Failed to save latest draft before publishing",
942
- });
943
- return;
944
- }
945
-
946
- const saveResult = await saveResponse.json();
947
- setFormData(saveResult.data || formData);
948
- setLastSavedData(saveResult.data || formData);
949
- }
950
-
951
- const response = await publishDocument();
952
-
953
- if (response.ok) {
954
- await clearDraftArtifacts();
955
- onActionSuccess?.("Published successfully");
956
- location.reload();
957
- } else {
958
- const error = await response.json();
959
- if (response.status === 409) {
960
- setAutoSaveStatus("conflict");
961
- }
962
- alert({
963
- title: response.status === 409 ? "Conflict detected" : "Error",
964
- message: error.error || "Failed to publish",
965
- });
966
- }
967
- } catch (err) {
968
- alert({
969
- title: "Error",
970
- message: "Failed to publish",
971
- });
972
- } finally {
973
- autoSaveSkipRef.current = false;
974
- if (btn) {
975
- btn.textContent = originalText;
976
- btn.removeAttribute("disabled");
977
- }
978
- }
979
- }}
980
- className="px-6 py-2.5 text-xs font-bold rounded-xl border-2 border-[var(--kyro-border)] text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)] hover:text-white transition-all"
981
- >
982
- {formData._prevStatus === 'published' ? 'Publish Changes' : 'Publish'}
983
- </button>
984
- )}
985
-
986
- <div ref={menuRef} className="relative">
987
- <button
988
- type="button"
989
- onClick={() => setIsMenuOpen(!isMenuOpen)}
990
- className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
991
- >
992
- <svg
993
- width="20"
994
- height="20"
995
- viewBox="0 0 24 24"
996
- fill="none"
997
- stroke="currentColor"
998
- strokeWidth="3"
999
- >
1000
- <circle cx="12" cy="12" r="1.5" fill="currentColor" />
1001
- <circle cx="12" cy="5" r="1.5" fill="currentColor" />
1002
- <circle cx="12" cy="19" r="1.5" fill="currentColor" />
1111
+ {documentStatus === "published" && !isNew && !hasUnsavedChanges ? (
1112
+ <span className="inline-flex items-center gap-1.5 px-6 py-2.5 text-xs rounded-xl bg-green-100 text-green-700 border border-green-200 cursor-not-allowed">
1113
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
1114
+ <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
1003
1115
  </svg>
1004
- </button>
1005
- {isMenuOpen && (
1006
- <div className="absolute right-0 mt-2 w-48 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 overflow-hidden">
1116
+ Published
1117
+ </span>
1118
+ ) : (
1119
+ <div ref={menuRef} className="relative flex items-center gap-3">
1120
+ <div className="flex items-center rounded-xl overflow-hidden shadow-lg">
1007
1121
  <button
1122
+ id="btn-publish"
1008
1123
  type="button"
1009
- onClick={() => {
1010
- handleCreateNew();
1011
- setIsMenuOpen(false);
1012
- }}
1013
- className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1124
+ onClick={isDraftMode ? handleSaveDraft : handlePublish}
1125
+ className={`px-6 py-2.5 text-xs font-bold rounded-l-xl rounded-r-none transition-all whitespace-nowrap ${isDraftMode ? 'kyro-btn-primary' : 'kyro-btn-success'}`}
1014
1126
  >
1015
- <svg
1016
- width="16"
1017
- height="16"
1018
- viewBox="0 0 24 24"
1019
- fill="none"
1020
- stroke="currentColor"
1021
- strokeWidth="2"
1022
- >
1023
- <line x1="12" y1="5" x2="12" y2="19"></line>
1024
- <line x1="5" y1="12" x2="19" y2="12"></line>
1127
+ {isDraftMode ? "Save Draft" : "Publish Changes"}
1128
+ </button>
1129
+ <button
1130
+ type="button"
1131
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
1132
+ className={`px-2.5 py-2.5 text-xs rounded-r-xl rounded-l-none transition-all ${isDraftMode ? 'kyro-btn-primary' : 'kyro-btn-success'}`}
1133
+ >
1134
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
1135
+ <polyline points="6 9 12 15 18 9" />
1025
1136
  </svg>
1026
- Create New
1027
1137
  </button>
1028
- {!isNew && (
1029
- <>
1138
+ </div>
1139
+
1140
+ {isMenuOpen && (
1141
+ <div className="absolute right-0 top-full mt-2 w-56 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 overflow-hidden">
1142
+ <button
1143
+ type="button"
1144
+ onClick={() => {
1145
+ handleSaveDraft();
1146
+ setIsMenuOpen(false);
1147
+ }}
1148
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1149
+ >
1150
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1151
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
1152
+ <polyline points="17 21 17 13 7 13 7 21" />
1153
+ <polyline points="7 3 7 8 15 8" />
1154
+ </svg>
1155
+ Save Draft
1156
+ </button>
1157
+ <button
1158
+ type="button"
1159
+ onClick={() => {
1160
+ handlePublish();
1161
+ setIsMenuOpen(false);
1162
+ }}
1163
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1164
+ >
1165
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1166
+ <polygon points="5 3 19 12 5 21 5 3" />
1167
+ </svg>
1168
+ Publish
1169
+ </button>
1170
+ <button
1171
+ type="button"
1172
+ onClick={() => {
1173
+ setIsMenuOpen(false);
1174
+ setShowSchedulePicker(true);
1175
+ }}
1176
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1177
+ >
1178
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1179
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
1180
+ <line x1="16" y1="2" x2="16" y2="6" />
1181
+ <line x1="8" y1="2" x2="8" y2="6" />
1182
+ <line x1="3" y1="10" x2="21" y2="10" />
1183
+ </svg>
1184
+ Schedule Publish
1185
+ </button>
1186
+ <div className="h-px bg-[var(--kyro-border)]" />
1187
+ {!globalSlug && (
1030
1188
  <button
1031
1189
  type="button"
1032
1190
  onClick={() => {
1033
- handleDuplicate();
1191
+ handleCreateNew();
1034
1192
  setIsMenuOpen(false);
1035
1193
  }}
1036
1194
  className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1037
1195
  >
1038
- <svg
1039
- width="16"
1040
- height="16"
1041
- viewBox="0 0 24 24"
1042
- fill="none"
1043
- stroke="currentColor"
1044
- strokeWidth="2"
1045
- >
1046
- <rect
1047
- x="9"
1048
- y="9"
1049
- width="13"
1050
- height="13"
1051
- rx="2"
1052
- ry="2"
1053
- ></rect>
1054
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1196
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1197
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1198
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1055
1199
  </svg>
1056
- Duplicate
1200
+ Create New
1057
1201
  </button>
1058
- {formData._status === "published" && (
1202
+ )}
1203
+ {!isNew && !globalSlug && (
1204
+ <>
1059
1205
  <button
1060
1206
  type="button"
1061
1207
  onClick={() => {
1062
- handleUnpublish();
1208
+ handleDuplicate();
1063
1209
  setIsMenuOpen(false);
1064
1210
  }}
1065
1211
  className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1066
1212
  >
1067
- <svg
1068
- width="16"
1069
- height="16"
1070
- viewBox="0 0 24 24"
1071
- fill="none"
1072
- stroke="currentColor"
1073
- strokeWidth="2"
1074
- >
1075
- <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
1076
- <line x1="1" y1="1" x2="23" y2="23"></line>
1213
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1214
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
1215
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
1077
1216
  </svg>
1078
- Unpublish
1217
+ Duplicate
1079
1218
  </button>
1080
- )}
1081
- <div className="h-px bg-[var(--kyro-border)]" />
1082
- <button
1083
- type="button"
1084
- onClick={() => {
1085
- handleDelete();
1086
- setIsMenuOpen(false);
1087
- }}
1088
- className="w-full px-4 py-2.5 text-left text-xs font-medium text-red-600 hover:bg-red-50 flex items-center gap-3 transition-colors"
1089
- >
1090
- <svg
1091
- width="16"
1092
- height="16"
1093
- viewBox="0 0 24 24"
1094
- fill="none"
1095
- stroke="currentColor"
1096
- strokeWidth="2"
1219
+ {documentStatus === "published" && (
1220
+ <button
1221
+ type="button"
1222
+ onClick={() => {
1223
+ handleUnpublish();
1224
+ setIsMenuOpen(false);
1225
+ }}
1226
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1227
+ >
1228
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1229
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
1230
+ <line x1="1" y1="1" x2="23" y2="23" />
1231
+ </svg>
1232
+ Unpublish
1233
+ </button>
1234
+ )}
1235
+ <div className="h-px bg-[var(--kyro-border)]" />
1236
+ <button
1237
+ type="button"
1238
+ onClick={() => {
1239
+ handleDelete();
1240
+ setIsMenuOpen(false);
1241
+ }}
1242
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-red-600 hover:bg-red-50 flex items-center gap-3 transition-colors"
1097
1243
  >
1098
- <polyline points="3 6 5 6 21 6"></polyline>
1099
- <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
1100
- </svg>
1101
- Delete
1102
- </button>
1103
- </>
1104
- )}
1244
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1245
+ <polyline points="3 6 5 6 21 6" />
1246
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
1247
+ </svg>
1248
+ Delete
1249
+ </button>
1250
+ </>
1251
+ )}
1252
+ </div>
1253
+ )}
1254
+ </div>
1255
+ )}
1256
+
1257
+ {showSchedulePicker && (
1258
+ <div ref={scheduleRef} className="relative">
1259
+ <div className="absolute right-0 top-2 p-4 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50">
1260
+ <p className="text-xs font-medium mb-2">Schedule Publish</p>
1261
+ <input
1262
+ type="datetime-local"
1263
+ id="schedule-datetime"
1264
+ className="kyro-form-input text-xs mb-3 w-full"
1265
+ min={new Date().toISOString().slice(0, 16)}
1266
+ />
1267
+ <div className="flex items-center gap-2 justify-end">
1268
+ <button
1269
+ type="button"
1270
+ onClick={() => setShowSchedulePicker(false)}
1271
+ className="px-3 py-1.5 text-xs kyro-btn rounded-lg"
1272
+ >
1273
+ Cancel
1274
+ </button>
1275
+ <button
1276
+ type="button"
1277
+ onClick={() => {
1278
+ const val = (document.getElementById("schedule-datetime") as HTMLInputElement)?.value;
1279
+ if (val) handleSchedulePublish(val);
1280
+ }}
1281
+ className="px-3 py-1.5 text-xs kyro-btn-success rounded-lg"
1282
+ >
1283
+ Schedule
1284
+ </button>
1285
+ </div>
1105
1286
  </div>
1106
- )}
1107
- </div>
1287
+ </div>
1288
+ )}
1108
1289
  </div>
1109
1290
  </div>
1110
1291
  </header>
@@ -1207,7 +1388,7 @@ if (field.type === "row" && "fields" in field) {
1207
1388
  type="button"
1208
1389
  onClick={handleCompareVersions}
1209
1390
  disabled={loadingDiffs}
1210
- className="px-3 py-1.5 rounded-lg bg-[var(--kyro-primary)] text-white text-[11px] font-bold tracking-wider hover:opacity-90 disabled:opacity-50"
1391
+ className="kyro-btn kyro-btn-primary px-3 py-1.5 rounded-lg text-[11px] font-bold tracking-wider hover:opacity-90 disabled:opacity-50"
1211
1392
  >
1212
1393
  {loadingDiffs ? "Comparing..." : "Compare"}
1213
1394
  </button>
@@ -1374,7 +1555,7 @@ if (field.type === "row" && "fields" in field) {
1374
1555
  <button
1375
1556
  type="button"
1376
1557
  onClick={() => handleRestoreVersion(v.id)}
1377
- className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold tracking-wider text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95"
1558
+ className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold tracking-wider text-[var(--kyro-text-secondary)] kyro-btn-primary hover:border-[var(--kyro-primary)] transition-all active:scale-95"
1378
1559
  >
1379
1560
  Restore
1380
1561
  </button>
@@ -1507,42 +1688,77 @@ if (field.type === "row" && "fields" in field) {
1507
1688
  <div className="flex flex-col h-full">
1508
1689
  {layout !== "single" && renderHeader()}
1509
1690
  {layout === "single" && (
1510
- <button
1511
- id="btn-save"
1512
- type="button"
1513
- style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
1514
- onClick={async () => {
1515
- console.log("[AutoForm] Hidden save button clicked");
1516
- const hiddenInput = document.getElementById("form-data") as HTMLInputElement;
1517
- if (!hiddenInput || !hiddenInput.value) {
1518
- console.error("[AutoForm] #form-data input not found or empty");
1519
- return;
1520
- }
1521
- try {
1522
- const data = JSON.parse(hiddenInput.value);
1523
- console.log("[AutoForm] Saving data:", data);
1524
- const response = await saveDocument(data);
1691
+ <>
1692
+ <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
1693
+ <div className="flex items-center gap-3 text-[11px] font-medium">
1694
+ {autoSaveStatus === "saving" && (
1695
+ <span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
1696
+ <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
1697
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
1698
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
1699
+ </svg>
1700
+ Saving...
1701
+ </span>
1702
+ )}
1703
+ {autoSaveStatus === "success" && (
1704
+ <span className="text-[var(--kyro-success)] flex items-center gap-1">
1705
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
1706
+ <path d="M20 6L9 17l-5-5" />
1707
+ </svg>
1708
+ {lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Saved"}
1709
+ </span>
1710
+ )}
1711
+ {autoSaveStatus === "retrying" && (
1712
+ <span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
1713
+ <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
1714
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
1715
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
1716
+ </svg>
1717
+ Retrying...
1718
+ </span>
1719
+ )}
1720
+ {autoSaveStatus === "offline" && (
1721
+ <span className="text-[var(--kyro-text-muted)]">Offline — cached locally</span>
1722
+ )}
1723
+ {autoSaveStatus === "error" && (
1724
+ <span className="text-[var(--kyro-danger)]">Save failed</span>
1725
+ )}
1726
+ {autoSaveStatus === "conflict" && (
1727
+ <span className="text-[var(--kyro-danger)]">Conflict detected</span>
1728
+ )}
1729
+ {hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
1730
+ <span className="text-[var(--kyro-warning)]">Unsaved changes</span>
1731
+ )}
1732
+ {!hasUnsavedChanges && autoSaveStatus !== "success" && autoSaveStatus !== "saving" && autoSaveStatus !== "error" && (
1733
+ <span className="text-[var(--kyro-success)]">All changes saved</span>
1734
+ )}
1735
+ </div>
1736
+ <span className="text-[11px] text-[var(--kyro-text-muted)] opacity-60">
1737
+ {formData.updatedAt ? `Modified ${new Date(formData.updatedAt as string).toLocaleString()}` : ""}
1738
+ </span>
1739
+ </div>
1740
+ <button
1741
+ id="btn-save"
1742
+ type="button"
1743
+ style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
1744
+ onClick={async () => {
1745
+ console.log("[AutoForm] Hidden save button clicked");
1746
+ try {
1747
+ const response = await saveDocument();
1525
1748
  if (response.ok) {
1526
1749
  const result = await response.json();
1527
- const savedData = result.data || data;
1528
- setFormData(savedData);
1529
- setLastSavedData(savedData);
1750
+ const savedData = result.data || formData;
1751
+ setFormData({ ...formData, ...savedData });
1752
+ setLastSavedData({ ...formData, ...savedData });
1530
1753
  onActionSuccess?.("Changes saved");
1531
- // Trigger a refresh to ensure all global state is updated
1532
- setTimeout(() => {
1533
- window.location.reload();
1534
- }, 1000); // Small delay to let the toast show
1535
- } else {
1536
- const errorData = await response.json().catch(() => ({}));
1537
- console.error("Save global failed:", errorData);
1538
- onActionError?.(errorData.error || "Save failed");
1539
1754
  }
1540
- } catch (e) {
1541
- console.error("Save error exception:", e);
1542
- onActionError?.("Save failed: " + (e as Error).message);
1543
- }
1544
- }}
1545
- />
1755
+ } catch (e) {
1756
+ console.error("Save error exception:", e);
1757
+ onActionError?.("Save failed: " + (e as Error).message);
1758
+ }
1759
+ }}
1760
+ />
1761
+ </>
1546
1762
  )}
1547
1763
  <main className="w-full">
1548
1764
  {view === "edit" && renderEditView()}
@@ -1596,7 +1812,7 @@ function RelationshipField({
1596
1812
  fetchOptions();
1597
1813
  }, [targetCollection]);
1598
1814
 
1599
- const getLabel = (opt: unknown) => {
1815
+ const getLabel = (opt: unknown) => {
1600
1816
  const o = opt as Record<string, unknown> | undefined;
1601
1817
  if (!o) return "";
1602
1818
  return String(
@@ -1658,13 +1874,13 @@ const getLabel = (opt: unknown) => {
1658
1874
 
1659
1875
  const filteredOptions = (search
1660
1876
  ? (options || []).filter((opt) => {
1661
- const o = opt as Record<string, unknown>;
1662
- const term = search.toLowerCase();
1663
- const searchableFields = ["title", "name", "label", "filename", "slug"];
1664
- return searchableFields.some(
1665
- (key) => o[key] && String(o[key]).toLowerCase().includes(term),
1666
- );
1667
- })
1877
+ const o = opt as Record<string, unknown>;
1878
+ const term = search.toLowerCase();
1879
+ const searchableFields = ["title", "name", "label", "filename", "slug"];
1880
+ return searchableFields.some(
1881
+ (key) => o[key] && String(o[key]).toLowerCase().includes(term),
1882
+ );
1883
+ })
1668
1884
  : options || []) as Array<Record<string, unknown>>;
1669
1885
 
1670
1886
  return (