@kyro-cms/admin 0.11.6 → 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.
- package/dist/index.cjs +18 -18
- package/dist/index.js +18 -18
- package/package.json +1 -1
- package/src/components/AutoForm.tsx +54 -22
- package/src/components/GraphQLPlayground.tsx +259 -12
- package/src/components/WebhookManager.tsx +338 -188
- package/src/components/autoform/AutoFormHeader.tsx +19 -8
- package/src/components/autoform/ErrorBoundary.tsx +93 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +2 -2
- package/src/components/blocks/HeroBlock.tsx +2 -2
- package/src/components/fields/BlocksField.tsx +1 -1
- package/src/components/fields/HeadingSubheadingField.tsx +2 -2
- package/src/components/fields/HeroField.tsx +4 -4
- package/src/components/fields/RelationshipField.tsx +2 -2
- package/src/components/fields/extensions/blocksStore.ts +1 -1
- package/src/hooks/useAutoFormState.ts +51 -1
- package/src/integration.ts +4 -4
- package/src/pages/settings/[slug].astro +1 -1
|
@@ -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.
|
|
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={
|
|
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.
|
|
26
|
-
subheading={data.
|
|
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.
|
|
64
|
-
subheading={data.
|
|
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.
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
46
|
+
const tabs = opt?.tabs as Record<string, unknown> | undefined;
|
|
47
47
|
|
|
48
48
|
return (
|
|
49
49
|
(opt?.title as string) ||
|
|
50
|
-
(
|
|
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": {
|
|
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
|
|
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,
|
package/src/integration.ts
CHANGED
|
@@ -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
|
|
145
|
-
if (
|
|
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 (!
|
|
148
|
-
|
|
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
|
});
|