@kyro-cms/admin 0.12.1 → 0.12.4
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 +24 -24
- package/dist/index.js +19 -19
- package/package.json +2 -2
- package/src/components/AutoForm.tsx +11 -2
- package/src/components/DetailView.tsx +88 -16
- package/src/components/WebhookManager.tsx +134 -7
- package/src/components/autoform/AutoFormEditView.tsx +2 -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/PageHeader.tsx +5 -6
- package/src/components/ui/SplitButton.tsx +5 -5
- package/src/lib/autoform-store.ts +13 -3
- package/src/lib/deep-equal.ts +73 -0
|
@@ -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
|
|
|
@@ -96,13 +96,12 @@ function ActionsSlot({ actions }: { actions: NonNullable<PageHeaderProps["action
|
|
|
96
96
|
type="button"
|
|
97
97
|
onClick={act.onClick}
|
|
98
98
|
disabled={act.disabled}
|
|
99
|
-
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm transition-all
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
: act.variant === "ghost"
|
|
99
|
+
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm transition-all ${act.variant === "outline"
|
|
100
|
+
? "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
|
|
101
|
+
: act.variant === "ghost"
|
|
103
102
|
? "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] shadow-none"
|
|
104
103
|
: "kyro-btn-primary hover:opacity-90"
|
|
105
|
-
|
|
104
|
+
} ${act.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${act.className || ""}`}
|
|
106
105
|
>
|
|
107
106
|
{act.icon && <act.icon className="w-4 h-4" />}
|
|
108
107
|
{act.label}
|
|
@@ -120,7 +119,7 @@ function SingleAction({ action }: { action: NonNullable<PageHeaderProps["action"
|
|
|
120
119
|
type="button"
|
|
121
120
|
onClick={action.onClick}
|
|
122
121
|
disabled={action.disabled}
|
|
123
|
-
className={`kyro-btn kyro-btn-primary flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm hover:opacity-90 transition-all
|
|
122
|
+
className={`kyro-btn kyro-btn-primary flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm hover:opacity-90 transition-all w-full lg:w-auto justify-center ${action.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${action.className || ""}`}
|
|
124
123
|
>
|
|
125
124
|
{action.icon && <action.icon className="w-4 h-4" />}
|
|
126
125
|
{action.label}
|
|
@@ -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;
|
|
@@ -85,6 +87,7 @@ interface AutoFormStore {
|
|
|
85
87
|
versions: Version[];
|
|
86
88
|
loadingVersions: boolean;
|
|
87
89
|
showPreview: boolean;
|
|
90
|
+
previewUrl: string | null;
|
|
88
91
|
isMenuOpen: boolean;
|
|
89
92
|
hasUnsavedChanges: boolean;
|
|
90
93
|
loadingFields: Record<string, boolean>;
|
|
@@ -116,6 +119,7 @@ interface AutoFormStore {
|
|
|
116
119
|
setVersions: (versions: Version[]) => void;
|
|
117
120
|
setLoadingVersions: (loading: boolean) => void;
|
|
118
121
|
setShowPreview: (show: boolean | ((prev: boolean) => boolean)) => void;
|
|
122
|
+
setPreviewUrl: (url: string | null) => void;
|
|
119
123
|
setIsMenuOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
|
120
124
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
|
121
125
|
setLoadingFields: (fields: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void;
|
|
@@ -185,6 +189,7 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
185
189
|
versions: [],
|
|
186
190
|
loadingVersions: false,
|
|
187
191
|
showPreview: false,
|
|
192
|
+
previewUrl: null,
|
|
188
193
|
isMenuOpen: false,
|
|
189
194
|
hasUnsavedChanges: false,
|
|
190
195
|
loadingFields: {},
|
|
@@ -205,11 +210,15 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
205
210
|
|
|
206
211
|
|
|
207
212
|
// Field update actions
|
|
208
|
-
setField: (field: string, value: unknown) => {
|
|
213
|
+
setField: (field: string, value: unknown) => {
|
|
209
214
|
const state = get();
|
|
210
215
|
const newDirty = new Set(state.dirtyFields);
|
|
211
|
-
// Mark dirty if value differs from last saved baseline
|
|
212
|
-
|
|
216
|
+
// Mark dirty if value differs from last saved baseline (normalized to strip full media details)
|
|
217
|
+
const normalizedValue = normalizeUploadFields(value);
|
|
218
|
+
const normalizedLastSaved = normalizeUploadFields(state.lastSavedData[field]);
|
|
219
|
+
if (isEmpty(normalizedValue) && isEmpty(normalizedLastSaved)) {
|
|
220
|
+
newDirty.delete(field);
|
|
221
|
+
} else if (!deepEqual(normalizedValue, normalizedLastSaved)) {
|
|
213
222
|
newDirty.add(field);
|
|
214
223
|
} else {
|
|
215
224
|
newDirty.delete(field);
|
|
@@ -269,6 +278,7 @@ setField: (field: string, value: unknown) => {
|
|
|
269
278
|
showPreview:
|
|
270
279
|
typeof show === "function" ? show(state.showPreview) : show,
|
|
271
280
|
})),
|
|
281
|
+
setPreviewUrl: (previewUrl) => set({ previewUrl }),
|
|
272
282
|
setIsMenuOpen: (open) =>
|
|
273
283
|
set((state) => ({
|
|
274
284
|
isMenuOpen: typeof open === "function" ? open(state.isMenuOpen) : open,
|
|
@@ -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
|
+
|