@kyro-cms/admin 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -568,7 +568,7 @@ export function AutoForm({
568
568
  const result = await response.json();
569
569
  const savedData = result.data || data;
570
570
  setFormData({ ...formData, ...savedData });
571
- setLastSavedData({ ...formData, ...savedData });
571
+ markSaved();
572
572
  lastAutoSaveTimeRef.current = Date.now();
573
573
  setAutoSaveStatus("success");
574
574
  setLocalSaveStatus("saved");
@@ -631,7 +631,7 @@ export function AutoForm({
631
631
  const result = await response.json();
632
632
  const savedData = result.data || data;
633
633
  setFormData({ ...formData, ...savedData });
634
- setLastSavedData({ ...formData, ...savedData });
634
+ markSaved();
635
635
  dataToPublish = { ...formData, ...savedData };
636
636
  }
637
637
 
@@ -640,8 +640,17 @@ export function AutoForm({
640
640
  const response = await saveDocument(data, false);
641
641
 
642
642
  if (response?.ok) {
643
+ const result = await response.json().catch(() => ({}));
644
+ const savedData = result.data || data;
645
+ setFormData({ ...formData, ...savedData });
646
+ markSaved();
643
647
  setLocalSaveStatus("saved");
644
648
  onActionSuccess?.("Published successfully");
649
+
650
+ setTimeout(() => {
651
+ setLocalSaveStatus("idle");
652
+ }, 2000);
653
+
645
654
  if (isNewDoc && !globalSlug && dataToPublish.id) {
646
655
  setTimeout(() => {
647
656
  window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { ChevronDown, ChevronUp, Plus, X } from "../ui/icons";
1
+ import React, { useState, useCallback } from "react";
2
+ import { ChevronDown, ChevronUp, Plus, X, Copy, ClipboardPaste, Check } from "../ui/icons";
3
3
 
4
4
  interface AccordionItem {
5
5
  title: string;
@@ -17,7 +17,56 @@ export const AccordionField: React.FC<AccordionFieldProps> = ({
17
17
  onChange,
18
18
  compact = false,
19
19
  }) => {
20
- const [openIndex, setOpenIndex] = React.useState<number | null>(0);
20
+ const [openIndex, setOpenIndex] = useState<number | null>(0);
21
+ const [copied, setCopied] = useState(false);
22
+
23
+ const handleCopy = useCallback(async (e: React.MouseEvent) => {
24
+ e.preventDefault();
25
+ e.stopPropagation();
26
+ try {
27
+ const payload = JSON.stringify({ __kyro_accordion: true, items });
28
+ await navigator.clipboard.writeText(payload);
29
+ setCopied(true);
30
+ setTimeout(() => setCopied(false), 2000);
31
+ } catch (err) {
32
+ console.error("Failed to copy", err);
33
+ }
34
+ }, [items]);
35
+
36
+ const handlePaste = useCallback(async (e: React.MouseEvent) => {
37
+ e.preventDefault();
38
+ e.stopPropagation();
39
+ try {
40
+ const text = await navigator.clipboard.readText();
41
+ const parsed = JSON.parse(text);
42
+ if (parsed && parsed.__kyro_accordion && Array.isArray(parsed.items)) {
43
+ onChange([...items, ...parsed.items]);
44
+ }
45
+ } catch (err) {
46
+ console.error("Failed to paste", err);
47
+ }
48
+ }, [items, onChange]);
49
+
50
+ const headerControls = (
51
+ <div className="flex justify-end gap-1 mb-2">
52
+ <button
53
+ type="button"
54
+ onClick={handleCopy}
55
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
56
+ title="Copy Items"
57
+ >
58
+ {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
59
+ </button>
60
+ <button
61
+ type="button"
62
+ onClick={handlePaste}
63
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
64
+ title="Paste Items"
65
+ >
66
+ <ClipboardPaste className="w-3.5 h-3.5" />
67
+ </button>
68
+ </div>
69
+ );
21
70
 
22
71
  const handleTitleChange = (index: number, value: string) => {
23
72
  const newItems = [...items];
@@ -52,6 +101,7 @@ export const AccordionField: React.FC<AccordionFieldProps> = ({
52
101
  if (compact) {
53
102
  return (
54
103
  <div className="space-y-2">
104
+ {headerControls}
55
105
  {items.length === 0 ? (
56
106
  <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
57
107
  No items. Click "Add Item" to create one.
@@ -134,6 +184,7 @@ export const AccordionField: React.FC<AccordionFieldProps> = ({
134
184
 
135
185
  return (
136
186
  <div className="space-y-2">
187
+ {headerControls}
137
188
  {items.length === 0 ? (
138
189
  <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
139
190
  No items. Click "Add Item" to create one.
@@ -14,6 +14,8 @@ import {
14
14
  import { Check, ClipboardCopy } from "lucide-react";
15
15
  import { GenericBlock } from "../blocks/GenericBlock";
16
16
  import { BlockEditModal } from "../blocks/BlockEditModal";
17
+ import { deepEqual } from "../../lib/deep-equal";
18
+ import { toast } from "../../lib/stores";
17
19
  import {
18
20
 
19
21
  DndContext,
@@ -499,7 +501,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
499
501
  const lastValueArray = lastValueRef.current || [];
500
502
 
501
503
  // Deep compare to catch external data changes (e.g. discard draft / auto-save restore)
502
- if (JSON.stringify(valueArray) !== JSON.stringify(lastValueArray)) {
504
+ if (!deepEqual(valueArray, lastValueArray)) {
503
505
  const valueArrayCopy = [...valueArray];
504
506
  prevBlocksLengthRef.current = valueArrayCopy.length;
505
507
  prevBlockIdsRef.current = new Set(valueArrayCopy.map((b: Record<string, unknown>) => b.id));
@@ -521,7 +523,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
521
523
  if (!lastValue) return; // Wait until initialized
522
524
 
523
525
  // Deep compare blocks vs lastValue to detect content edits, not just ID changes
524
- if (JSON.stringify(blocks) !== JSON.stringify(lastValue)) {
526
+ if (!deepEqual(blocks, lastValue)) {
525
527
  lastValueRef.current = [...blocks]; // Update ref BEFORE firing onChange to prevent loops
526
528
  onChangeRef.current(blocks);
527
529
  }
@@ -569,6 +571,33 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
569
571
  setIsDrawerOpen(false);
570
572
  }, [field.blocks, blocks, store]);
571
573
 
574
+ const handleContainerPaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
575
+ const activeEl = document.activeElement;
576
+ if (
577
+ activeEl &&
578
+ (activeEl.tagName === "INPUT" ||
579
+ activeEl.tagName === "TEXTAREA" ||
580
+ activeEl.getAttribute("contenteditable") === "true")
581
+ ) {
582
+ return;
583
+ }
584
+
585
+ const text = e.clipboardData?.getData("text");
586
+ if (!text) return;
587
+
588
+ try {
589
+ const parsed = JSON.parse(text);
590
+ if (parsed && parsed.__kyro_block) {
591
+ e.preventDefault();
592
+ e.stopPropagation();
593
+ handlePasteBlock(parsed);
594
+ toast.success(`Block pasted: ${parsed.type}`);
595
+ }
596
+ } catch {
597
+ // Ignore
598
+ }
599
+ }, [handlePasteBlock]);
600
+
572
601
  const duplicateBlock = useCallback(
573
602
  (blockId: string) => {
574
603
  const blockIndex = blocks.findIndex((b) => b.id === blockId);
@@ -689,7 +718,12 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
689
718
 
690
719
  return (
691
720
  <BlocksContext.Provider value={storeRef.current}>
692
- <div className="kyro-blocks-field">
721
+ <div
722
+ className="kyro-blocks-field"
723
+ onPaste={handleContainerPaste}
724
+ tabIndex={0}
725
+ style={{ outline: "none" }}
726
+ >
693
727
  <DndContext
694
728
  sensors={sensors}
695
729
  collisionDetection={closestCenter}
@@ -1,4 +1,6 @@
1
- import React from "react";
1
+ import React, { useState, useCallback } from "react";
2
+ import type { Field } from "@kyro-cms/core/client";
3
+ import { Copy, ClipboardPaste, Check } from "../ui/icons";
2
4
  import type { Field } from "@kyro-cms/core/client";
3
5
 
4
6
  interface GroupLayoutProps {
@@ -19,12 +21,60 @@ export function GroupLayout({
19
21
  renderField,
20
22
  }: GroupLayoutProps) {
21
23
  const groupData = value || {};
24
+ const [copied, setCopied] = useState(false);
25
+
26
+ const handleCopy = useCallback(async (e: React.MouseEvent) => {
27
+ e.preventDefault();
28
+ e.stopPropagation();
29
+ try {
30
+ const payload = JSON.stringify({ __kyro_container: true, value: groupData });
31
+ await navigator.clipboard.writeText(payload);
32
+ setCopied(true);
33
+ setTimeout(() => setCopied(false), 2000);
34
+ } catch (err) {
35
+ console.error("Failed to copy", err);
36
+ }
37
+ }, [groupData]);
38
+
39
+ const handlePaste = useCallback(async (e: React.MouseEvent) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ try {
43
+ const text = await navigator.clipboard.readText();
44
+ const parsed = JSON.parse(text);
45
+ if (parsed && (parsed.__kyro_group || parsed.__kyro_container) && parsed.value) {
46
+ onChange({ ...groupData, ...parsed.value });
47
+ }
48
+ } catch (err) {
49
+ console.error("Failed to paste", err);
50
+ }
51
+ }, [groupData, onChange]);
22
52
 
23
53
  return (
24
54
  <div className="kyro-form-group border border-[var(--kyro-border)] rounded-[var(--kyro-radius-lg)] p-6 bg-[var(--kyro-surface-accent)]/30">
25
- <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
26
- {field.label || field.name}
27
- </h3>
55
+ <div className="flex items-center justify-between mb-6 border-b border-[var(--kyro-border)] pb-2">
56
+ <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)]">
57
+ {field.label || field.name}
58
+ </h3>
59
+ <div className="flex items-center gap-1">
60
+ <button
61
+ type="button"
62
+ onClick={handleCopy}
63
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
64
+ title="Copy Group Data"
65
+ >
66
+ {copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
67
+ </button>
68
+ <button
69
+ type="button"
70
+ onClick={handlePaste}
71
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
72
+ title="Paste Group Data"
73
+ >
74
+ <ClipboardPaste className="w-4 h-4" />
75
+ </button>
76
+ </div>
77
+ </div>
28
78
  <div className={field.admin?.inline ? "flex items-start gap-4" : "space-y-6"}>
29
79
  {(field as Field & { fields?: Field[] }).fields.map((f: Field) =>
30
80
  renderField(f, groupData, onChange),
@@ -1,6 +1,7 @@
1
- import React, { useState } from "react";
1
+ import React, { useState, useCallback } from "react";
2
2
  import type { Field } from "@kyro-cms/core/client";
3
3
  import { SeoPreview } from "../ui/SeoPreview";
4
+ import { Copy, ClipboardPaste, Check } from "../ui/icons";
4
5
 
5
6
  interface TabsLayoutProps {
6
7
  field: Field;
@@ -29,22 +30,72 @@ export function TabsLayout({
29
30
  ? (formData[field.name] as Record<string, unknown>) || {}
30
31
  : formData;
31
32
 
33
+ const [copied, setCopied] = useState(false);
34
+
35
+ const handleCopy = useCallback(async (e: React.MouseEvent) => {
36
+ e.preventDefault();
37
+ e.stopPropagation();
38
+ try {
39
+ const payload = JSON.stringify({ __kyro_container: true, value: tabData });
40
+ await navigator.clipboard.writeText(payload);
41
+ setCopied(true);
42
+ setTimeout(() => setCopied(false), 2000);
43
+ } catch (err) {
44
+ console.error("Failed to copy", err);
45
+ }
46
+ }, [tabData]);
47
+
48
+ const handlePaste = useCallback(async (e: React.MouseEvent) => {
49
+ e.preventDefault();
50
+ e.stopPropagation();
51
+ try {
52
+ const text = await navigator.clipboard.readText();
53
+ const parsed = JSON.parse(text);
54
+ if (parsed && (parsed.__kyro_group || parsed.__kyro_container) && parsed.value) {
55
+ onTabDataChange({ ...tabData, ...parsed.value });
56
+ }
57
+ } catch (err) {
58
+ console.error("Failed to paste", err);
59
+ }
60
+ }, [tabData, onTabDataChange]);
61
+
32
62
  return (
33
63
  <div className="space-y-8">
34
- <div className="flex items-center gap-2 border-b border-[var(--kyro-border)] mb-6 overflow-x-auto hide-scrollbar">
35
- {fieldTabs.map((tab: { label: string }, index: number) => (
64
+ <div className="flex items-center justify-between border-b border-[var(--kyro-border)] mb-6">
65
+ <div className="flex items-center gap-2 overflow-x-auto hide-scrollbar">
66
+ {fieldTabs.map((tab: { label: string }, index: number) => (
67
+ <button
68
+ key={index}
69
+ type="button"
70
+ className={`px-6 py-3 text-sm tracking-widest font-medium transition-all border-b-2 -mb-[1px] whitespace-nowrap ${
71
+ activeTab === index
72
+ ? "border-[var(--kyro-primary)] text-[var(--kyro-primary)]"
73
+ : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] opacity-60 hover:opacity-100"
74
+ }`}
75
+ onClick={() => setActiveTab(index)}
76
+ >
77
+ {tab.label}
78
+ </button>
79
+ ))}
80
+ </div>
81
+ <div className="flex items-center gap-1 pr-2">
36
82
  <button
37
- key={index}
38
83
  type="button"
39
- className={`px-6 py-3 text-sm tracking-widest font-medium transition-all border-b-2 -mb-[1px] whitespace-nowrap ${activeTab === index
40
- ? "border-[var(--kyro-primary)] text-[var(--kyro-primary)]"
41
- : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] opacity-60 hover:opacity-100"
42
- }`}
43
- onClick={() => setActiveTab(index)}
84
+ onClick={handleCopy}
85
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
86
+ title="Copy Tab Data"
44
87
  >
45
- {tab.label}
88
+ {copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
46
89
  </button>
47
- ))}
90
+ <button
91
+ type="button"
92
+ onClick={handlePaste}
93
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
94
+ title="Paste Tab Data"
95
+ >
96
+ <ClipboardPaste className="w-4 h-4" />
97
+ </button>
98
+ </div>
48
99
  </div>
49
100
  <div className="space-y-6">
50
101
  {currentTab?.fields.map((f: Field) =>
@@ -96,6 +96,8 @@ export function UploadField({
96
96
  const currentValue = Array.isArray(value) ? value : value ? [value] : [];
97
97
  const canAddMore = currentValue.length < maxCount;
98
98
 
99
+ const [fetchedDetails, setFetchedDetails] = useState<Record<string, any>>({});
100
+
99
101
  useEffect(() => {
100
102
  const fetchMissingDetails = async () => {
101
103
  const idsToFetch = currentValue
@@ -111,37 +113,29 @@ export function UploadField({
111
113
  .map(item => item.id as string);
112
114
 
113
115
  const allIds = [...idsToFetch, ...objectIdsToFetch];
114
- if (allIds.length === 0) return;
116
+ const missingIds = allIds.filter(id => !fetchedDetails[id]);
117
+
118
+ if (missingIds.length === 0) return;
115
119
 
116
120
  try {
117
121
  const fetchedItems = await Promise.all(
118
- allIds.map(id => apiGet<any>(`/api/media/${id}`))
122
+ missingIds.map(id => apiGet<any>(`/api/media/${id}`))
119
123
  );
120
124
 
121
- const newItems = [...currentValue];
122
- fetchedItems.forEach(fetchedItem => {
123
- const id = fetchedItem.id as string;
124
- const strIndex = newItems.findIndex(item => item === id);
125
- if (strIndex !== -1) {
126
- newItems[strIndex] = fetchedItem;
127
- return;
128
- }
129
- const objIndex = newItems.findIndex(
130
- item => typeof item === 'object' && item !== null && (item as any).id === id
131
- );
132
- if (objIndex !== -1) {
133
- newItems[objIndex] = fetchedItem;
134
- }
125
+ setFetchedDetails(prev => {
126
+ const next = { ...prev };
127
+ fetchedItems.forEach(item => {
128
+ if (item && item.id) next[item.id] = item;
129
+ });
130
+ return next;
135
131
  });
136
-
137
- onChange(isMultiple ? newItems : newItems[0]);
138
132
  } catch (err) {
139
133
  console.error("Failed to fetch media details:", err);
140
134
  }
141
135
  };
142
136
 
143
137
  fetchMissingDetails();
144
- }, [value]);
138
+ }, [value, fetchedDetails]);
145
139
 
146
140
  useEffect(() => {
147
141
  if (showPicker) {
@@ -303,8 +297,10 @@ export function UploadField({
303
297
  );
304
298
  }
305
299
 
306
- const renderImagePreview = (img: any, index?: number) => {
307
- if (!img) return null;
300
+ const renderImagePreview = (rawImg: any, index?: number) => {
301
+ if (!rawImg) return null;
302
+ const id = typeof rawImg === 'string' ? rawImg : rawImg.id;
303
+ const img = fetchedDetails[id] || (typeof rawImg === 'object' ? rawImg : { id });
308
304
  const fileType = getFileType(img.mimeType, img.filename || img.url);
309
305
  const isImage = fileType === "image";
310
306
 
@@ -33,7 +33,7 @@ export function SplitButton({
33
33
  const isPublishedIdle =
34
34
  status === "published" && !hasChanges && saveStatus !== "saving" && saveStatus !== "error";
35
35
 
36
- const isDisabled = disabled || saveStatus === "saving" || isPublishedIdle;
36
+ const isDisabled = disabled || saveStatus === "saving" || isPublishedIdle || saveStatus === "saved";
37
37
 
38
38
  // ── button colour ──────────────────────────────────────────────────────────
39
39
  const btnBase =
@@ -41,7 +41,7 @@ export function SplitButton({
41
41
 
42
42
  const getBtnClass = () => {
43
43
  if (saveStatus === "saving") return `${btnBase} bg-[var(--kyro-primary)]/70 border-[var(--kyro-primary)]/70 text-[var(--kyro-sidebar-text-active)] cursor-wait`;
44
- if (saveStatus === "saved") return `${btnBase} bg-[var(--kyro-success)] border-[var(--kyro-success)] text-[var(--kyro-sidebar-text-active)]`;
44
+ if (saveStatus === "saved") return `${btnBase} bg-[var(--kyro-gray-200)] border-[var(--kyro-gray-200)] text-[var(--kyro-text-muted)] cursor-not-allowed`;
45
45
  if (saveStatus === "error") return `${btnBase} bg-[var(--kyro-error)] border-[var(--kyro-error)] text-[var(--kyro-sidebar-text-active)]`;
46
46
  if (isPublishedIdle) return `${btnBase} bg-[var(--kyro-gray-200)] border-[var(--kyro-gray-200)] text-[var(--kyro-text-muted)] cursor-not-allowed`;
47
47
  // has changes → accent
@@ -53,7 +53,7 @@ export function SplitButton({
53
53
 
54
54
  const getChevronClass = () => {
55
55
  if (saveStatus === "saving") return `${chevronBase} bg-[var(--kyro-primary)]/70 text-[var(--kyro-sidebar-text-active)] border-[var(--kyro-primary)]/70`;
56
- if (saveStatus === "saved") return `${chevronBase} bg-[var(--kyro-success)] text-[var(--kyro-sidebar-text-active)] border-[var(--kyro-success)]`;
56
+ if (saveStatus === "saved") return `${chevronBase} bg-[var(--kyro-gray-200)] text-[var(--kyro-text-muted)] border-[var(--kyro-gray-200)]`;
57
57
  if (saveStatus === "error") return `${chevronBase} bg-[var(--kyro-error)] text-[var(--kyro-sidebar-text-active)] border-[var(--kyro-error)]`;
58
58
  if (isPublishedIdle) return `${chevronBase} bg-[var(--kyro-gray-200)] text-[var(--kyro-text-muted)] border-[var(--kyro-gray-200)]`;
59
59
  return `${chevronBase} bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)] border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary-hover)]`;
@@ -62,7 +62,7 @@ export function SplitButton({
62
62
  // ── label ──────────────────────────────────────────────────────────────────
63
63
  const getLabel = () => {
64
64
  if (saveStatus === "saving") return "Publishing...";
65
- if (saveStatus === "saved") return "Published";
65
+ if (saveStatus === "saved") return "Published";
66
66
  if (saveStatus === "error") return "Retry";
67
67
  if (isPublishedIdle) return "Published";
68
68
  return "Publish Changes";
@@ -78,7 +78,7 @@ export function SplitButton({
78
78
  className={`${getBtnClass()} ${!children ? "rounded-r-lg border-r border-[var(--kyro-border)]" : ""}`}
79
79
  >
80
80
  {saveStatus === "saving" && <Spinner size="sm" className="inline mr-1.5" />}
81
- {isPublishedIdle && (
81
+ {(isPublishedIdle || saveStatus === "saved") && (
82
82
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="inline mr-1">
83
83
  <polyline points="20 6 9 17 4 12" />
84
84
  </svg>
@@ -4,6 +4,8 @@ import type { StateStorage } from "zustand/middleware";
4
4
  import { createStorage } from "unstorage";
5
5
  import indexedbDriver from "unstorage/drivers/indexedb";
6
6
  import type { Version } from "@kyro-cms/core/client";
7
+ import { deepEqual, isEmpty } from "./deep-equal";
8
+ import { normalizeUploadFields } from "./normalize-upload-fields";
7
9
 
8
10
  let storageInstance: ReturnType<typeof createStorage> | null = null;
9
11
  let storageReady = false;
@@ -205,11 +207,15 @@ export const useAutoFormStore = create<AutoFormStore>()(
205
207
 
206
208
 
207
209
  // Field update actions
208
- setField: (field: string, value: unknown) => {
210
+ setField: (field: string, value: unknown) => {
209
211
  const state = get();
210
212
  const newDirty = new Set(state.dirtyFields);
211
- // Mark dirty if value differs from last saved baseline
212
- if (JSON.stringify(value) !== JSON.stringify(state.lastSavedData[field])) {
213
+ // Mark dirty if value differs from last saved baseline (normalized to strip full media details)
214
+ const normalizedValue = normalizeUploadFields(value);
215
+ const normalizedLastSaved = normalizeUploadFields(state.lastSavedData[field]);
216
+ if (isEmpty(normalizedValue) && isEmpty(normalizedLastSaved)) {
217
+ newDirty.delete(field);
218
+ } else if (!deepEqual(normalizedValue, normalizedLastSaved)) {
213
219
  newDirty.add(field);
214
220
  } else {
215
221
  newDirty.delete(field);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Performs a deep equality check between two values.
3
+ * Ignores the order of keys in objects.
4
+ */
5
+ export function deepEqual(obj1: any, obj2: any): boolean {
6
+ if (obj1 === obj2) {
7
+ return true;
8
+ }
9
+
10
+ if (
11
+ typeof obj1 !== "object" ||
12
+ obj1 === null ||
13
+ typeof obj2 !== "object" ||
14
+ obj2 === null
15
+ ) {
16
+ return false;
17
+ }
18
+
19
+ if (Array.isArray(obj1) && Array.isArray(obj2)) {
20
+ if (obj1.length !== obj2.length) {
21
+ return false;
22
+ }
23
+ for (let i = 0; i < obj1.length; i++) {
24
+ if (!deepEqual(obj1[i], obj2[i])) {
25
+ return false;
26
+ }
27
+ }
28
+ return true;
29
+ }
30
+
31
+ if (Array.isArray(obj1) || Array.isArray(obj2)) {
32
+ return false;
33
+ }
34
+
35
+ const keys1 = Object.keys(obj1);
36
+ const keys2 = Object.keys(obj2);
37
+
38
+ if (keys1.length !== keys2.length) {
39
+ return false;
40
+ }
41
+
42
+ for (const key of keys1) {
43
+ if (!Object.prototype.hasOwnProperty.call(obj2, key)) {
44
+ return false;
45
+ }
46
+ if (!deepEqual(obj1[key], obj2[key])) {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Checks if a value is "empty-like" (undefined, null, empty string, false, empty array, or empty object).
56
+ * This helps prevent false dirty states when form fields initialize with default empty values.
57
+ */
58
+ export function isEmpty(val: any): boolean {
59
+ if (val === undefined || val === null || val === "" || val === false) {
60
+ return true;
61
+ }
62
+ if (Array.isArray(val)) {
63
+ return val.length === 0;
64
+ }
65
+ if (typeof val === "object") {
66
+ if (Object.keys(val).length === 0) return true;
67
+ if (val.type === "doc" && Array.isArray(val.content)) {
68
+ return val.content.length === 0 || (val.content.length === 1 && val.content[0].type === "paragraph" && !val.content[0].content);
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+