@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.
@@ -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
 
@@ -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 shadow-lg shadow-[var(--kyro-primary)]/10 ${
100
- act.variant === "outline"
101
- ? "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
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
- } ${act.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${act.className || ""}`}
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 shadow-lg shadow-[var(--kyro-primary)]/10 w-full lg:w-auto justify-center ${action.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${action.className || ""}`}
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-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;
@@ -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
- if (JSON.stringify(value) !== JSON.stringify(state.lastSavedData[field])) {
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
+