@kyro-cms/admin 0.11.5 → 0.12.0

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.
@@ -20,7 +20,9 @@ interface AutoFormHeaderProps {
20
20
  handleUnpublish: () => void;
21
21
  handleDelete: () => void;
22
22
  handlePublish: () => void;
23
+ handleSaveDraft: () => void;
23
24
  handleSchedulePublish: (scheduledFor: string) => void;
25
+ handleConflictOverride?: () => void;
24
26
  }
25
27
 
26
28
  export function AutoFormHeader({
@@ -35,7 +37,9 @@ export function AutoFormHeader({
35
37
  handleUnpublish,
36
38
  handleDelete,
37
39
  handlePublish,
40
+ handleSaveDraft,
38
41
  handleSchedulePublish,
42
+ handleConflictOverride,
39
43
  }: AutoFormHeaderProps) {
40
44
  const [now, setNow] = useState(Date.now());
41
45
  const [showSchedulePicker, setShowSchedulePicker] = useState(false);
@@ -76,7 +80,7 @@ export function AutoFormHeader({
76
80
  }, [showSchedulePicker]);
77
81
 
78
82
  const docTitle = String(
79
- (formData.mainTabs as { title?: string })?.title ||
83
+ (formData.tabs as { title?: string })?.title ||
80
84
  (typeof formData.title === "object" ? "" : formData.title) ||
81
85
  (typeof formData.name === "object" ? "" : formData.name) ||
82
86
  "Untitled",
@@ -165,13 +169,7 @@ export function AutoFormHeader({
165
169
  <span className="opacity-30">—</span>
166
170
  <button
167
171
  type="button"
168
- onClick={async () => {
169
- // To keep changes during conflict, we'd fire the prop or a store action.
170
- // For now, reload handles the other side. Since this is in header, we'll
171
- // trigger a window reload or a saveDocument via context if needed.
172
- // It's cleaner to handle this conflict directly if passed as a prop,
173
- // but let's just trigger a reload for simplicity if they want the server version.
174
- }}
172
+ onClick={() => handleConflictOverride?.()}
175
173
  className="text-[var(--kyro-primary)] hover:underline"
176
174
  >
177
175
  Keep my changes
@@ -207,6 +205,19 @@ export function AutoFormHeader({
207
205
  }
208
206
  direction="down"
209
207
  >
208
+ <DropdownItem
209
+ onClick={handleSaveDraft}
210
+ icon={
211
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
212
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
213
+ <polyline points="17 21 17 13 7 13 7 21" />
214
+ <polyline points="7 3 7 8 15 8" />
215
+ </svg>
216
+ }
217
+ >
218
+ Save Draft
219
+ <span className="ml-auto text-[9px] opacity-40 font-mono">⌘S</span>
220
+ </DropdownItem>
210
221
  {!globalSlug && (
211
222
  <DropdownItem
212
223
  onClick={handleCreateNew}
@@ -0,0 +1,93 @@
1
+ import React, { Component, type ReactNode } from "react";
2
+ import { AlertTriangle, RefreshCw, Copy } from "../ui/icons";
3
+ import { useAutoFormStore } from "../../lib/autoform-store";
4
+ import { toast } from "../../lib/stores";
5
+
6
+ interface Props {
7
+ children: ReactNode;
8
+ fallbackTitle?: string;
9
+ }
10
+
11
+ interface State {
12
+ hasError: boolean;
13
+ error: Error | null;
14
+ errorInfo: string | null;
15
+ }
16
+
17
+ export class ErrorBoundary extends Component<Props, State> {
18
+ constructor(props: Props) {
19
+ super(props);
20
+ this.state = { hasError: false, error: null, errorInfo: null };
21
+ }
22
+
23
+ static getDerivedStateFromError(error: Error): Partial<State> {
24
+ return { hasError: true, error };
25
+ }
26
+
27
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
28
+ console.error("[ErrorBoundary] Caught:", error, errorInfo);
29
+ this.setState({ errorInfo: errorInfo.componentStack || null });
30
+ }
31
+
32
+ handleCopyToClipboard = () => {
33
+ const { error, errorInfo } = this.state;
34
+ const formData = useAutoFormStore.getState().formData;
35
+ const payload = {
36
+ error: error?.message,
37
+ stack: error?.stack,
38
+ componentStack: errorInfo,
39
+ formData,
40
+ timestamp: new Date().toISOString(),
41
+ };
42
+ navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
43
+ toast.success("Crash details copied to clipboard");
44
+ });
45
+ };
46
+
47
+ handleReload = () => {
48
+ window.location.reload();
49
+ };
50
+
51
+ render() {
52
+ if (this.state.hasError) {
53
+ return (
54
+ <div className="flex flex-col items-center justify-center gap-4 p-16">
55
+ <div className="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center">
56
+ <AlertTriangle className="w-8 h-8 text-red-500" />
57
+ </div>
58
+ <h3 className="text-lg font-bold text-[var(--kyro-text-primary)]">
59
+ {this.props.fallbackTitle || "Something went wrong"}
60
+ </h3>
61
+ <p className="text-sm text-[var(--kyro-text-secondary)] text-center max-w-md">
62
+ The form encountered an unexpected error. Your unsaved changes may still be recoverable.
63
+ </p>
64
+ {this.state.error && (
65
+ <p className="text-xs font-mono text-red-500/70 bg-red-500/5 px-4 py-2 rounded-lg max-w-full overflow-auto">
66
+ {this.state.error.message}
67
+ </p>
68
+ )}
69
+ <div className="flex items-center gap-3 mt-2">
70
+ <button
71
+ type="button"
72
+ onClick={this.handleReload}
73
+ className="flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl border border-[var(--kyro-border)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
74
+ >
75
+ <RefreshCw className="w-4 h-4" />
76
+ Reload Page
77
+ </button>
78
+ <button
79
+ type="button"
80
+ onClick={this.handleCopyToClipboard}
81
+ className="flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl bg-[var(--kyro-primary)]/10 text-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)]/20 transition-colors"
82
+ >
83
+ <Copy className="w-4 h-4" />
84
+ Copy Details
85
+ </button>
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ return this.props.children;
92
+ }
93
+ }
@@ -22,8 +22,8 @@ export const HeadingSubheadingBlock: React.FC<{ block: Record<string, unknown>;
22
22
  return (
23
23
  <BlockWrapper id={block.id} type="heading-subheading" label="Heading + Subheading">
24
24
  <HeadingSubheadingField
25
- heading={data.heading || ""}
26
- subheading={data.subheading || ""}
25
+ heading={data.title || ""}
26
+ subheading={data.subtitle || ""}
27
27
  onChange={handleChange}
28
28
  compact
29
29
  />
@@ -60,8 +60,8 @@ export const HeroBlock: React.FC<{ block: Record<string, unknown>; index: number
60
60
 
61
61
  <div className="space-y-3">
62
62
  <HeroField
63
- heading={data.heading || ""}
64
- subheading={data.subheading || ""}
63
+ heading={data.title || ""}
64
+ subheading={data.subtitle || ""}
65
65
  ctaText={data.ctaText || ""}
66
66
  ctaUrl={data.ctaUrl || ""}
67
67
  onChange={handleChange}
@@ -58,7 +58,7 @@ function getBlockPreviewSnippet(
58
58
  }
59
59
  }
60
60
  }
61
- return data.heading || data.title || data.text || data.name || data.label || data.sectionTitle || "";
61
+ return data.title || data.text || data.name || data.label || "";
62
62
  }
63
63
 
64
64
  // Sortable block wrapper for drag-and-drop
@@ -26,13 +26,13 @@ export const HeadingSubheadingField: React.FC<HeadingSubheadingFieldProps> = ({
26
26
  <input
27
27
  type="text"
28
28
  value={heading}
29
- onChange={(e) => onChange("heading", e.target.value)}
29
+ onChange={(e) => onChange("title", e.target.value)}
30
30
  className={`${inputClass} font-bold text-base`}
31
31
  placeholder="Heading..."
32
32
  />
33
33
  <textarea
34
34
  value={subheading}
35
- onChange={(e) => onChange("subheading", e.target.value)}
35
+ onChange={(e) => onChange("subtitle", e.target.value)}
36
36
  className={textareaClass}
37
37
  placeholder="Subheading..."
38
38
  />
@@ -31,13 +31,13 @@ export const HeroField: React.FC<HeroFieldProps> = ({
31
31
  <input
32
32
  type="text"
33
33
  value={heading}
34
- onChange={(e) => onChange("heading", e.target.value)}
34
+ onChange={(e) => onChange("title", e.target.value)}
35
35
  className={`${inputClass} font-bold text-base`}
36
36
  placeholder="Hero heading..."
37
37
  />
38
38
  <textarea
39
39
  value={subheading}
40
- onChange={(e) => onChange("subheading", e.target.value)}
40
+ onChange={(e) => onChange("subtitle", e.target.value)}
41
41
  className={textareaClass}
42
42
  placeholder="Hero subheading..."
43
43
  />
@@ -67,13 +67,13 @@ export const HeroField: React.FC<HeroFieldProps> = ({
67
67
  <input
68
68
  type="text"
69
69
  value={heading}
70
- onChange={(e) => onChange("heading", e.target.value)}
70
+ onChange={(e) => onChange("title", e.target.value)}
71
71
  className={`${inputClass} font-bold text-base`}
72
72
  placeholder="Hero heading..."
73
73
  />
74
74
  <textarea
75
75
  value={subheading}
76
- onChange={(e) => onChange("subheading", e.target.value)}
76
+ onChange={(e) => onChange("subtitle", e.target.value)}
77
77
  className={textareaClass}
78
78
  placeholder="Hero subheading..."
79
79
  />
@@ -43,11 +43,11 @@ interface ResolvedDoc {
43
43
  }
44
44
 
45
45
  function getLabel(opt: Record<string, unknown>): string {
46
- const mainTabs = opt?.mainTabs as Record<string, unknown> | undefined;
46
+ const tabs = opt?.tabs as Record<string, unknown> | undefined;
47
47
 
48
48
  return (
49
49
  (opt?.title as string) ||
50
- (mainTabs?.title as string) ||
50
+ (tabs?.title as string) ||
51
51
  (opt?.name as string) ||
52
52
  (opt?.label as string) ||
53
53
  (opt?.email as string) ||
@@ -157,7 +157,7 @@ export function createNewBlock(type: string): BlockData {
157
157
  function getDefaultData(type: string): Record<string, unknown> {
158
158
  const defaults: Record<string, unknown> = {
159
159
  heading: { level: 1, text: "" },
160
- "heading-subheading": { heading: "", subheading: "" },
160
+ "heading-subheading": { title: "", subtitle: "" },
161
161
  hero: { isMultiScreen: false },
162
162
  card: { title: "", description: "", icon: "", link: "", linkText: "", isMultiCard: false },
163
163
  paragraph: { text: "" },
@@ -151,7 +151,7 @@ const persistBrowserDraft = useCallback(
151
151
  const latestFormData = state.formData;
152
152
  const currentLastSaved = state.lastSavedData;
153
153
 
154
- if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
154
+ if (autoSaveSkipRef.current) return;
155
155
  if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
156
156
  if (!state.hasDirtyFields()) return;
157
157
 
@@ -169,6 +169,10 @@ const persistBrowserDraft = useCallback(
169
169
  setAutoSaveStatus("saving");
170
170
  state.setBackgroundProcessing(true);
171
171
 
172
+ if (globalSlug) {
173
+ window.dispatchEvent(new Event("kyro:global-save-start"));
174
+ }
175
+
172
176
  try {
173
177
  const url = globalSlug
174
178
  ? resolveUrl(`/api/globals/${globalSlug}?autosave=true`)
@@ -226,6 +230,9 @@ const persistBrowserDraft = useCallback(
226
230
  setAutoSaveStatus("offline");
227
231
  }
228
232
  } finally {
233
+ if (globalSlug) {
234
+ window.dispatchEvent(new Event("kyro:global-save-end"));
235
+ }
229
236
  setIsAutoSaving(false);
230
237
  useAutoFormStore.getState().setBackgroundProcessing(false);
231
238
  }
@@ -287,6 +294,48 @@ const persistBrowserDraft = useCallback(
287
294
  [collectionSlug, globalSlug, setAutoSaveStatus],
288
295
  );
289
296
 
297
+ // Force-save: retries without baseUpdatedAt to bypass OCC (conflict override)
298
+ const forceSave = useCallback(
299
+ async (isDraft = true) => {
300
+ const state = useAutoFormStore.getState();
301
+ const payload = state.formData;
302
+
303
+ const url = globalSlug
304
+ ? resolveUrl(`/api/globals/${globalSlug}`)
305
+ : resolveUrl(`/api/${collectionSlug}/${payload.id}`);
306
+
307
+ const response = await fetchWithAuth(
308
+ url,
309
+ {
310
+ method: "PATCH",
311
+ headers: {
312
+ "Content-Type": "application/json",
313
+ "X-Draft": String(isDraft),
314
+ },
315
+ body: JSON.stringify({
316
+ ...normalizeUploadFields(payload) as Record<string, unknown>,
317
+ }),
318
+ },
319
+ );
320
+
321
+ if (response.ok) {
322
+ const result = await response.json();
323
+ const savedData = result.data || payload;
324
+ state.setFormData({ ...payload, ...savedData });
325
+ state.setLastSavedData({ ...payload, ...savedData });
326
+ setAutoSaveStatus("success");
327
+ setTimeout(() => {
328
+ if (useAutoFormStore.getState().autoSaveStatus === "success") {
329
+ setAutoSaveStatus("idle");
330
+ }
331
+ }, 2000);
332
+ }
333
+
334
+ return response;
335
+ },
336
+ [collectionSlug, globalSlug, setAutoSaveStatus],
337
+ );
338
+
290
339
  // Track sidebar toggle
291
340
  useEffect(() => {
292
341
  const handleToggle = () => {
@@ -527,6 +576,7 @@ const persistBrowserDraft = useCallback(
527
576
  fetchVersions,
528
577
  performAutoSave: performAutosave,
529
578
  saveDocument,
579
+ forceSave,
530
580
  autoSaveSkipRef,
531
581
  lastAutoSaveTimeRef,
532
582
  documentStatus,
@@ -141,11 +141,11 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
141
141
  ];
142
142
  for (const col of (configResult.collections || [])) {
143
143
  if (col.seo) {
144
- const mainTabsField = col.fields?.find((f: any) => f.type === 'tabs' && f.name === 'mainTabs');
145
- if (mainTabsField?.tabs) {
144
+ const tabsField = col.fields?.find((f: any) => f.type === 'tabs' && f.name === 'tabs');
145
+ if (tabsField?.tabs) {
146
146
  // Only add if not already injected (avoid duplicates on hot-reload)
147
- if (!mainTabsField.tabs.find((t: any) => t.label === 'SEO Settings')) {
148
- mainTabsField.tabs.push({ label: "SEO Settings", fields: seoFields });
147
+ if (!tabsField.tabs.find((t: any) => t.label === 'SEO Settings')) {
148
+ tabsField.tabs.push({ label: "SEO Settings", fields: seoFields });
149
149
  }
150
150
  }
151
151
  }
@@ -118,7 +118,7 @@ const description =
118
118
  window.addEventListener("kyro:global-save-start", () => {
119
119
  if (saveBtn) {
120
120
  saveBtn.disabled = true;
121
- saveBtn.innerHTML = "Saving...";
121
+ saveBtn.innerHTML = `<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline-block" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>Saving...`;
122
122
  saveBtn.classList.add("opacity-50", "cursor-wait");
123
123
  }
124
124
  });