@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
@@ -1,3 +1,4 @@
1
+ import { ChevronRight, Check, ExternalLink, X } from "./ui/icons";
1
2
  import { useState, useRef, useEffect } from "react";
2
3
  import type {
3
4
  CollectionConfig,
@@ -18,18 +19,22 @@ import TextField from "./fields/TextField";
18
19
  import { globals, collections } from "../lib/config";
19
20
  import { slugifyText } from "../lib/slugify";
20
21
  import { resolveUrl, apiDelete, fetchWithAuth } from "../lib/api";
22
+ import { normalizeUploadFields } from "../lib/normalize-upload-fields";
21
23
  import { useAutoFormStore } from "../lib/autoform-store";
22
24
  import { useAutoFormState } from "../hooks/useAutoFormState";
23
- import { useUIStore } from "../lib/stores";
25
+ import { useUIStore, toast } from "../lib/stores";
26
+ import { EmptyState } from "./ui/EmptyState";
24
27
 
25
28
  import { adminPath as ADMIN_BASE, apiPath as API_BASE } from "../lib/paths";
26
29
 
27
30
  import { BlocksField } from "./fields/BlocksField";
28
- import PortableTextField from "./fields/PortableTextField";
29
31
  import { ConfirmModal, Modal as UIModal } from "./ui/Modal";
30
32
  import { ListField } from "./fields/ListField";
31
33
  import { RelationshipBlockField } from "./fields/RelationshipBlockField";
32
34
  import { FieldRenderer } from "./FieldRenderer";
35
+ import { Dropdown, DropdownItem, DropdownSeparator } from "./ui/Dropdown";
36
+ import { SplitButton } from "./ui/SplitButton";
37
+ import type { SplitButtonStatus } from "./ui/SplitButton";
33
38
  import { TabsLayout } from "./fields/TabsLayout";
34
39
  import { GroupLayout } from "./fields/GroupLayout";
35
40
  import { ArrayLayout } from "./fields/ArrayLayout";
@@ -73,14 +78,17 @@ export function AutoForm({
73
78
  const config = activeConfig || propConfig;
74
79
 
75
80
 
76
- const { confirm, alert } = useUIStore();
81
+ const { confirm } = useUIStore();
77
82
 
78
83
  const {
79
84
  formData,
80
85
  lastSavedData,
81
86
  hasUnsavedChanges,
82
87
  isAutoSaving,
88
+ backgroundProcessing,
83
89
  autoSaveStatus,
90
+ lastSavedAt,
91
+ retryCount,
84
92
  sidebarCollapsed,
85
93
  setSidebarCollapsed,
86
94
  activeTab,
@@ -114,8 +122,6 @@ export function AutoForm({
114
122
  setAutoSaveStatus,
115
123
  fetchVersions,
116
124
  saveDocument,
117
- publishDocument,
118
- clearDraftArtifacts,
119
125
  autoSaveSkipRef,
120
126
  lastAutoSaveTimeRef,
121
127
  documentStatus,
@@ -132,8 +138,36 @@ export function AutoForm({
132
138
  });
133
139
 
134
140
  const menuRef = useRef<HTMLDivElement>(null);
141
+ const scheduleRef = useRef<HTMLDivElement>(null);
142
+ const [showSchedulePicker, setShowSchedulePicker] = useState(false);
143
+ const [localSaveStatus, setLocalSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
144
+ const [now, setNow] = useState(Date.now());
135
145
  const disabled = propDisabled;
136
146
 
147
+ // Tick every 10s so the "saved X ago" label stays fresh
148
+ useEffect(() => {
149
+ const id = setInterval(() => setNow(Date.now()), 10_000);
150
+ return () => clearInterval(id);
151
+ }, []);
152
+
153
+ const resolveAdminFlag = (
154
+ value: boolean | ((
155
+ data: Record<string, unknown>,
156
+ siblingData: Record<string, unknown>,
157
+ ) => boolean) | undefined,
158
+ currentData: Record<string, unknown>,
159
+ ): boolean => {
160
+ if (typeof value === "function") {
161
+ try {
162
+ return value(formData, currentData);
163
+ } catch (error) {
164
+ console.warn("Error evaluating admin runtime flag:", error);
165
+ return false;
166
+ }
167
+ }
168
+ return Boolean(value);
169
+ };
170
+
137
171
  const handleRestoreVersion = (versionId: string) => {
138
172
  confirm({
139
173
  title: "Restore Version",
@@ -161,17 +195,18 @@ export function AutoForm({
161
195
 
162
196
  const result = await resp.json();
163
197
  if (result.data) {
164
- setFormData(result.data);
165
- useAutoFormStore.getState().loadDocument(result.data, result.data);
198
+ const restoredData = { ...formData, ...result.data };
199
+ setFormData(restoredData);
200
+ useAutoFormStore.getState().loadDocument(restoredData, restoredData);
166
201
  onActionSuccess?.("Version restored successfully");
167
202
  fetchVersions();
168
203
  setView("edit");
169
204
  } else {
170
- alert({ title: "Error", message: result.error || "Failed to restore version" });
205
+ toast.error(result.error || "Failed to restore version");
171
206
  }
172
207
  } catch (err) {
173
208
  console.error("Failed to restore version:", err);
174
- alert({ title: "Error", message: "Failed to restore version" });
209
+ toast.error("Failed to restore version");
175
210
  }
176
211
  }
177
212
  });
@@ -208,13 +243,18 @@ export function AutoForm({
208
243
 
209
244
  useEffect(() => {
210
245
  const handleShortcuts = (e: KeyboardEvent) => {
211
- // Cmd/Ctrl + S = Publish
246
+ // Cmd/Ctrl + S = Save Draft
212
247
  if ((e.metaKey || e.ctrlKey) && e.key === "s") {
213
248
  e.preventDefault();
214
- (document.getElementById("btn-save") as HTMLButtonElement | null)?.click();
249
+ handleSaveDraft();
215
250
  }
216
- // Cmd/Ctrl + P = Toggle Preview
217
- if ((e.metaKey || e.ctrlKey) && e.key === "p") {
251
+ // Cmd/Ctrl + Shift + P = Publish Changes
252
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "P" || e.key === "p")) {
253
+ e.preventDefault();
254
+ (document.getElementById("btn-publish") as HTMLButtonElement | null)?.click();
255
+ }
256
+ // Cmd/Ctrl + P (no shift) = Toggle Preview
257
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "p") {
218
258
  e.preventDefault();
219
259
  setShowPreview((prev) => !prev);
220
260
  }
@@ -248,17 +288,30 @@ export function AutoForm({
248
288
  if (isMenuOpen) {
249
289
  document.addEventListener("mousedown", handleClickOutside);
250
290
  return () =>
251
- document.addEventListener("mousedown", handleClickOutside);
291
+ document.removeEventListener("mousedown", handleClickOutside);
252
292
  }
253
293
  }, [isMenuOpen]);
254
294
 
295
+ useEffect(() => {
296
+ const handleClickOutside = (e: MouseEvent) => {
297
+ if (scheduleRef.current && !scheduleRef.current.contains(e.target as Node)) {
298
+ setShowSchedulePicker(false);
299
+ }
300
+ };
301
+ if (showSchedulePicker) {
302
+ document.addEventListener("mousedown", handleClickOutside);
303
+ return () =>
304
+ document.removeEventListener("mousedown", handleClickOutside);
305
+ }
306
+ }, [showSchedulePicker]);
307
+
255
308
  const handleCreateNew = () => {
256
309
  if (hasUnsavedChanges) {
257
310
  confirm({
258
311
  title: "Unsaved Changes",
259
312
  message: "You have unsaved changes. Save before creating new?",
260
313
  onConfirm: async () => {
261
- (document.getElementById("btn-save") as HTMLButtonElement | null)?.click();
314
+ await handleSaveDraft();
262
315
  await new Promise((r) => setTimeout(r, 1000));
263
316
  window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
264
317
  },
@@ -275,11 +328,12 @@ export function AutoForm({
275
328
  onConfirm: async () => {
276
329
  try {
277
330
  const { id, createdAt, updatedAt, ...duplicateData } = formData;
331
+ const normalizedData = normalizeUploadFields(duplicateData) as Record<string, unknown>;
278
332
  const response = await fetchWithAuth(`/api/${collectionSlug}`, {
279
333
  method: "POST",
280
334
  headers: { "Content-Type": "application/json" },
281
335
  body: JSON.stringify({
282
- ...duplicateData,
336
+ ...normalizedData,
283
337
  title: `${duplicateData.title || duplicateData.name || "Copy"} (Copy)`,
284
338
  slug: `${duplicateData.slug || "copy"}-${Date.now()}`,
285
339
  status: "draft",
@@ -294,16 +348,10 @@ export function AutoForm({
294
348
  }
295
349
  } else {
296
350
  const error = await response.json();
297
- alert({
298
- title: "Error",
299
- message: error.error || "Failed to duplicate document",
300
- });
351
+ toast.error(error.error || "Failed to duplicate document");
301
352
  }
302
353
  } catch (err) {
303
- alert({
304
- title: "Error",
305
- message: "Failed to duplicate document",
306
- });
354
+ toast.error("Failed to duplicate document");
307
355
  }
308
356
  },
309
357
  });
@@ -319,10 +367,7 @@ export function AutoForm({
319
367
  await apiDelete(`/api/${collectionSlug}/${formData.id}`);
320
368
  window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
321
369
  } catch (err) {
322
- alert({
323
- title: "Error",
324
- message: (err as Error).message || "Failed to delete document",
325
- });
370
+ toast.error((err as Error).message || "Failed to delete document");
326
371
  }
327
372
  },
328
373
  });
@@ -334,32 +379,173 @@ export function AutoForm({
334
379
  message: "Unpublish this document?",
335
380
  onConfirm: async () => {
336
381
  try {
337
- const response = await fetchWithAuth(
338
- resolveUrl(`/api/${collectionSlug}/${formData.id}/unpublish`),
339
- {
340
- method: "POST",
341
- },
382
+ const response = await saveDocument(
383
+ { ...formData, status: 'draft' } as Record<string, unknown>,
384
+ false,
342
385
  );
343
- if (response.ok) {
386
+ if (response?.ok) {
344
387
  onActionSuccess?.("Document unpublished successfully");
345
388
  location.reload();
346
389
  } else {
347
- const error = await response.json();
348
- alert({
349
- title: "Error",
350
- message: error.error || "Failed to unpublish",
351
- });
390
+ const error = await response?.json().catch(() => ({}));
391
+ toast.error(error?.error || "Failed to unpublish");
352
392
  }
353
393
  } catch (err) {
354
- alert({
355
- title: "Error",
356
- message: "Failed to unpublish",
357
- });
394
+ toast.error("Failed to unpublish");
358
395
  }
359
396
  },
360
397
  });
361
398
  };
362
399
 
400
+ const handleSaveDraft = async () => {
401
+ const isNewDoc = !formData.id;
402
+ autoSaveSkipRef.current = true;
403
+ setLocalSaveStatus("saving");
404
+
405
+ try {
406
+ const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
407
+ const isPost = isNewDoc && !globalSlug;
408
+
409
+ const response = isPost
410
+ ? await fetchWithAuth(`/api/${collectionSlug}`, {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/json" },
413
+ body: JSON.stringify(data),
414
+ })
415
+ : await saveDocument(data);
416
+
417
+ if (response.ok) {
418
+ const result = await response.json();
419
+ const savedData = result.data || data;
420
+ setFormData({ ...formData, ...savedData });
421
+ setLastSavedData({ ...formData, ...savedData });
422
+ lastAutoSaveTimeRef.current = Date.now();
423
+ setAutoSaveStatus("success");
424
+ setLocalSaveStatus("saved");
425
+ if (versionsEnabled) fetchVersions();
426
+ setTimeout(() => {
427
+ setAutoSaveStatus("idle");
428
+ setLocalSaveStatus("idle");
429
+ }, 2000);
430
+ onActionSuccess?.(
431
+ isPost ? "Document created successfully" : "Changes saved",
432
+ );
433
+ if (isPost) {
434
+ setTimeout(() => {
435
+ window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
436
+ }, 800);
437
+ }
438
+ } else {
439
+ const error = await response.json();
440
+ if (response.status === 409) {
441
+ setAutoSaveStatus("conflict");
442
+ }
443
+ setLocalSaveStatus("error");
444
+ toast.error(error.error || "Failed to save");
445
+ setTimeout(() => setLocalSaveStatus("idle"), 3000);
446
+ }
447
+ } catch (err) {
448
+ setLocalSaveStatus("error");
449
+ toast.error("Failed to save document");
450
+ setTimeout(() => setLocalSaveStatus("idle"), 3000);
451
+ } finally {
452
+ autoSaveSkipRef.current = false;
453
+ }
454
+ };
455
+
456
+ const handlePublish = async () => {
457
+ const isNewDoc = !formData.id;
458
+ autoSaveSkipRef.current = true;
459
+ setLocalSaveStatus("saving");
460
+
461
+ try {
462
+ if (isNewDoc && !globalSlug) {
463
+ // Create then immediately publish
464
+ const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
465
+ const response = await fetchWithAuth(`/api/${collectionSlug}`, {
466
+ method: "POST",
467
+ headers: { "Content-Type": "application/json" },
468
+ body: JSON.stringify(data),
469
+ });
470
+ if (!response.ok) {
471
+ const error = await response.json().catch(() => ({}));
472
+ if (response.status === 409) setAutoSaveStatus("conflict");
473
+ setLocalSaveStatus("error");
474
+ toast.error(error.error || "Failed to create document");
475
+ setTimeout(() => setLocalSaveStatus("idle"), 3000);
476
+ return;
477
+ }
478
+ const result = await response.json();
479
+ const savedData = result.data || data;
480
+ setFormData({ ...formData, ...savedData });
481
+ setLastSavedData({ ...formData, ...savedData });
482
+ }
483
+
484
+ // Save and publish (X-Draft: false writes to main doc + versions table)
485
+ const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
486
+ const response = await saveDocument(data, false);
487
+
488
+ if (response?.ok) {
489
+ setLocalSaveStatus("saved");
490
+ onActionSuccess?.("Published successfully");
491
+ await new Promise((r) => setTimeout(r, 1000));
492
+ location.reload();
493
+ } else {
494
+ const error = await response?.json().catch(() => ({}));
495
+ if (response?.status === 409) setAutoSaveStatus("conflict");
496
+ setLocalSaveStatus("error");
497
+ toast.error(error?.error || "Failed to publish");
498
+ setTimeout(() => setLocalSaveStatus("idle"), 3000);
499
+ }
500
+ } catch (err) {
501
+ setLocalSaveStatus("error");
502
+ toast.error("Failed to publish");
503
+ setTimeout(() => setLocalSaveStatus("idle"), 3000);
504
+ } finally {
505
+ autoSaveSkipRef.current = false;
506
+ }
507
+ };
508
+
509
+ const handleSchedulePublish = async (scheduledFor: string) => {
510
+ const isNewDoc = !formData.id;
511
+ // Save the document first with _schedulePublishAt metadata
512
+ autoSaveSkipRef.current = true;
513
+
514
+ try {
515
+ const data = {
516
+ ...normalizeUploadFields({ ...formData }) as Record<string, unknown>,
517
+ _schedulePublishAt: scheduledFor,
518
+ };
519
+
520
+ if (isNewDoc && !globalSlug) {
521
+ const response = await fetchWithAuth(`/api/${collectionSlug}`, {
522
+ method: "POST",
523
+ headers: { "Content-Type": "application/json" },
524
+ body: JSON.stringify(data),
525
+ });
526
+ if (!response.ok) {
527
+ const err = await response.json().catch(() => ({}));
528
+ toast.error(err.error || "Failed to schedule publish");
529
+ return;
530
+ }
531
+ } else {
532
+ const response = await saveDocument(data);
533
+ if (!response.ok) {
534
+ const err = await response.json().catch(() => ({}));
535
+ toast.error(err.error || "Failed to schedule publish");
536
+ return;
537
+ }
538
+ }
539
+
540
+ onActionSuccess?.(`Scheduled publish for ${new Date(scheduledFor).toLocaleString()}`);
541
+ setShowSchedulePicker(false);
542
+ } catch {
543
+ toast.error("Failed to schedule publish");
544
+ } finally {
545
+ autoSaveSkipRef.current = false;
546
+ }
547
+ };
548
+
363
549
  const handleFieldChange = (fieldName: string, value: unknown) => {
364
550
  setField(fieldName, value);
365
551
  };
@@ -369,22 +555,54 @@ export function AutoForm({
369
555
  parentData?: Record<string, unknown>,
370
556
  onParentChange?: (val: unknown) => void,
371
557
  ): React.ReactNode => {
372
- if (field.admin?.hidden) return null;
373
-
374
558
  const currentData = parentData !== undefined ? parentData : formData;
559
+ const isHidden = resolveAdminFlag((field.hidden !== undefined ? field.hidden : field.admin?.hidden) as any, currentData);
560
+ if (isHidden) return null;
561
+
562
+ const isReadOnly = resolveAdminFlag((field.readOnly !== undefined ? field.readOnly : field.admin?.readOnly) as any, currentData);
563
+ const effectiveDisabled = Boolean(disabled || isReadOnly);
375
564
 
376
565
  // Evaluate display condition if present
377
566
  // For conditional fields, pass formData as the root context (first arg)
378
567
  // 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;
568
+ if (field.admin?.condition) {
569
+ if (typeof field.admin.condition === "function") {
570
+ try {
571
+ // Compatibility wrapper: pass { values: formData, ...formData } to support both old and new signatures
572
+ const evalData = { values: formData || {}, ...(formData || {}) };
573
+ const shouldShow = field.admin.condition(evalData, currentData);
574
+ if (!shouldShow) {
575
+ return null;
576
+ }
577
+ } catch (e) {
578
+ console.warn(`Condition error for field ${field.name}:`, e);
579
+ // Show the field if there's an error evaluating the condition
580
+ }
581
+ } else if (typeof field.admin.condition === "object") {
582
+ try {
583
+ const cond = field.admin.condition as any;
584
+ const targetField = cond.field;
585
+
586
+ // Get target field value, prioritizing sibling context (currentData) then root context (formData)
587
+ const val = (currentData && currentData[targetField] !== undefined)
588
+ ? currentData[targetField]
589
+ : (formData && formData[targetField] !== undefined ? formData[targetField] : undefined);
590
+
591
+ let shouldShow = true;
592
+ if ("equals" in cond) {
593
+ shouldShow = val === cond.equals;
594
+ } else if ("notEquals" in cond) {
595
+ shouldShow = val !== cond.notEquals;
596
+ } else if ("in" in cond && Array.isArray(cond.in)) {
597
+ shouldShow = cond.in.includes(val);
598
+ }
599
+
600
+ if (!shouldShow) {
601
+ return null;
602
+ }
603
+ } catch (e) {
604
+ console.warn(`Declarative condition error for field ${field.name}:`, e);
384
605
  }
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
606
  }
389
607
  }
390
608
 
@@ -399,7 +617,7 @@ export function AutoForm({
399
617
  }
400
618
  };
401
619
 
402
- if (field.type === "row" && "fields" in field) {
620
+ if (field.type === "row" && "fields" in field) {
403
621
  const rowFields = (field as Field & { fields?: Field[] }).fields;
404
622
  return (
405
623
  <div
@@ -410,7 +628,7 @@ if (field.type === "row" && "fields" in field) {
410
628
  const fAdmin = f.admin || {};
411
629
  const actionUrl = fAdmin?.action as string | undefined;
412
630
 
413
- if (f.type === "button" && actionUrl) {
631
+ if ((f.type === "button" || f.type === "action") && actionUrl) {
414
632
  const siblingEmailField = rowFields?.find(
415
633
  (ff: Field) => ff.type === "email",
416
634
  );
@@ -465,8 +683,8 @@ if (field.type === "row" && "fields" in field) {
465
683
  }
466
684
  }}
467
685
  //@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"
686
+ disabled={loadingFields[f.name as string] || effectiveDisabled}
687
+ className="kyro-btn kyro-btn-primary px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
470
688
  >
471
689
  {loadingFields[f.name as string] ? "Sending..." : f.label || "Click"}
472
690
  </button>
@@ -477,7 +695,7 @@ if (field.type === "row" && "fields" in field) {
477
695
  return (
478
696
  <div
479
697
  key={f.name}
480
- className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
698
+ className={f.type === "button" || f.type === "action" ? "flex-shrink-0" : "flex-1"}
481
699
  style={
482
700
  fAdmin?.width ? { width: fAdmin.width as string, flex: "none" } : {}
483
701
  }
@@ -497,11 +715,10 @@ if (field.type === "row" && "fields" in field) {
497
715
  key={field.name || `tabs-${Math.random()}`}
498
716
  field={field}
499
717
  formData={formData}
500
- onTabDataChange={(newTabData) => {
501
- const updateTabData = useAutoFormStore.getState().updateTabData;
502
- updateTabData(field.name as string, newTabData);
718
+ onTabDataChange={(tabData) => {
719
+ setField(field.name!, tabData);
503
720
  }}
504
- renderField={renderField}
721
+ renderField={(f, parentData, onChange) => renderField(f, parentData, onChange)}
505
722
  />
506
723
  );
507
724
 
@@ -524,19 +741,20 @@ if (field.type === "row" && "fields" in field) {
524
741
  value={value as unknown[]}
525
742
  onChange={onFieldChange}
526
743
  renderField={renderField}
527
- disabled={disabled}
744
+ disabled={effectiveDisabled}
528
745
  />
529
746
  );
530
747
 
531
748
 
532
- case "button": {
749
+ case "button":
750
+ case "action": {
533
751
  const fieldName = field.name as string;
534
752
  const isLoading = loadingFields[fieldName];
535
753
  return (
536
754
  <div key={fieldName} className="kyro-form-field">
537
755
  <button
538
756
  type="button"
539
- disabled={isLoading || disabled}
757
+ disabled={isLoading || effectiveDisabled}
540
758
  onClick={async () => {
541
759
  const action = (field.admin?.action || (field as Record<string, unknown>).action) as string | undefined;
542
760
  const method =
@@ -630,7 +848,7 @@ if (field.type === "row" && "fields" in field) {
630
848
  value={value}
631
849
  onChange={onFieldChange}
632
850
  error={error}
633
- disabled={disabled}
851
+ disabled={effectiveDisabled}
634
852
  />
635
853
  );
636
854
  }
@@ -638,13 +856,13 @@ if (field.type === "row" && "fields" in field) {
638
856
 
639
857
  const renderHeader = () => {
640
858
  const docTitle = String(
641
- (formData.mainTabs as { title?: string })?.title ||
642
- formData.title ||
643
- formData.name ||
859
+ (formData.mainTabs as { title?: string })?.title ||
860
+ (typeof formData.title === "object" ? "" : formData.title) ||
861
+ (typeof formData.name === "object" ? "" : formData.name) ||
644
862
  "Untitled",
645
- );
646
- // Use _status from the document (set by the new draft/publish system)
647
- const docStatus = documentStatus ?? formData._status ?? formData.status ?? 'draft';
863
+ );
864
+ // Use status from the document (merged from version table on draft reads)
865
+ const docStatus = documentStatus ?? formData.status ?? 'draft';
648
866
  const isNew = !formData.id;
649
867
  const lastModified = formData.updatedAt
650
868
  ? new Date(formData.updatedAt as string).toLocaleString()
@@ -653,52 +871,44 @@ if (field.type === "row" && "fields" in field) {
653
871
  ? new Date(formData.createdAt as string).toLocaleString()
654
872
  : "Just now";
655
873
 
874
+ const isDraftMode = !formData.id || documentStatus === 'draft';
875
+
656
876
  // Status label shown in the header
657
877
  const statusLabel = hasUnpublishedChanges
658
- ? docStatus === 'draft' && !formData._prevStatus
659
- ? 'Draft'
660
- : 'Published (unpublished changes)'
878
+ ? 'Draft (unpublished changes)'
661
879
  : docStatus === 'published'
662
880
  ? 'Published'
663
881
  : 'Draft';
664
882
 
665
- const statusColor = docStatus === 'published' && !hasUnpublishedChanges
883
+ const statusColor = docStatus === 'published' && !hasUnsavedChanges
666
884
  ? 'bg-[var(--kyro-success)]'
667
885
  : hasUnpublishedChanges
668
886
  ? 'bg-[var(--kyro-warning)]'
669
887
  : 'bg-[var(--kyro-text-muted)]';
670
888
 
889
+ const statusBadgeBg = docStatus === 'published' && !hasUnpublishedChanges
890
+ ? 'bg-[var(--kyro-success)]/10 text-[var(--kyro-success)] border-[var(--kyro-success)]/20'
891
+ : hasUnpublishedChanges
892
+ ? 'bg-[var(--kyro-warning)]/10 text-[var(--kyro-warning)] border-[var(--kyro-warning)]/20'
893
+ : 'bg-[var(--kyro-text-muted)]/10 text-[var(--kyro-text-muted)] border-[var(--kyro-text-muted)]/20';
894
+
671
895
  return (
672
896
  <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">
897
+ <div className="flex flex-col gap-2">
898
+ <div className="flex items-center gap-3">
675
899
  <a
676
900
  href={`/${collectionSlug}`}
677
901
  className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
678
902
  >
679
- <svg
680
- className="w-4 h-4"
681
- fill="none"
682
- stroke="currentColor"
683
- viewBox="0 0 24 24"
684
- >
685
- <path
686
- strokeLinecap="round"
687
- strokeLinejoin="round"
688
- strokeWidth="2.5"
689
- d="M15 19l-7-7 7-7"
690
- />
691
- </svg>
903
+ <ChevronRight className="w-4 h-4" />
692
904
  </a>
693
905
  <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
- />
906
+ <span className={`inline-flex items-center gap-1.5 px-2 rounded-full text-[10px] font-regular border ${statusBadgeBg}`}>
907
+ <span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
700
908
  {statusLabel}
701
909
  </span>
910
+ </div>
911
+ <div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
702
912
  {autoSaveStatus === "saving" && (
703
913
  <span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
704
914
  <svg
@@ -725,26 +935,63 @@ if (field.type === "row" && "fields" in field) {
725
935
  )}
726
936
  {autoSaveStatus === "success" && (
727
937
  <span className="text-[var(--kyro-success)] flex items-center gap-1">
728
- <svg
729
- width="12"
730
- height="12"
731
- viewBox="0 0 24 24"
732
- fill="none"
733
- stroke="currentColor"
734
- strokeWidth="3"
735
- >
736
- <path d="M20 6L9 17l-5-5" />
938
+ <Check className="w-4 h-4" />
939
+ {lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Draft saved"}
940
+ </span>
941
+ )}
942
+ {autoSaveStatus === "retrying" && (
943
+ <span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
944
+ <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
945
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
946
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
737
947
  </svg>
738
- Draft saved
948
+ Retrying save ({retryCount}/5)
949
+ </span>
950
+ )}
951
+ {autoSaveStatus === "offline" && (
952
+ <span className="text-[var(--kyro-text-muted)] flex items-center gap-1.5">
953
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
954
+ <path d="M10.61 10.61a3 3 0 0 0 4.24 4.24" />
955
+ <path d="M13.36 13.36a3 3 0 0 0-4.24-4.24" />
956
+ <path d="m2 2 20 20" />
957
+ <path d="M18.36 5.64a9 9 0 0 0-12.72 0" />
958
+ <path d="M22.61 1.39a15 15 0 0 0-21.22 0" />
959
+ </svg>
960
+ Offline — cached locally
739
961
  </span>
740
962
  )}
741
963
  {autoSaveStatus === "error" && (
742
964
  <span className="text-[var(--kyro-danger)]">Draft save failed</span>
743
965
  )}
744
966
  {autoSaveStatus === "conflict" && (
745
- <span className="text-[var(--kyro-danger)]">Conflict detected</span>
967
+ <div className="flex items-center gap-3">
968
+ <span className="text-[var(--kyro-danger)] font-semibold">Conflict detected</span>
969
+ <span className="opacity-30">—</span>
970
+ <button
971
+ type="button"
972
+ onClick={async () => {
973
+ // Keep mine: force save and overwrite server
974
+ await saveDocument(formData);
975
+ setAutoSaveStatus("success");
976
+ }}
977
+ className="text-[var(--kyro-primary)] hover:underline"
978
+ >
979
+ Keep my changes
980
+ </button>
981
+ <span className="opacity-30">|</span>
982
+ <button
983
+ type="button"
984
+ onClick={() => {
985
+ // Reload server version
986
+ window.location.reload();
987
+ }}
988
+ className="text-[var(--kyro-danger)] hover:underline"
989
+ >
990
+ Reload server version
991
+ </button>
992
+ </div>
746
993
  )}
747
- {hasUnsavedChanges && autoSaveStatus !== "saving" && (
994
+ {hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
748
995
  <>
749
996
  <span className="opacity-30">—</span>
750
997
  <button
@@ -752,7 +999,6 @@ if (field.type === "row" && "fields" in field) {
752
999
  onClick={async () => {
753
1000
  setFormData(lastSavedData);
754
1001
  markSaved();
755
- await clearDraftArtifacts();
756
1002
  }}
757
1003
  className="text-[var(--kyro-primary)] hover:underline"
758
1004
  >
@@ -760,6 +1006,19 @@ if (field.type === "row" && "fields" in field) {
760
1006
  </button>
761
1007
  </>
762
1008
  )}
1009
+ {/* Live auto-save timestamp */}
1010
+ {lastSavedAt && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "success" && (
1011
+ <span className="border-l border-[var(--kyro-border)] pl-4">
1012
+ Draft saved {(() => {
1013
+ const diffMs = now - lastSavedAt;
1014
+ const diffMin = Math.floor(diffMs / 60_000);
1015
+ const diffSec = Math.floor(diffMs / 1_000);
1016
+ if (diffMin >= 1) return `${diffMin}m ago`;
1017
+ if (diffSec >= 5) return `${diffSec}s ago`;
1018
+ return "just now";
1019
+ })()}
1020
+ </span>
1021
+ )}
763
1022
  <span className="border-l border-[var(--kyro-border)] pl-4">
764
1023
  Modified {lastModified}
765
1024
  </span>
@@ -789,19 +1048,10 @@ if (field.type === "row" && "fields" in field) {
789
1048
  <button
790
1049
  type="button"
791
1050
  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)]"}`}
1051
+ 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
1052
  title="Live Preview"
794
1053
  >
795
- <svg
796
- width="20"
797
- height="20"
798
- viewBox="0 0 24 24"
799
- fill="none"
800
- stroke="currentColor"
801
- strokeWidth="2"
802
- >
803
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
804
- </svg>
1054
+ <ExternalLink className="w-4 h-4" />
805
1055
  {showPreview && (
806
1056
  <span className="text-[10px] font-bold tracking-widest pr-1">
807
1057
  Active
@@ -813,7 +1063,7 @@ if (field.type === "row" && "fields" in field) {
813
1063
  onClick={() => {
814
1064
  window.dispatchEvent(new CustomEvent("toggle-sidebar"));
815
1065
  }}
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)]"}`}
1066
+ className={`kyro-btn p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
817
1067
  title="Toggle Sidebar"
818
1068
  >
819
1069
  <svg
@@ -829,282 +1079,138 @@ if (field.type === "row" && "fields" in field) {
829
1079
  </svg>
830
1080
  </button>
831
1081
 
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;
1082
+ {/* ── Publish button (no dropdown) ──────────────────────────────── */}
1083
+ <SplitButton
1084
+ status={documentStatus as SplitButtonStatus}
1085
+ saveStatus={localSaveStatus}
1086
+ hasChanges={hasUnsavedChanges}
1087
+ onPublish={handlePublish}
1088
+ disabled={localSaveStatus === "saving"}
1089
+ />
854
1090
 
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);
1091
+ {/* ── Kebab: document management actions ───────────────────────── */}
1092
+ {!isNew && (
1093
+ <Dropdown
1094
+ trigger={
1095
+ <button
1096
+ type="button"
1097
+ className="kyro-btn p-2.5 rounded-xl border border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)] transition-all"
1098
+ title="More actions"
1099
+ >
1100
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
1101
+ <circle cx="12" cy="5" r="1.5" />
1102
+ <circle cx="12" cy="12" r="1.5" />
1103
+ <circle cx="12" cy="19" r="1.5" />
1104
+ </svg>
1105
+ </button>
1106
+ }
1107
+ direction="down"
1108
+ >
1109
+ {!globalSlug && (
1110
+ <DropdownItem
1111
+ onClick={handleCreateNew}
1112
+ icon={
1113
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1114
+ <line x1="12" y1="5" x2="12" y2="19" />
1115
+ <line x1="5" y1="12" x2="19" y2="12" />
1116
+ </svg>
884
1117
  }
885
- } else {
886
- const error = await response.json();
887
- if (response.status === 409) {
888
- setAutoSaveStatus("conflict");
1118
+ >
1119
+ Create New
1120
+ </DropdownItem>
1121
+ )}
1122
+ {!globalSlug && (
1123
+ <DropdownItem
1124
+ onClick={handleDuplicate}
1125
+ icon={
1126
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1127
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
1128
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
1129
+ </svg>
889
1130
  }
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");
1131
+ >
1132
+ Duplicate
1133
+ </DropdownItem>
1134
+ )}
1135
+ <DropdownItem
1136
+ onClick={() => setShowSchedulePicker(true)}
1137
+ icon={
1138
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1139
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
1140
+ <line x1="16" y1="2" x2="16" y2="6" />
1141
+ <line x1="8" y1="2" x2="8" y2="6" />
1142
+ <line x1="3" y1="10" x2="21" y2="10" />
1143
+ </svg>
926
1144
  }
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);
1145
+ >
1146
+ Schedule Publish
1147
+ </DropdownItem>
1148
+ {documentStatus === "published" && (
1149
+ <DropdownItem
1150
+ onClick={handleUnpublish}
1151
+ icon={
1152
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1153
+ <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" />
1154
+ <line x1="1" y1="1" x2="23" y2="23" />
1155
+ </svg>
949
1156
  }
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");
1157
+ >
1158
+ Unpublish
1159
+ </DropdownItem>
1160
+ )}
1161
+ {!globalSlug && (
1162
+ <>
1163
+ <DropdownSeparator />
1164
+ <DropdownItem
1165
+ onClick={handleDelete}
1166
+ danger
1167
+ icon={
1168
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1169
+ <polyline points="3 6 5 6 21 6" />
1170
+ <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" />
1171
+ </svg>
961
1172
  }
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>
1173
+ >
1174
+ Delete
1175
+ </DropdownItem>
1176
+ </>
1177
+ )}
1178
+ </Dropdown>
984
1179
  )}
985
1180
 
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" />
1003
- </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">
1007
- <button
1008
- 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"
1014
- >
1015
- <svg
1016
- width="16"
1017
- height="16"
1018
- viewBox="0 0 24 24"
1019
- fill="none"
1020
- stroke="currentColor"
1021
- strokeWidth="2"
1181
+
1182
+ {showSchedulePicker && (
1183
+ <div ref={scheduleRef} className="relative">
1184
+ <div className="absolute right-0 top-2 p-4 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50">
1185
+ <p className="text-xs font-medium mb-2">Schedule Publish</p>
1186
+ <input
1187
+ type="datetime-local"
1188
+ id="schedule-datetime"
1189
+ className="kyro-form-input text-xs mb-3 w-full"
1190
+ min={new Date().toISOString().slice(0, 16)}
1191
+ />
1192
+ <div className="flex items-center gap-2 justify-end">
1193
+ <button
1194
+ type="button"
1195
+ onClick={() => setShowSchedulePicker(false)}
1196
+ className="px-3 py-1.5 text-xs kyro-btn rounded-lg"
1022
1197
  >
1023
- <line x1="12" y1="5" x2="12" y2="19"></line>
1024
- <line x1="5" y1="12" x2="19" y2="12"></line>
1025
- </svg>
1026
- Create New
1027
- </button>
1028
- {!isNew && (
1029
- <>
1030
- <button
1031
- type="button"
1032
- onClick={() => {
1033
- handleDuplicate();
1034
- setIsMenuOpen(false);
1035
- }}
1036
- 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
- >
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>
1055
- </svg>
1056
- Duplicate
1057
- </button>
1058
- {formData._status === "published" && (
1059
- <button
1060
- type="button"
1061
- onClick={() => {
1062
- handleUnpublish();
1063
- setIsMenuOpen(false);
1064
- }}
1065
- 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
- >
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>
1077
- </svg>
1078
- Unpublish
1079
- </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"
1097
- >
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
- )}
1198
+ Cancel
1199
+ </button>
1200
+ <button
1201
+ type="button"
1202
+ onClick={() => {
1203
+ const val = (document.getElementById("schedule-datetime") as HTMLInputElement)?.value;
1204
+ if (val) handleSchedulePublish(val);
1205
+ }}
1206
+ className="px-3 py-1.5 text-xs kyro-btn-success rounded-lg"
1207
+ >
1208
+ Schedule
1209
+ </button>
1210
+ </div>
1105
1211
  </div>
1106
- )}
1107
- </div>
1212
+ </div>
1213
+ )}
1108
1214
  </div>
1109
1215
  </div>
1110
1216
  </header>
@@ -1207,7 +1313,7 @@ if (field.type === "row" && "fields" in field) {
1207
1313
  type="button"
1208
1314
  onClick={handleCompareVersions}
1209
1315
  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"
1316
+ 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
1317
  >
1212
1318
  {loadingDiffs ? "Comparing..." : "Compare"}
1213
1319
  </button>
@@ -1241,15 +1347,7 @@ if (field.type === "row" && "fields" in field) {
1241
1347
  onClick={() => setCompareDiffs([])}
1242
1348
  className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
1243
1349
  >
1244
- <svg
1245
- className="w-3.5 h-3.5"
1246
- viewBox="0 0 24 24"
1247
- fill="none"
1248
- stroke="currentColor"
1249
- strokeWidth="2.5"
1250
- >
1251
- <path d="M18 6L6 18M6 6l12 12" />
1252
- </svg>
1350
+ <X className="w-4 h-4" />
1253
1351
  </button>
1254
1352
  </div>
1255
1353
  <div className="max-h-[400px] overflow-y-auto">
@@ -1316,15 +1414,7 @@ if (field.type === "row" && "fields" in field) {
1316
1414
  }`}
1317
1415
  >
1318
1416
  {isSelected && (
1319
- <svg
1320
- className="w-full h-full text-white p-0.5"
1321
- viewBox="0 0 24 24"
1322
- fill="none"
1323
- stroke="currentColor"
1324
- strokeWidth="3"
1325
- >
1326
- <path d="M20 6L9 17l-5-5" />
1327
- </svg>
1417
+ <Check className="w-4 h-4" />
1328
1418
  )}
1329
1419
  </div>
1330
1420
  ) : (
@@ -1374,7 +1464,7 @@ if (field.type === "row" && "fields" in field) {
1374
1464
  <button
1375
1465
  type="button"
1376
1466
  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"
1467
+ 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
1468
  >
1379
1469
  Restore
1380
1470
  </button>
@@ -1464,16 +1554,7 @@ if (field.type === "row" && "fields" in field) {
1464
1554
  className={`w-4 h-4 rounded border transition-all flex items-center justify-center ${item.checked ? "bg-[var(--kyro-primary)] border-[var(--kyro-primary)]" : "border-[var(--kyro-border)] group-hover:border-[var(--kyro-text-secondary)]"}`}
1465
1555
  >
1466
1556
  {item.checked && (
1467
- <svg
1468
- width="10"
1469
- height="10"
1470
- viewBox="0 0 24 24"
1471
- fill="none"
1472
- stroke="white"
1473
- strokeWidth="4"
1474
- >
1475
- <path d="M20 6L9 17l-5-5" />
1476
- </svg>
1557
+ <Check className="w-4 h-4" />
1477
1558
  )}
1478
1559
  </div>
1479
1560
  <span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
@@ -1507,42 +1588,74 @@ if (field.type === "row" && "fields" in field) {
1507
1588
  <div className="flex flex-col h-full">
1508
1589
  {layout !== "single" && renderHeader()}
1509
1590
  {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);
1591
+ <>
1592
+ <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
1593
+ <div className="flex items-center gap-3 text-[11px] font-medium">
1594
+ {autoSaveStatus === "saving" && (
1595
+ <span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
1596
+ <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
1597
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
1598
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
1599
+ </svg>
1600
+ Saving...
1601
+ </span>
1602
+ )}
1603
+ {autoSaveStatus === "success" && (
1604
+ <span className="text-[var(--kyro-success)] flex items-center gap-1">
1605
+ <Check className="w-4 h-4" />
1606
+ {lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Saved"}
1607
+ </span>
1608
+ )}
1609
+ {autoSaveStatus === "retrying" && (
1610
+ <span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
1611
+ <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
1612
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
1613
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
1614
+ </svg>
1615
+ Retrying...
1616
+ </span>
1617
+ )}
1618
+ {autoSaveStatus === "offline" && (
1619
+ <span className="text-[var(--kyro-text-muted)]">Offline — cached locally</span>
1620
+ )}
1621
+ {autoSaveStatus === "error" && (
1622
+ <span className="text-[var(--kyro-danger)]">Save failed</span>
1623
+ )}
1624
+ {autoSaveStatus === "conflict" && (
1625
+ <span className="text-[var(--kyro-danger)]">Conflict detected</span>
1626
+ )}
1627
+ {hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
1628
+ <span className="text-[var(--kyro-warning)]">Unsaved changes</span>
1629
+ )}
1630
+ {!hasUnsavedChanges && autoSaveStatus !== "success" && autoSaveStatus !== "saving" && autoSaveStatus !== "error" && (
1631
+ <span className="text-[var(--kyro-success)]">All changes saved</span>
1632
+ )}
1633
+ </div>
1634
+ <span className="text-[11px] text-[var(--kyro-text-muted)] opacity-60">
1635
+ {formData.updatedAt ? `Modified ${new Date(formData.updatedAt as string).toLocaleString()}` : ""}
1636
+ </span>
1637
+ </div>
1638
+ <button
1639
+ id="btn-save"
1640
+ type="button"
1641
+ style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
1642
+ onClick={async () => {
1643
+ try {
1644
+ const response = await saveDocument();
1525
1645
  if (response.ok) {
1526
1646
  const result = await response.json();
1527
- const savedData = result.data || data;
1528
- setFormData(savedData);
1529
- setLastSavedData(savedData);
1647
+ const savedData = result.data || formData;
1648
+ setFormData({ ...formData, ...savedData });
1649
+ setLastSavedData({ ...formData, ...savedData });
1530
1650
  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
1651
  }
1540
- } catch (e) {
1541
- console.error("Save error exception:", e);
1542
- onActionError?.("Save failed: " + (e as Error).message);
1543
- }
1544
- }}
1545
- />
1652
+ } catch (e) {
1653
+ console.error("Save error exception:", e);
1654
+ onActionError?.("Save failed: " + (e as Error).message);
1655
+ }
1656
+ }}
1657
+ />
1658
+ </>
1546
1659
  )}
1547
1660
  <main className="w-full">
1548
1661
  {view === "edit" && renderEditView()}
@@ -1596,7 +1709,7 @@ function RelationshipField({
1596
1709
  fetchOptions();
1597
1710
  }, [targetCollection]);
1598
1711
 
1599
- const getLabel = (opt: unknown) => {
1712
+ const getLabel = (opt: unknown) => {
1600
1713
  const o = opt as Record<string, unknown> | undefined;
1601
1714
  if (!o) return "";
1602
1715
  return String(
@@ -1658,13 +1771,13 @@ const getLabel = (opt: unknown) => {
1658
1771
 
1659
1772
  const filteredOptions = (search
1660
1773
  ? (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
- })
1774
+ const o = opt as Record<string, unknown>;
1775
+ const term = search.toLowerCase();
1776
+ const searchableFields = ["title", "name", "label", "filename", "slug"];
1777
+ return searchableFields.some(
1778
+ (key) => o[key] && String(o[key]).toLowerCase().includes(term),
1779
+ );
1780
+ })
1668
1781
  : options || []) as Array<Record<string, unknown>>;
1669
1782
 
1670
1783
  return (
@@ -1727,9 +1840,7 @@ const getLabel = (opt: unknown) => {
1727
1840
  {loading ? (
1728
1841
  <div className="kyro-relation-modal-empty">Loading...</div>
1729
1842
  ) : filteredOptions.length === 0 ? (
1730
- <div className="kyro-relation-modal-empty">
1731
- No results found.
1732
- </div>
1843
+ <EmptyState title="No results found." />
1733
1844
  ) : (
1734
1845
  filteredOptions.map((opt) => {
1735
1846
  const o = opt as { id?: string };