@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/dist/index.cjs +25 -25
- package/dist/index.js +20 -20
- package/package.json +1 -1
- package/src/components/AutoForm.tsx +11 -2
- package/src/components/fields/AccordionField.tsx +54 -3
- package/src/components/fields/BlocksField.tsx +37 -3
- package/src/components/fields/GroupLayout.tsx +54 -4
- package/src/components/fields/TabsLayout.tsx +62 -11
- package/src/components/fields/UploadField.tsx +17 -21
- package/src/components/ui/SplitButton.tsx +5 -5
- package/src/lib/autoform-store.ts +9 -3
- package/src/lib/deep-equal.ts +73 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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] =
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
missingIds.map(id => apiGet<any>(`/api/media/${id}`))
|
|
119
123
|
);
|
|
120
124
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 = (
|
|
307
|
-
if (!
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
+
|