@kyro-cms/admin 0.10.6 → 0.10.10

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.
@@ -0,0 +1,283 @@
1
+ import React from "react";
2
+ import { Check, X } from "../ui/icons";
3
+ import { useAutoFormStore } from "../../lib/autoform-store";
4
+
5
+ interface AutoFormVersionViewProps {
6
+ handleRestoreVersion: (versionId: string) => void;
7
+ handleCompareVersions: () => Promise<void>;
8
+ toggleCompareSelection: (versionId: string) => void;
9
+ }
10
+
11
+ export function AutoFormVersionView({
12
+ handleRestoreVersion,
13
+ handleCompareVersions,
14
+ toggleCompareSelection,
15
+ }: AutoFormVersionViewProps) {
16
+ const {
17
+ compareMode,
18
+ setCompareMode,
19
+ compareSelected,
20
+ setCompareSelected,
21
+ compareDiffs,
22
+ setCompareDiffs,
23
+ loadingDiffs,
24
+ loadingVersions,
25
+ versions,
26
+ } = useAutoFormStore();
27
+
28
+ return (
29
+ <div className="w-full animate-in fade-in slide-in-from-bottom-4 pb-12">
30
+ <div className="surface-tile p-0 overflow-hidden">
31
+ <div className="px-4 md:px-6 py-3 md:py-4 border-b border-[var(--kyro-border)] flex flex-col md:flex-row md:items-center justify-between gap-2">
32
+ <div>
33
+ <h2 className="text-base md:text-lg font-bold text-[var(--kyro-text-primary)]">
34
+ Version History
35
+ </h2>
36
+ <p className="text-[11px] text-[var(--kyro-text-muted)] mt-0.5">
37
+ {compareMode
38
+ ? `Select 2 versions · ${compareSelected.length}/2 chosen`
39
+ : `${versions.length} snapshot${versions.length !== 1 ? "s" : ""} · Auto-saved`}
40
+ </p>
41
+ </div>
42
+ <div className="flex items-center gap-2">
43
+ {compareMode && compareSelected.length === 2 && (
44
+ <button
45
+ type="button"
46
+ onClick={handleCompareVersions}
47
+ disabled={loadingDiffs}
48
+ 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"
49
+ >
50
+ {loadingDiffs ? "Comparing..." : "Compare"}
51
+ </button>
52
+ )}
53
+ <button
54
+ type="button"
55
+ onClick={() => {
56
+ setCompareMode(!compareMode);
57
+ setCompareSelected([]);
58
+ setCompareDiffs([]);
59
+ }}
60
+ className={`px-3 py-1.5 rounded-lg text-[11px] font-bold tracking-wider transition-all ${compareMode
61
+ ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
62
+ : "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
63
+ }`}
64
+ >
65
+ {compareMode ? "Done" : "Compare"}
66
+ </button>
67
+ </div>
68
+ </div>
69
+
70
+ {compareDiffs.length > 0 && (
71
+ <div className="border-b border-[var(--kyro-border)]">
72
+ <div className="px-6 py-3 flex items-center justify-between">
73
+ <span className="text-[11px] font-bold text-[var(--kyro-text-primary)] tracking-wider">
74
+ {compareDiffs.length} change
75
+ {compareDiffs.length !== 1 ? "s" : ""}
76
+ </span>
77
+ <button
78
+ type="button"
79
+ onClick={() => setCompareDiffs([])}
80
+ className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
81
+ >
82
+ <X className="w-4 h-4" />
83
+ </button>
84
+ </div>
85
+ <div className="max-h-[400px] overflow-y-auto">
86
+ {compareDiffs.map((d, i) => (
87
+ <div
88
+ key={i}
89
+ className="flex flex-col md:grid md:grid-cols-4 gap-1 md:gap-3 px-4 md:px-6 py-2.5 text-[11px] font-mono border-t border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)]"
90
+ >
91
+ <div className="text-[var(--kyro-text-muted)] truncate font-semibold md:font-normal">
92
+ {d.field}
93
+ </div>
94
+ <div className="text-[var(--kyro-text-muted)] truncate hidden md:block">
95
+ {typeof d.oldValue === "object"
96
+ ? JSON.stringify(d.oldValue)
97
+ : String(d.oldValue ?? "null")}
98
+ </div>
99
+ <div className="md:col-span-2 text-[var(--kyro-text-primary)] truncate">
100
+ <span className="md:hidden text-[var(--kyro-text-muted)]">→ </span>
101
+ {typeof d.newValue === "object"
102
+ ? JSON.stringify(d.newValue)
103
+ : String(d.newValue ?? "null")}
104
+ </div>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ </div>
109
+ )}
110
+
111
+ {loadingVersions ? (
112
+ <div className="flex justify-center py-16">
113
+ <span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
114
+ </div>
115
+ ) : versions.length === 0 ? (
116
+ <div className="text-center py-16 text-[var(--kyro-text-muted)] text-sm italic">
117
+ No versions yet.
118
+ </div>
119
+ ) : (
120
+ <div className="divide-y divide-[var(--kyro-border)]">
121
+ {versions.map((v, i) => {
122
+ const isSelected = compareSelected.includes(v.id);
123
+ const isAutoSaved = (v.changeDescription || "")
124
+ .toLowerCase()
125
+ .includes("auto");
126
+
127
+ return (
128
+ <div
129
+ key={v.id}
130
+ onClick={
131
+ compareMode ? () => toggleCompareSelection(v.id) : undefined
132
+ }
133
+ className={`transition-all ${compareMode
134
+ ? isSelected
135
+ ? "bg-[var(--kyro-primary)]/5 cursor-pointer"
136
+ : "hover:bg-[var(--kyro-bg-secondary)] cursor-pointer"
137
+ : "hover:bg-[var(--kyro-bg-secondary)]"
138
+ }`}
139
+ >
140
+ {/* Desktop: grid row */}
141
+ <div className="hidden md:grid grid-cols-12 gap-3 px-6 py-3 items-center">
142
+ <div className="col-span-1 flex items-center gap-2">
143
+ {compareMode ? (
144
+ <div
145
+ className={`w-4 h-4 rounded-full border ${isSelected
146
+ ? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
147
+ : "border-[var(--kyro-border)]"
148
+ }`}
149
+ >
150
+ {isSelected && (
151
+ <Check className="w-4 h-4" />
152
+ )}
153
+ </div>
154
+ ) : (
155
+ <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5">
156
+ {versions.length - i}
157
+ </span>
158
+ )}
159
+ </div>
160
+ <div className="col-span-4 min-w-0">
161
+ <div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-2">
162
+ {v.changeDescription || "Snapshot"}
163
+ {isAutoSaved && (
164
+ <span className="text-[9px] px-1.5 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold tracking-wider">
165
+ Auto
166
+ </span>
167
+ )}
168
+ </div>
169
+ <div className="text-[11px] text-[var(--kyro-text-muted)]">
170
+ {new Date(v.createdAt as string).toLocaleString("en-US", {
171
+ month: "short",
172
+ day: "numeric",
173
+ hour: "2-digit",
174
+ minute: "2-digit",
175
+ })}
176
+ </div>
177
+ </div>
178
+ <div className="col-span-3">
179
+ {v.status && (
180
+ <span
181
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold capitalize tracking-wider ${v.status === "published"
182
+ ? " text-[var(--kyro-success)]"
183
+ : " text-[var(--kyro-warning)]"
184
+ }`}
185
+ >
186
+ <span
187
+ className={`w-1.5 h-1.5 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
188
+ />
189
+ {v.status}
190
+ </span>
191
+ )}
192
+ </div>
193
+ <div className="col-span-2 text-[11px] text-[var(--kyro-text-muted)]">
194
+ {v.createdBy || "system"}
195
+ </div>
196
+ <div className="col-span-2 flex justify-end">
197
+ {!compareMode && (
198
+ <button
199
+ type="button"
200
+ onClick={() => handleRestoreVersion(v.id)}
201
+ 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:text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] transition-all active:scale-95"
202
+ >
203
+ Restore
204
+ </button>
205
+ )}
206
+ </div>
207
+ </div>
208
+
209
+ {/* Mobile: card layout */}
210
+ <div className="md:hidden flex items-start gap-3 px-4 py-3">
211
+ <div className="pt-0.5 shrink-0">
212
+ {compareMode ? (
213
+ <div
214
+ className={`w-4 h-4 rounded-full border ${isSelected
215
+ ? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
216
+ : "border-[var(--kyro-border)]"
217
+ }`}
218
+ >
219
+ {isSelected && <Check className="w-4 h-4" />}
220
+ </div>
221
+ ) : (
222
+ <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5 inline-block text-center">
223
+ {versions.length - i}
224
+ </span>
225
+ )}
226
+ </div>
227
+
228
+ <div className="flex-1 min-w-0">
229
+ <div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-1.5">
230
+ {v.changeDescription || "Snapshot"}
231
+ {isAutoSaved && (
232
+ <span className="text-[9px] px-1 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold tracking-wider shrink-0">
233
+ Auto
234
+ </span>
235
+ )}
236
+ </div>
237
+ <div className="flex items-center gap-2 mt-1 flex-wrap">
238
+ <span className="text-[11px] text-[var(--kyro-text-muted)]">
239
+ {new Date(v.createdAt as string).toLocaleString("en-US", {
240
+ month: "short",
241
+ day: "numeric",
242
+ hour: "2-digit",
243
+ minute: "2-digit",
244
+ })}
245
+ </span>
246
+ {v.status && (
247
+ <span
248
+ className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold capitalize tracking-wider ${v.status === "published"
249
+ ? "text-[var(--kyro-success)]"
250
+ : "text-[var(--kyro-warning)]"
251
+ }`}
252
+ >
253
+ <span
254
+ className={`w-1 h-1 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
255
+ />
256
+ {v.status}
257
+ </span>
258
+ )}
259
+ <span className="text-[10px] text-[var(--kyro-text-muted)] opacity-60">
260
+ {v.createdBy || "system"}
261
+ </span>
262
+ </div>
263
+ </div>
264
+
265
+ {!compareMode && (
266
+ <button
267
+ type="button"
268
+ onClick={() => handleRestoreVersion(v.id)}
269
+ className="shrink-0 px-2.5 py-1 rounded-lg border border-[var(--kyro-border)] text-[10px] font-bold tracking-wider text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] transition-all active:scale-95"
270
+ >
271
+ Restore
272
+ </button>
273
+ )}
274
+ </div>
275
+ </div>
276
+ );
277
+ })}
278
+ </div>
279
+ )}
280
+ </div>
281
+ </div>
282
+ );
283
+ }
@@ -13,6 +13,7 @@ interface UseAutoFormStateProps {
13
13
  initialData: Record<string, unknown>;
14
14
  collectionSlug?: string;
15
15
  globalSlug?: string;
16
+ documentId?: string;
16
17
  onChange?: (data: Record<string, unknown>) => void;
17
18
  onActionSuccess?: (msg: string) => void;
18
19
  onActionError?: (msg: string) => void;
@@ -23,6 +24,7 @@ export function useAutoFormState({
23
24
  initialData,
24
25
  collectionSlug,
25
26
  globalSlug,
27
+ documentId,
26
28
  onChange,
27
29
  onActionSuccess,
28
30
  onActionError,
@@ -58,7 +60,7 @@ export function useAutoFormState({
58
60
  // Globals have no id field and are singleton per slug, so skip this check.
59
61
  // Must run in useEffect — calling resetForm() during render triggers
60
62
  // "Cannot update a component while rendering" React error.
61
- const currentContextKey = globalSlug || (initialData?.id as string | undefined) || collectionSlug;
63
+ const currentContextKey = globalSlug || (initialData?.id as string | undefined) || documentId || collectionSlug;
62
64
  const needsResetRef = useRef(false);
63
65
  if (
64
66
  !globalSlug &&
@@ -198,7 +198,6 @@ export default debug;
198
198
  optimizeDeps: {
199
199
  include: [
200
200
  '@kyro-cms/admin',
201
- 'use-sync-external-store',
202
201
  ],
203
202
  exclude: ['debug', 'react/compiler-runtime'],
204
203
  },
@@ -352,6 +352,14 @@ setField: (field: string, value: unknown) => {
352
352
  data: Record<string, unknown>,
353
353
  lastSaved?: Record<string, unknown>,
354
354
  ) => {
355
+ const state = get();
356
+ if (
357
+ state.formData === data &&
358
+ state.lastSavedData === (lastSaved || data) &&
359
+ !state.hasUnsavedChanges
360
+ ) {
361
+ return;
362
+ }
355
363
  set({
356
364
  formData: data,
357
365
  lastSavedData: lastSaved || data,