@kyro-cms/admin 0.9.0 → 0.9.1
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 +11960 -11006
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +67 -65
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +563 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +12183 -11238
- package/dist/index.js.map +1 -1
- package/package.json +15 -11
- package/src/components/ActionBar.tsx +27 -14
- package/src/components/Admin.tsx +1 -1
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AutoForm.tsx +585 -369
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +71 -56
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +33 -20
- package/src/components/MediaGallery.tsx +219 -194
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +7 -7
- package/src/components/SessionsManager.tsx +1 -1
- package/src/components/SettingsPage.tsx +22 -0
- package/src/components/Sidebar.astro +13 -41
- package/src/components/UserManagement.tsx +153 -15
- package/src/components/UserMenu.tsx +30 -4
- package/src/components/VersionHistoryPanel.tsx +112 -119
- package/src/components/WebhookManager.tsx +6 -4
- package/src/components/blocks/ArrayBlock.tsx +6 -23
- package/src/components/blocks/BlockEditModal.tsx +82 -309
- package/src/components/blocks/CardBlock.tsx +35 -0
- package/src/components/blocks/ChildBlocksTree.tsx +57 -31
- package/src/components/blocks/GenericBlock.tsx +44 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
- package/src/components/blocks/HeroBlock.tsx +5 -14
- package/src/components/blocks/RichTextBlock.tsx +5 -5
- package/src/components/blocks/index.ts +5 -3
- package/src/components/fields/AccordionField.tsx +2 -2
- package/src/components/fields/ArrayField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +120 -29
- package/src/components/fields/BlocksField.tsx +430 -50
- package/src/components/fields/CardField.tsx +73 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/DateField.tsx +4 -1
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/HeadingSubheadingField.tsx +43 -0
- package/src/components/fields/ListField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -1
- package/src/components/fields/RelationshipField.tsx +153 -87
- package/src/components/fields/RichTextField.tsx +781 -0
- package/src/components/fields/SecretField.tsx +102 -0
- package/src/components/fields/SelectField.tsx +19 -6
- package/src/components/fields/TabsLayout.tsx +19 -9
- package/src/components/fields/TextField.tsx +4 -1
- package/src/components/fields/UploadField.tsx +122 -56
- package/src/components/fields/extensions/blockComponents.tsx +103 -174
- package/src/components/fields/extensions/blocksStore.ts +8 -1
- package/src/components/fields/index.ts +4 -2
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/SlidePanel.tsx +8 -3
- package/src/components/ui/icons.tsx +109 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/hooks/useAutoFormState.ts +125 -62
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AuthLayout.astro +14 -2
- package/src/lib/autoform-store.ts +85 -52
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +44 -9
- package/src/lib/normalize-upload-fields.ts +41 -0
- package/src/lib/paths.ts +2 -2
- package/src/lib/resolve-field-value.ts +110 -0
- package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
- package/src/lib/shim/use-sync-external-store.js +1 -0
- package/src/lib/stores/index.ts +1 -0
- package/src/lib/useResourceManager.ts +4 -4
- package/src/lib/vite-shim-plugin.ts +100 -0
- package/src/pages/[collection]/[id].astro +1 -1
- package/src/pages/preview/[collection]/[id].astro +4 -4
- package/src/pages/settings/[slug].astro +2 -2
- package/src/styles/main.css +60 -54
- package/README.md +0 -46
- package/dist/EditorClient-Q23UXR37.cjs +0 -468
- package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
- package/dist/EditorClient-T5PASFNR.js +0 -466
- package/dist/EditorClient-T5PASFNR.js.map +0 -1
- package/dist/chunk-3BGDYKTD.cjs +0 -348
- package/dist/chunk-3BGDYKTD.cjs.map +0 -1
- package/dist/chunk-EEFXLQVT.js +0 -3
- package/dist/chunk-EEFXLQVT.js.map +0 -1
- package/src/components/blocks/ButtonBlock.tsx +0 -64
- package/src/components/blocks/ColumnsBlock.tsx +0 -55
- package/src/components/blocks/DividerBlock.tsx +0 -43
- package/src/components/blocks/LinkBlock.tsx +0 -65
- package/src/components/blocks/VStackBlock.tsx +0 -29
- package/src/components/fields/EditorClient.tsx +0 -535
- package/src/components/fields/PortableTextField.tsx +0 -155
- package/src/components/fields/PortableTextRenderer.tsx +0 -68
|
@@ -18,14 +18,14 @@ import TextField from "./fields/TextField";
|
|
|
18
18
|
import { globals, collections } from "../lib/config";
|
|
19
19
|
import { slugifyText } from "../lib/slugify";
|
|
20
20
|
import { resolveUrl, apiDelete, fetchWithAuth } from "../lib/api";
|
|
21
|
+
import { normalizeUploadFields } from "../lib/normalize-upload-fields";
|
|
21
22
|
import { useAutoFormStore } from "../lib/autoform-store";
|
|
22
23
|
import { useAutoFormState } from "../hooks/useAutoFormState";
|
|
23
|
-
import { useUIStore } from "../lib/stores";
|
|
24
|
+
import { useUIStore, toast } from "../lib/stores";
|
|
24
25
|
|
|
25
26
|
import { adminPath as ADMIN_BASE, apiPath as API_BASE } from "../lib/paths";
|
|
26
27
|
|
|
27
28
|
import { BlocksField } from "./fields/BlocksField";
|
|
28
|
-
import PortableTextField from "./fields/PortableTextField";
|
|
29
29
|
import { ConfirmModal, Modal as UIModal } from "./ui/Modal";
|
|
30
30
|
import { ListField } from "./fields/ListField";
|
|
31
31
|
import { RelationshipBlockField } from "./fields/RelationshipBlockField";
|
|
@@ -73,7 +73,7 @@ export function AutoForm({
|
|
|
73
73
|
const config = activeConfig || propConfig;
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
const { confirm
|
|
76
|
+
const { confirm } = useUIStore();
|
|
77
77
|
|
|
78
78
|
const {
|
|
79
79
|
formData,
|
|
@@ -81,6 +81,8 @@ export function AutoForm({
|
|
|
81
81
|
hasUnsavedChanges,
|
|
82
82
|
isAutoSaving,
|
|
83
83
|
autoSaveStatus,
|
|
84
|
+
lastSavedAt,
|
|
85
|
+
retryCount,
|
|
84
86
|
sidebarCollapsed,
|
|
85
87
|
setSidebarCollapsed,
|
|
86
88
|
activeTab,
|
|
@@ -132,8 +134,28 @@ export function AutoForm({
|
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
137
|
+
const scheduleRef = useRef<HTMLDivElement>(null);
|
|
138
|
+
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
|
|
135
139
|
const disabled = propDisabled;
|
|
136
140
|
|
|
141
|
+
const resolveAdminFlag = (
|
|
142
|
+
value: boolean | ((
|
|
143
|
+
data: Record<string, unknown>,
|
|
144
|
+
siblingData: Record<string, unknown>,
|
|
145
|
+
) => boolean) | undefined,
|
|
146
|
+
currentData: Record<string, unknown>,
|
|
147
|
+
): boolean => {
|
|
148
|
+
if (typeof value === "function") {
|
|
149
|
+
try {
|
|
150
|
+
return value(formData, currentData);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.warn("Error evaluating admin runtime flag:", error);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return Boolean(value);
|
|
157
|
+
};
|
|
158
|
+
|
|
137
159
|
const handleRestoreVersion = (versionId: string) => {
|
|
138
160
|
confirm({
|
|
139
161
|
title: "Restore Version",
|
|
@@ -161,17 +183,18 @@ export function AutoForm({
|
|
|
161
183
|
|
|
162
184
|
const result = await resp.json();
|
|
163
185
|
if (result.data) {
|
|
164
|
-
|
|
165
|
-
|
|
186
|
+
const restoredData = { ...formData, ...result.data };
|
|
187
|
+
setFormData(restoredData);
|
|
188
|
+
useAutoFormStore.getState().loadDocument(restoredData, restoredData);
|
|
166
189
|
onActionSuccess?.("Version restored successfully");
|
|
167
190
|
fetchVersions();
|
|
168
191
|
setView("edit");
|
|
169
192
|
} else {
|
|
170
|
-
|
|
193
|
+
toast.error(result.error || "Failed to restore version");
|
|
171
194
|
}
|
|
172
195
|
} catch (err) {
|
|
173
196
|
console.error("Failed to restore version:", err);
|
|
174
|
-
|
|
197
|
+
toast.error("Failed to restore version");
|
|
175
198
|
}
|
|
176
199
|
}
|
|
177
200
|
});
|
|
@@ -208,13 +231,18 @@ export function AutoForm({
|
|
|
208
231
|
|
|
209
232
|
useEffect(() => {
|
|
210
233
|
const handleShortcuts = (e: KeyboardEvent) => {
|
|
211
|
-
// Cmd/Ctrl + S =
|
|
234
|
+
// Cmd/Ctrl + S = Save Draft
|
|
212
235
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
213
236
|
e.preventDefault();
|
|
214
|
-
(
|
|
237
|
+
handleSaveDraft();
|
|
238
|
+
}
|
|
239
|
+
// Cmd/Ctrl + Shift + P = Publish Changes
|
|
240
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "P" || e.key === "p")) {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
(document.getElementById("btn-publish") as HTMLButtonElement | null)?.click();
|
|
215
243
|
}
|
|
216
|
-
// Cmd/Ctrl + P = Toggle Preview
|
|
217
|
-
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
|
244
|
+
// Cmd/Ctrl + P (no shift) = Toggle Preview
|
|
245
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "p") {
|
|
218
246
|
e.preventDefault();
|
|
219
247
|
setShowPreview((prev) => !prev);
|
|
220
248
|
}
|
|
@@ -248,17 +276,30 @@ export function AutoForm({
|
|
|
248
276
|
if (isMenuOpen) {
|
|
249
277
|
document.addEventListener("mousedown", handleClickOutside);
|
|
250
278
|
return () =>
|
|
251
|
-
document.
|
|
279
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
252
280
|
}
|
|
253
281
|
}, [isMenuOpen]);
|
|
254
282
|
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
285
|
+
if (scheduleRef.current && !scheduleRef.current.contains(e.target as Node)) {
|
|
286
|
+
setShowSchedulePicker(false);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
if (showSchedulePicker) {
|
|
290
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
291
|
+
return () =>
|
|
292
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
293
|
+
}
|
|
294
|
+
}, [showSchedulePicker]);
|
|
295
|
+
|
|
255
296
|
const handleCreateNew = () => {
|
|
256
297
|
if (hasUnsavedChanges) {
|
|
257
298
|
confirm({
|
|
258
299
|
title: "Unsaved Changes",
|
|
259
300
|
message: "You have unsaved changes. Save before creating new?",
|
|
260
301
|
onConfirm: async () => {
|
|
261
|
-
|
|
302
|
+
await handleSaveDraft();
|
|
262
303
|
await new Promise((r) => setTimeout(r, 1000));
|
|
263
304
|
window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
|
|
264
305
|
},
|
|
@@ -275,11 +316,12 @@ export function AutoForm({
|
|
|
275
316
|
onConfirm: async () => {
|
|
276
317
|
try {
|
|
277
318
|
const { id, createdAt, updatedAt, ...duplicateData } = formData;
|
|
319
|
+
const normalizedData = normalizeUploadFields(duplicateData) as Record<string, unknown>;
|
|
278
320
|
const response = await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
279
321
|
method: "POST",
|
|
280
322
|
headers: { "Content-Type": "application/json" },
|
|
281
323
|
body: JSON.stringify({
|
|
282
|
-
...
|
|
324
|
+
...normalizedData,
|
|
283
325
|
title: `${duplicateData.title || duplicateData.name || "Copy"} (Copy)`,
|
|
284
326
|
slug: `${duplicateData.slug || "copy"}-${Date.now()}`,
|
|
285
327
|
status: "draft",
|
|
@@ -294,16 +336,10 @@ export function AutoForm({
|
|
|
294
336
|
}
|
|
295
337
|
} else {
|
|
296
338
|
const error = await response.json();
|
|
297
|
-
|
|
298
|
-
title: "Error",
|
|
299
|
-
message: error.error || "Failed to duplicate document",
|
|
300
|
-
});
|
|
339
|
+
toast.error(error.error || "Failed to duplicate document");
|
|
301
340
|
}
|
|
302
341
|
} catch (err) {
|
|
303
|
-
|
|
304
|
-
title: "Error",
|
|
305
|
-
message: "Failed to duplicate document",
|
|
306
|
-
});
|
|
342
|
+
toast.error("Failed to duplicate document");
|
|
307
343
|
}
|
|
308
344
|
},
|
|
309
345
|
});
|
|
@@ -319,10 +355,7 @@ export function AutoForm({
|
|
|
319
355
|
await apiDelete(`/api/${collectionSlug}/${formData.id}`);
|
|
320
356
|
window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
|
|
321
357
|
} catch (err) {
|
|
322
|
-
|
|
323
|
-
title: "Error",
|
|
324
|
-
message: (err as Error).message || "Failed to delete document",
|
|
325
|
-
});
|
|
358
|
+
toast.error((err as Error).message || "Failed to delete document");
|
|
326
359
|
}
|
|
327
360
|
},
|
|
328
361
|
});
|
|
@@ -345,21 +378,183 @@ export function AutoForm({
|
|
|
345
378
|
location.reload();
|
|
346
379
|
} else {
|
|
347
380
|
const error = await response.json();
|
|
348
|
-
|
|
349
|
-
title: "Error",
|
|
350
|
-
message: error.error || "Failed to unpublish",
|
|
351
|
-
});
|
|
381
|
+
toast.error(error.error || "Failed to unpublish");
|
|
352
382
|
}
|
|
353
383
|
} catch (err) {
|
|
354
|
-
|
|
355
|
-
title: "Error",
|
|
356
|
-
message: "Failed to unpublish",
|
|
357
|
-
});
|
|
384
|
+
toast.error("Failed to unpublish");
|
|
358
385
|
}
|
|
359
386
|
},
|
|
360
387
|
});
|
|
361
388
|
};
|
|
362
389
|
|
|
390
|
+
const handleSaveDraft = async () => {
|
|
391
|
+
const isNewDoc = !formData.id;
|
|
392
|
+
autoSaveSkipRef.current = true;
|
|
393
|
+
|
|
394
|
+
const btn = document.getElementById("btn-publish") as HTMLButtonElement | null;
|
|
395
|
+
const originalText = btn?.textContent || "";
|
|
396
|
+
if (btn) {
|
|
397
|
+
btn.textContent = "Saving...";
|
|
398
|
+
btn.setAttribute("disabled", "true");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
|
|
403
|
+
const isPost = isNewDoc && !globalSlug;
|
|
404
|
+
|
|
405
|
+
const response = isPost
|
|
406
|
+
? await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
407
|
+
method: "POST",
|
|
408
|
+
headers: { "Content-Type": "application/json" },
|
|
409
|
+
body: JSON.stringify(data),
|
|
410
|
+
})
|
|
411
|
+
: await saveDocument(data);
|
|
412
|
+
|
|
413
|
+
if (response.ok) {
|
|
414
|
+
const result = await response.json();
|
|
415
|
+
const savedData = result.data || data;
|
|
416
|
+
setFormData({ ...formData, ...savedData });
|
|
417
|
+
setLastSavedData({ ...formData, ...savedData });
|
|
418
|
+
lastAutoSaveTimeRef.current = Date.now();
|
|
419
|
+
setAutoSaveStatus("success");
|
|
420
|
+
await clearDraftArtifacts();
|
|
421
|
+
if (versionsEnabled) fetchVersions();
|
|
422
|
+
setTimeout(() => setAutoSaveStatus("idle"), 5000);
|
|
423
|
+
onActionSuccess?.(
|
|
424
|
+
isPost ? "Document created successfully" : "Changes saved",
|
|
425
|
+
);
|
|
426
|
+
if (isPost) {
|
|
427
|
+
setTimeout(() => {
|
|
428
|
+
window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
|
|
429
|
+
}, 800);
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
const error = await response.json();
|
|
433
|
+
if (response.status === 409) {
|
|
434
|
+
setAutoSaveStatus("conflict");
|
|
435
|
+
}
|
|
436
|
+
toast.error(error.error || "Failed to save");
|
|
437
|
+
}
|
|
438
|
+
} catch (err) {
|
|
439
|
+
toast.error("Failed to save document");
|
|
440
|
+
} finally {
|
|
441
|
+
autoSaveSkipRef.current = false;
|
|
442
|
+
if (btn) {
|
|
443
|
+
btn.textContent = originalText;
|
|
444
|
+
btn.removeAttribute("disabled");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const handlePublish = async () => {
|
|
450
|
+
const isNewDoc = !formData.id;
|
|
451
|
+
autoSaveSkipRef.current = true;
|
|
452
|
+
|
|
453
|
+
const btn = document.getElementById("btn-publish") as HTMLButtonElement | null;
|
|
454
|
+
const originalText = btn?.textContent || "";
|
|
455
|
+
if (btn) {
|
|
456
|
+
btn.textContent = "Publishing...";
|
|
457
|
+
btn.setAttribute("disabled", "true");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
// Step 1: Create or save the document
|
|
462
|
+
if (isNewDoc && !globalSlug) {
|
|
463
|
+
const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
|
|
464
|
+
const response = await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: { "Content-Type": "application/json" },
|
|
467
|
+
body: JSON.stringify(data),
|
|
468
|
+
});
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
const error = await response.json().catch(() => ({}));
|
|
471
|
+
if (response.status === 409) setAutoSaveStatus("conflict");
|
|
472
|
+
toast.error(error.error || "Failed to create document");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const result = await response.json();
|
|
476
|
+
const savedData = result.data || data;
|
|
477
|
+
setFormData({ ...formData, ...savedData });
|
|
478
|
+
setLastSavedData({ ...formData, ...savedData });
|
|
479
|
+
} else if (hasUnsavedChanges) {
|
|
480
|
+
const response = await saveDocument(formData);
|
|
481
|
+
if (!response.ok) {
|
|
482
|
+
const error = await response.json().catch(() => ({}));
|
|
483
|
+
if (response.status === 409) setAutoSaveStatus("conflict");
|
|
484
|
+
toast.error(error.error || "Failed to save before publishing");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const result = await response.json();
|
|
488
|
+
if (result.data) {
|
|
489
|
+
setFormData({ ...formData, ...result.data });
|
|
490
|
+
setLastSavedData({ ...formData, ...result.data });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Step 2: Publish
|
|
495
|
+
const response = await publishDocument();
|
|
496
|
+
|
|
497
|
+
if (response.ok) {
|
|
498
|
+
await clearDraftArtifacts();
|
|
499
|
+
onActionSuccess?.("Published successfully");
|
|
500
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
501
|
+
location.reload();
|
|
502
|
+
} else {
|
|
503
|
+
const error = await response.json();
|
|
504
|
+
if (response.status === 409) setAutoSaveStatus("conflict");
|
|
505
|
+
toast.error(error.error || "Failed to publish");
|
|
506
|
+
}
|
|
507
|
+
} catch (err) {
|
|
508
|
+
toast.error("Failed to publish");
|
|
509
|
+
} finally {
|
|
510
|
+
autoSaveSkipRef.current = false;
|
|
511
|
+
if (btn) {
|
|
512
|
+
btn.textContent = originalText;
|
|
513
|
+
btn.removeAttribute("disabled");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const handleSchedulePublish = async (scheduledFor: string) => {
|
|
519
|
+
const isNewDoc = !formData.id;
|
|
520
|
+
// Save the document first with _schedulePublishAt metadata
|
|
521
|
+
autoSaveSkipRef.current = true;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
const data = {
|
|
525
|
+
...normalizeUploadFields({ ...formData }) as Record<string, unknown>,
|
|
526
|
+
_schedulePublishAt: scheduledFor,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
if (isNewDoc && !globalSlug) {
|
|
530
|
+
const response = await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: { "Content-Type": "application/json" },
|
|
533
|
+
body: JSON.stringify(data),
|
|
534
|
+
});
|
|
535
|
+
if (!response.ok) {
|
|
536
|
+
const err = await response.json().catch(() => ({}));
|
|
537
|
+
toast.error(err.error || "Failed to schedule publish");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
const response = await saveDocument(data);
|
|
542
|
+
if (!response.ok) {
|
|
543
|
+
const err = await response.json().catch(() => ({}));
|
|
544
|
+
toast.error(err.error || "Failed to schedule publish");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
onActionSuccess?.(`Scheduled publish for ${new Date(scheduledFor).toLocaleString()}`);
|
|
550
|
+
setShowSchedulePicker(false);
|
|
551
|
+
} catch {
|
|
552
|
+
toast.error("Failed to schedule publish");
|
|
553
|
+
} finally {
|
|
554
|
+
autoSaveSkipRef.current = false;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
363
558
|
const handleFieldChange = (fieldName: string, value: unknown) => {
|
|
364
559
|
setField(fieldName, value);
|
|
365
560
|
};
|
|
@@ -369,22 +564,54 @@ export function AutoForm({
|
|
|
369
564
|
parentData?: Record<string, unknown>,
|
|
370
565
|
onParentChange?: (val: unknown) => void,
|
|
371
566
|
): React.ReactNode => {
|
|
372
|
-
if (field.admin?.hidden) return null;
|
|
373
|
-
|
|
374
567
|
const currentData = parentData !== undefined ? parentData : formData;
|
|
568
|
+
const isHidden = resolveAdminFlag((field.hidden !== undefined ? field.hidden : field.admin?.hidden) as any, currentData);
|
|
569
|
+
if (isHidden) return null;
|
|
570
|
+
|
|
571
|
+
const isReadOnly = resolveAdminFlag((field.readOnly !== undefined ? field.readOnly : field.admin?.readOnly) as any, currentData);
|
|
572
|
+
const effectiveDisabled = Boolean(disabled || isReadOnly);
|
|
375
573
|
|
|
376
574
|
// Evaluate display condition if present
|
|
377
575
|
// For conditional fields, pass formData as the root context (first arg)
|
|
378
576
|
// and currentData as the sibling context (second arg)
|
|
379
|
-
if (field.admin?.condition
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
577
|
+
if (field.admin?.condition) {
|
|
578
|
+
if (typeof field.admin.condition === "function") {
|
|
579
|
+
try {
|
|
580
|
+
// Compatibility wrapper: pass { values: formData, ...formData } to support both old and new signatures
|
|
581
|
+
const evalData = { values: formData || {}, ...(formData || {}) };
|
|
582
|
+
const shouldShow = field.admin.condition(evalData, currentData);
|
|
583
|
+
if (!shouldShow) {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
} catch (e) {
|
|
587
|
+
console.warn(`Condition error for field ${field.name}:`, e);
|
|
588
|
+
// Show the field if there's an error evaluating the condition
|
|
589
|
+
}
|
|
590
|
+
} else if (typeof field.admin.condition === "object") {
|
|
591
|
+
try {
|
|
592
|
+
const cond = field.admin.condition as any;
|
|
593
|
+
const targetField = cond.field;
|
|
594
|
+
|
|
595
|
+
// Get target field value, prioritizing sibling context (currentData) then root context (formData)
|
|
596
|
+
const val = (currentData && currentData[targetField] !== undefined)
|
|
597
|
+
? currentData[targetField]
|
|
598
|
+
: (formData && formData[targetField] !== undefined ? formData[targetField] : undefined);
|
|
599
|
+
|
|
600
|
+
let shouldShow = true;
|
|
601
|
+
if ("equals" in cond) {
|
|
602
|
+
shouldShow = val === cond.equals;
|
|
603
|
+
} else if ("notEquals" in cond) {
|
|
604
|
+
shouldShow = val !== cond.notEquals;
|
|
605
|
+
} else if ("in" in cond && Array.isArray(cond.in)) {
|
|
606
|
+
shouldShow = cond.in.includes(val);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!shouldShow) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
} catch (e) {
|
|
613
|
+
console.warn(`Declarative condition error for field ${field.name}:`, e);
|
|
384
614
|
}
|
|
385
|
-
} catch (e) {
|
|
386
|
-
console.warn(`Condition error for field ${field.name}:`, e);
|
|
387
|
-
// Show the field if there's an error evaluating the condition
|
|
388
615
|
}
|
|
389
616
|
}
|
|
390
617
|
|
|
@@ -399,7 +626,7 @@ export function AutoForm({
|
|
|
399
626
|
}
|
|
400
627
|
};
|
|
401
628
|
|
|
402
|
-
if (field.type === "row" && "fields" in field) {
|
|
629
|
+
if (field.type === "row" && "fields" in field) {
|
|
403
630
|
const rowFields = (field as Field & { fields?: Field[] }).fields;
|
|
404
631
|
return (
|
|
405
632
|
<div
|
|
@@ -410,7 +637,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
410
637
|
const fAdmin = f.admin || {};
|
|
411
638
|
const actionUrl = fAdmin?.action as string | undefined;
|
|
412
639
|
|
|
413
|
-
if (f.type === "button" && actionUrl) {
|
|
640
|
+
if ((f.type === "button" || f.type === "action") && actionUrl) {
|
|
414
641
|
const siblingEmailField = rowFields?.find(
|
|
415
642
|
(ff: Field) => ff.type === "email",
|
|
416
643
|
);
|
|
@@ -465,8 +692,8 @@ if (field.type === "row" && "fields" in field) {
|
|
|
465
692
|
}
|
|
466
693
|
}}
|
|
467
694
|
//@ts-ignore
|
|
468
|
-
disabled={loadingFields[f.name as string] ||
|
|
469
|
-
className="
|
|
695
|
+
disabled={loadingFields[f.name as string] || effectiveDisabled}
|
|
696
|
+
className="kyro-btn kyro-btn-primary px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
470
697
|
>
|
|
471
698
|
{loadingFields[f.name as string] ? "Sending..." : f.label || "Click"}
|
|
472
699
|
</button>
|
|
@@ -477,7 +704,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
477
704
|
return (
|
|
478
705
|
<div
|
|
479
706
|
key={f.name}
|
|
480
|
-
className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
|
|
707
|
+
className={f.type === "button" || f.type === "action" ? "flex-shrink-0" : "flex-1"}
|
|
481
708
|
style={
|
|
482
709
|
fAdmin?.width ? { width: fAdmin.width as string, flex: "none" } : {}
|
|
483
710
|
}
|
|
@@ -497,11 +724,10 @@ if (field.type === "row" && "fields" in field) {
|
|
|
497
724
|
key={field.name || `tabs-${Math.random()}`}
|
|
498
725
|
field={field}
|
|
499
726
|
formData={formData}
|
|
500
|
-
onTabDataChange={(
|
|
501
|
-
|
|
502
|
-
updateTabData(field.name as string, newTabData);
|
|
727
|
+
onTabDataChange={(tabData) => {
|
|
728
|
+
setField(field.name!, tabData);
|
|
503
729
|
}}
|
|
504
|
-
renderField={renderField}
|
|
730
|
+
renderField={(f, parentData, onChange) => renderField(f, parentData, onChange)}
|
|
505
731
|
/>
|
|
506
732
|
);
|
|
507
733
|
|
|
@@ -524,19 +750,20 @@ if (field.type === "row" && "fields" in field) {
|
|
|
524
750
|
value={value as unknown[]}
|
|
525
751
|
onChange={onFieldChange}
|
|
526
752
|
renderField={renderField}
|
|
527
|
-
disabled={
|
|
753
|
+
disabled={effectiveDisabled}
|
|
528
754
|
/>
|
|
529
755
|
);
|
|
530
756
|
|
|
531
757
|
|
|
532
|
-
case "button":
|
|
758
|
+
case "button":
|
|
759
|
+
case "action": {
|
|
533
760
|
const fieldName = field.name as string;
|
|
534
761
|
const isLoading = loadingFields[fieldName];
|
|
535
762
|
return (
|
|
536
763
|
<div key={fieldName} className="kyro-form-field">
|
|
537
764
|
<button
|
|
538
765
|
type="button"
|
|
539
|
-
disabled={isLoading ||
|
|
766
|
+
disabled={isLoading || effectiveDisabled}
|
|
540
767
|
onClick={async () => {
|
|
541
768
|
const action = (field.admin?.action || (field as Record<string, unknown>).action) as string | undefined;
|
|
542
769
|
const method =
|
|
@@ -630,7 +857,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
630
857
|
value={value}
|
|
631
858
|
onChange={onFieldChange}
|
|
632
859
|
error={error}
|
|
633
|
-
disabled={
|
|
860
|
+
disabled={effectiveDisabled}
|
|
634
861
|
/>
|
|
635
862
|
);
|
|
636
863
|
}
|
|
@@ -638,13 +865,13 @@ if (field.type === "row" && "fields" in field) {
|
|
|
638
865
|
|
|
639
866
|
const renderHeader = () => {
|
|
640
867
|
const docTitle = String(
|
|
641
|
-
|
|
642
|
-
formData.title ||
|
|
643
|
-
formData.name ||
|
|
868
|
+
(formData.mainTabs as { title?: string })?.title ||
|
|
869
|
+
(typeof formData.title === "object" ? "" : formData.title) ||
|
|
870
|
+
(typeof formData.name === "object" ? "" : formData.name) ||
|
|
644
871
|
"Untitled",
|
|
645
|
-
|
|
646
|
-
// Use
|
|
647
|
-
const docStatus = documentStatus ?? formData.
|
|
872
|
+
);
|
|
873
|
+
// Use publishStatus from the document (set by the new draft/publish system)
|
|
874
|
+
const docStatus = documentStatus ?? formData.publishStatus ?? formData.status ?? 'draft';
|
|
648
875
|
const isNew = !formData.id;
|
|
649
876
|
const lastModified = formData.updatedAt
|
|
650
877
|
? new Date(formData.updatedAt as string).toLocaleString()
|
|
@@ -653,6 +880,8 @@ if (field.type === "row" && "fields" in field) {
|
|
|
653
880
|
? new Date(formData.createdAt as string).toLocaleString()
|
|
654
881
|
: "Just now";
|
|
655
882
|
|
|
883
|
+
const isDraftMode = !formData.id || documentStatus === 'draft' || !!formData.hasDraft;
|
|
884
|
+
|
|
656
885
|
// Status label shown in the header
|
|
657
886
|
const statusLabel = hasUnpublishedChanges
|
|
658
887
|
? docStatus === 'draft' && !formData._prevStatus
|
|
@@ -662,16 +891,22 @@ if (field.type === "row" && "fields" in field) {
|
|
|
662
891
|
? 'Published'
|
|
663
892
|
: 'Draft';
|
|
664
893
|
|
|
665
|
-
const statusColor = docStatus === 'published' && !
|
|
894
|
+
const statusColor = docStatus === 'published' && !hasUnsavedChanges
|
|
666
895
|
? 'bg-[var(--kyro-success)]'
|
|
667
896
|
: hasUnpublishedChanges
|
|
668
897
|
? 'bg-[var(--kyro-warning)]'
|
|
669
898
|
: 'bg-[var(--kyro-text-muted)]';
|
|
670
899
|
|
|
900
|
+
const statusBadgeBg = docStatus === 'published' && !hasUnpublishedChanges
|
|
901
|
+
? 'bg-[var(--kyro-success)]/10 text-[var(--kyro-success)] border-[var(--kyro-success)]/20'
|
|
902
|
+
: hasUnpublishedChanges
|
|
903
|
+
? 'bg-[var(--kyro-warning)]/10 text-[var(--kyro-warning)] border-[var(--kyro-warning)]/20'
|
|
904
|
+
: 'bg-[var(--kyro-text-muted)]/10 text-[var(--kyro-text-muted)] border-[var(--kyro-text-muted)]/20';
|
|
905
|
+
|
|
671
906
|
return (
|
|
672
907
|
<header className="surface-tile px-8 py-6 flex items-center justify-between sticky top-0 z-50 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] backdrop-blur-md">
|
|
673
|
-
<div className="flex flex-col gap-
|
|
674
|
-
<div className="flex items-center gap-
|
|
908
|
+
<div className="flex flex-col gap-2">
|
|
909
|
+
<div className="flex items-center gap-3">
|
|
675
910
|
<a
|
|
676
911
|
href={`/${collectionSlug}`}
|
|
677
912
|
className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
|
|
@@ -691,14 +926,12 @@ if (field.type === "row" && "fields" in field) {
|
|
|
691
926
|
</svg>
|
|
692
927
|
</a>
|
|
693
928
|
<h1 className="text-xl font-bold tracking-tighter">{docTitle}</h1>
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
<span className="flex items-center gap-1.5 capitalize">
|
|
697
|
-
<span
|
|
698
|
-
className={`h-1.5 w-1.5 rounded-full ${statusColor}`}
|
|
699
|
-
/>
|
|
929
|
+
<span className={`inline-flex items-center gap-1.5 px-2 rounded-full text-[10px] font-regular border ${statusBadgeBg}`}>
|
|
930
|
+
<span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
|
|
700
931
|
{statusLabel}
|
|
701
932
|
</span>
|
|
933
|
+
</div>
|
|
934
|
+
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
702
935
|
{autoSaveStatus === "saving" && (
|
|
703
936
|
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
704
937
|
<svg
|
|
@@ -735,16 +968,62 @@ if (field.type === "row" && "fields" in field) {
|
|
|
735
968
|
>
|
|
736
969
|
<path d="M20 6L9 17l-5-5" />
|
|
737
970
|
</svg>
|
|
738
|
-
Draft saved
|
|
971
|
+
{lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Draft saved"}
|
|
972
|
+
</span>
|
|
973
|
+
)}
|
|
974
|
+
{autoSaveStatus === "retrying" && (
|
|
975
|
+
<span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
|
|
976
|
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
|
|
977
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
978
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
979
|
+
</svg>
|
|
980
|
+
Retrying save ({retryCount}/5)
|
|
981
|
+
</span>
|
|
982
|
+
)}
|
|
983
|
+
{autoSaveStatus === "offline" && (
|
|
984
|
+
<span className="text-[var(--kyro-text-muted)] flex items-center gap-1.5">
|
|
985
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
986
|
+
<path d="M10.61 10.61a3 3 0 0 0 4.24 4.24" />
|
|
987
|
+
<path d="M13.36 13.36a3 3 0 0 0-4.24-4.24" />
|
|
988
|
+
<path d="m2 2 20 20" />
|
|
989
|
+
<path d="M18.36 5.64a9 9 0 0 0-12.72 0" />
|
|
990
|
+
<path d="M22.61 1.39a15 15 0 0 0-21.22 0" />
|
|
991
|
+
</svg>
|
|
992
|
+
Offline — cached locally
|
|
739
993
|
</span>
|
|
740
994
|
)}
|
|
741
995
|
{autoSaveStatus === "error" && (
|
|
742
996
|
<span className="text-[var(--kyro-danger)]">Draft save failed</span>
|
|
743
997
|
)}
|
|
744
998
|
{autoSaveStatus === "conflict" && (
|
|
745
|
-
<
|
|
999
|
+
<div className="flex items-center gap-3">
|
|
1000
|
+
<span className="text-[var(--kyro-danger)] font-semibold">Conflict detected</span>
|
|
1001
|
+
<span className="opacity-30">—</span>
|
|
1002
|
+
<button
|
|
1003
|
+
type="button"
|
|
1004
|
+
onClick={async () => {
|
|
1005
|
+
// Keep mine: force save and overwrite server
|
|
1006
|
+
await saveDocument(formData);
|
|
1007
|
+
setAutoSaveStatus("success");
|
|
1008
|
+
}}
|
|
1009
|
+
className="text-[var(--kyro-primary)] hover:underline"
|
|
1010
|
+
>
|
|
1011
|
+
Keep my changes
|
|
1012
|
+
</button>
|
|
1013
|
+
<span className="opacity-30">|</span>
|
|
1014
|
+
<button
|
|
1015
|
+
type="button"
|
|
1016
|
+
onClick={() => {
|
|
1017
|
+
// Reload server version
|
|
1018
|
+
window.location.reload();
|
|
1019
|
+
}}
|
|
1020
|
+
className="text-[var(--kyro-danger)] hover:underline"
|
|
1021
|
+
>
|
|
1022
|
+
Reload server version
|
|
1023
|
+
</button>
|
|
1024
|
+
</div>
|
|
746
1025
|
)}
|
|
747
|
-
{hasUnsavedChanges && autoSaveStatus !== "saving" && (
|
|
1026
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
748
1027
|
<>
|
|
749
1028
|
<span className="opacity-30">—</span>
|
|
750
1029
|
<button
|
|
@@ -789,7 +1068,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
789
1068
|
<button
|
|
790
1069
|
type="button"
|
|
791
1070
|
onClick={() => setShowPreview(!showPreview)}
|
|
792
|
-
className={`p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "
|
|
1071
|
+
className={`kyro-btn p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
793
1072
|
title="Live Preview"
|
|
794
1073
|
>
|
|
795
1074
|
<svg
|
|
@@ -813,7 +1092,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
813
1092
|
onClick={() => {
|
|
814
1093
|
window.dispatchEvent(new CustomEvent("toggle-sidebar"));
|
|
815
1094
|
}}
|
|
816
|
-
className={`p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "
|
|
1095
|
+
className={`kyro-btn p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
817
1096
|
title="Toggle Sidebar"
|
|
818
1097
|
>
|
|
819
1098
|
<svg
|
|
@@ -829,282 +1108,184 @@ if (field.type === "row" && "fields" in field) {
|
|
|
829
1108
|
</svg>
|
|
830
1109
|
</button>
|
|
831
1110
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
autoSaveSkipRef.current = true;
|
|
837
|
-
const hiddenInput = document.getElementById(
|
|
838
|
-
"form-data",
|
|
839
|
-
) as HTMLInputElement;
|
|
840
|
-
if (!hiddenInput || !hiddenInput.value) return;
|
|
841
|
-
|
|
842
|
-
const btn = document.getElementById(
|
|
843
|
-
"btn-save",
|
|
844
|
-
) as HTMLButtonElement;
|
|
845
|
-
const originalText = btn?.textContent || "";
|
|
846
|
-
if (btn) {
|
|
847
|
-
btn.textContent = "Saving...";
|
|
848
|
-
btn.setAttribute("disabled", "true");
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
try {
|
|
852
|
-
const data = JSON.parse(hiddenInput.value);
|
|
853
|
-
const isPost = isNew && !globalSlug;
|
|
854
|
-
|
|
855
|
-
const response = isPost
|
|
856
|
-
? await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
857
|
-
method: "POST",
|
|
858
|
-
headers: { "Content-Type": "application/json" },
|
|
859
|
-
body: JSON.stringify(data),
|
|
860
|
-
})
|
|
861
|
-
: await saveDocument(data);
|
|
862
|
-
|
|
863
|
-
if (response.ok) {
|
|
864
|
-
const result = await response.json();
|
|
865
|
-
setFormData(result.data || data);
|
|
866
|
-
setLastSavedData(result.data || data);
|
|
867
|
-
lastAutoSaveTimeRef.current = Date.now();
|
|
868
|
-
setAutoSaveStatus("success");
|
|
869
|
-
await clearDraftArtifacts();
|
|
870
|
-
if (versionsEnabled) fetchVersions();
|
|
871
|
-
setTimeout(() => setAutoSaveStatus("idle"), 2000);
|
|
872
|
-
onActionSuccess?.(
|
|
873
|
-
isPost ? "Document created successfully" : "Changes saved",
|
|
874
|
-
);
|
|
875
|
-
if (globalSlug) {
|
|
876
|
-
setTimeout(() => {
|
|
877
|
-
window.location.reload();
|
|
878
|
-
}, 1000);
|
|
879
|
-
}
|
|
880
|
-
if (isPost) {
|
|
881
|
-
setTimeout(() => {
|
|
882
|
-
window.location.href = `/${collectionSlug}`;
|
|
883
|
-
}, 800);
|
|
884
|
-
}
|
|
885
|
-
} else {
|
|
886
|
-
const error = await response.json();
|
|
887
|
-
if (response.status === 409) {
|
|
888
|
-
setAutoSaveStatus("conflict");
|
|
889
|
-
}
|
|
890
|
-
alert({
|
|
891
|
-
title: response.status === 409 ? "Conflict detected" : "Error",
|
|
892
|
-
message: error.error || "Failed to save",
|
|
893
|
-
});
|
|
894
|
-
}
|
|
895
|
-
} catch (err) {
|
|
896
|
-
alert({
|
|
897
|
-
title: "Error",
|
|
898
|
-
message: "Failed to save document",
|
|
899
|
-
});
|
|
900
|
-
} finally {
|
|
901
|
-
autoSaveSkipRef.current = false;
|
|
902
|
-
if (btn) {
|
|
903
|
-
btn.textContent = originalText;
|
|
904
|
-
btn.removeAttribute("disabled");
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}}
|
|
908
|
-
className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
|
|
909
|
-
>
|
|
910
|
-
{isNew ? (globalSlug ? "Save" : "Create") : hasUnsavedChanges ? (versionsEnabled ? "Save Draft" : "Save") : "Saved"}
|
|
911
|
-
</button>
|
|
912
|
-
|
|
913
|
-
{!isNew && versionsEnabled && documentStatus === "draft" && (
|
|
914
|
-
<button
|
|
915
|
-
id="btn-publish"
|
|
916
|
-
type="button"
|
|
917
|
-
onClick={async () => {
|
|
918
|
-
autoSaveSkipRef.current = true;
|
|
919
|
-
const btn = document.getElementById(
|
|
920
|
-
"btn-publish",
|
|
921
|
-
) as HTMLButtonElement;
|
|
922
|
-
const originalText = btn?.textContent || "";
|
|
923
|
-
if (btn) {
|
|
924
|
-
btn.textContent = "Publishing...";
|
|
925
|
-
btn.setAttribute("disabled", "true");
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
try {
|
|
929
|
-
if (hasUnsavedChanges) {
|
|
930
|
-
const saveResponse = await saveDocument(formData);
|
|
931
|
-
if (!saveResponse.ok) {
|
|
932
|
-
const saveError = await saveResponse.json().catch(() => ({}));
|
|
933
|
-
if (saveResponse.status === 409) {
|
|
934
|
-
setAutoSaveStatus("conflict");
|
|
935
|
-
}
|
|
936
|
-
alert({
|
|
937
|
-
title:
|
|
938
|
-
saveResponse.status === 409
|
|
939
|
-
? "Conflict detected"
|
|
940
|
-
: "Error",
|
|
941
|
-
message: saveError.error || "Failed to save latest draft before publishing",
|
|
942
|
-
});
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const saveResult = await saveResponse.json();
|
|
947
|
-
setFormData(saveResult.data || formData);
|
|
948
|
-
setLastSavedData(saveResult.data || formData);
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
const response = await publishDocument();
|
|
952
|
-
|
|
953
|
-
if (response.ok) {
|
|
954
|
-
await clearDraftArtifacts();
|
|
955
|
-
onActionSuccess?.("Published successfully");
|
|
956
|
-
location.reload();
|
|
957
|
-
} else {
|
|
958
|
-
const error = await response.json();
|
|
959
|
-
if (response.status === 409) {
|
|
960
|
-
setAutoSaveStatus("conflict");
|
|
961
|
-
}
|
|
962
|
-
alert({
|
|
963
|
-
title: response.status === 409 ? "Conflict detected" : "Error",
|
|
964
|
-
message: error.error || "Failed to publish",
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
} catch (err) {
|
|
968
|
-
alert({
|
|
969
|
-
title: "Error",
|
|
970
|
-
message: "Failed to publish",
|
|
971
|
-
});
|
|
972
|
-
} finally {
|
|
973
|
-
autoSaveSkipRef.current = false;
|
|
974
|
-
if (btn) {
|
|
975
|
-
btn.textContent = originalText;
|
|
976
|
-
btn.removeAttribute("disabled");
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}}
|
|
980
|
-
className="px-6 py-2.5 text-xs font-bold rounded-xl border-2 border-[var(--kyro-border)] text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)] hover:text-white transition-all"
|
|
981
|
-
>
|
|
982
|
-
{formData._prevStatus === 'published' ? 'Publish Changes' : 'Publish'}
|
|
983
|
-
</button>
|
|
984
|
-
)}
|
|
985
|
-
|
|
986
|
-
<div ref={menuRef} className="relative">
|
|
987
|
-
<button
|
|
988
|
-
type="button"
|
|
989
|
-
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
990
|
-
className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
|
|
991
|
-
>
|
|
992
|
-
<svg
|
|
993
|
-
width="20"
|
|
994
|
-
height="20"
|
|
995
|
-
viewBox="0 0 24 24"
|
|
996
|
-
fill="none"
|
|
997
|
-
stroke="currentColor"
|
|
998
|
-
strokeWidth="3"
|
|
999
|
-
>
|
|
1000
|
-
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
|
1001
|
-
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
|
1002
|
-
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
1111
|
+
{documentStatus === "published" && !isNew && !hasUnsavedChanges ? (
|
|
1112
|
+
<span className="inline-flex items-center gap-1.5 px-6 py-2.5 text-xs rounded-xl bg-green-100 text-green-700 border border-green-200 cursor-not-allowed">
|
|
1113
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
1114
|
+
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
|
1003
1115
|
</svg>
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1116
|
+
Published
|
|
1117
|
+
</span>
|
|
1118
|
+
) : (
|
|
1119
|
+
<div ref={menuRef} className="relative flex items-center gap-3">
|
|
1120
|
+
<div className="flex items-center rounded-xl overflow-hidden shadow-lg">
|
|
1007
1121
|
<button
|
|
1122
|
+
id="btn-publish"
|
|
1008
1123
|
type="button"
|
|
1009
|
-
onClick={
|
|
1010
|
-
|
|
1011
|
-
setIsMenuOpen(false);
|
|
1012
|
-
}}
|
|
1013
|
-
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1124
|
+
onClick={isDraftMode ? handleSaveDraft : handlePublish}
|
|
1125
|
+
className={`px-6 py-2.5 text-xs font-bold rounded-l-xl rounded-r-none transition-all whitespace-nowrap ${isDraftMode ? 'kyro-btn-primary' : 'kyro-btn-success'}`}
|
|
1014
1126
|
>
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
>
|
|
1023
|
-
<
|
|
1024
|
-
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
1127
|
+
{isDraftMode ? "Save Draft" : "Publish Changes"}
|
|
1128
|
+
</button>
|
|
1129
|
+
<button
|
|
1130
|
+
type="button"
|
|
1131
|
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
1132
|
+
className={`px-2.5 py-2.5 text-xs rounded-r-xl rounded-l-none transition-all ${isDraftMode ? 'kyro-btn-primary' : 'kyro-btn-success'}`}
|
|
1133
|
+
>
|
|
1134
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
1135
|
+
<polyline points="6 9 12 15 18 9" />
|
|
1025
1136
|
</svg>
|
|
1026
|
-
Create New
|
|
1027
1137
|
</button>
|
|
1028
|
-
|
|
1029
|
-
|
|
1138
|
+
</div>
|
|
1139
|
+
|
|
1140
|
+
{isMenuOpen && (
|
|
1141
|
+
<div className="absolute right-0 top-full mt-2 w-56 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 overflow-hidden">
|
|
1142
|
+
<button
|
|
1143
|
+
type="button"
|
|
1144
|
+
onClick={() => {
|
|
1145
|
+
handleSaveDraft();
|
|
1146
|
+
setIsMenuOpen(false);
|
|
1147
|
+
}}
|
|
1148
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1149
|
+
>
|
|
1150
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1151
|
+
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
|
1152
|
+
<polyline points="17 21 17 13 7 13 7 21" />
|
|
1153
|
+
<polyline points="7 3 7 8 15 8" />
|
|
1154
|
+
</svg>
|
|
1155
|
+
Save Draft
|
|
1156
|
+
</button>
|
|
1157
|
+
<button
|
|
1158
|
+
type="button"
|
|
1159
|
+
onClick={() => {
|
|
1160
|
+
handlePublish();
|
|
1161
|
+
setIsMenuOpen(false);
|
|
1162
|
+
}}
|
|
1163
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1164
|
+
>
|
|
1165
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1166
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
1167
|
+
</svg>
|
|
1168
|
+
Publish
|
|
1169
|
+
</button>
|
|
1170
|
+
<button
|
|
1171
|
+
type="button"
|
|
1172
|
+
onClick={() => {
|
|
1173
|
+
setIsMenuOpen(false);
|
|
1174
|
+
setShowSchedulePicker(true);
|
|
1175
|
+
}}
|
|
1176
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1177
|
+
>
|
|
1178
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1179
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
1180
|
+
<line x1="16" y1="2" x2="16" y2="6" />
|
|
1181
|
+
<line x1="8" y1="2" x2="8" y2="6" />
|
|
1182
|
+
<line x1="3" y1="10" x2="21" y2="10" />
|
|
1183
|
+
</svg>
|
|
1184
|
+
Schedule Publish
|
|
1185
|
+
</button>
|
|
1186
|
+
<div className="h-px bg-[var(--kyro-border)]" />
|
|
1187
|
+
{!globalSlug && (
|
|
1030
1188
|
<button
|
|
1031
1189
|
type="button"
|
|
1032
1190
|
onClick={() => {
|
|
1033
|
-
|
|
1191
|
+
handleCreateNew();
|
|
1034
1192
|
setIsMenuOpen(false);
|
|
1035
1193
|
}}
|
|
1036
1194
|
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1037
1195
|
>
|
|
1038
|
-
<svg
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
viewBox="0 0 24 24"
|
|
1042
|
-
fill="none"
|
|
1043
|
-
stroke="currentColor"
|
|
1044
|
-
strokeWidth="2"
|
|
1045
|
-
>
|
|
1046
|
-
<rect
|
|
1047
|
-
x="9"
|
|
1048
|
-
y="9"
|
|
1049
|
-
width="13"
|
|
1050
|
-
height="13"
|
|
1051
|
-
rx="2"
|
|
1052
|
-
ry="2"
|
|
1053
|
-
></rect>
|
|
1054
|
-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
1196
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1197
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
1198
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
1055
1199
|
</svg>
|
|
1056
|
-
|
|
1200
|
+
Create New
|
|
1057
1201
|
</button>
|
|
1058
|
-
|
|
1202
|
+
)}
|
|
1203
|
+
{!isNew && !globalSlug && (
|
|
1204
|
+
<>
|
|
1059
1205
|
<button
|
|
1060
1206
|
type="button"
|
|
1061
1207
|
onClick={() => {
|
|
1062
|
-
|
|
1208
|
+
handleDuplicate();
|
|
1063
1209
|
setIsMenuOpen(false);
|
|
1064
1210
|
}}
|
|
1065
1211
|
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1066
1212
|
>
|
|
1067
|
-
<svg
|
|
1068
|
-
width="
|
|
1069
|
-
|
|
1070
|
-
viewBox="0 0 24 24"
|
|
1071
|
-
fill="none"
|
|
1072
|
-
stroke="currentColor"
|
|
1073
|
-
strokeWidth="2"
|
|
1074
|
-
>
|
|
1075
|
-
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
|
1076
|
-
<line x1="1" y1="1" x2="23" y2="23"></line>
|
|
1213
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1214
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
1215
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
1077
1216
|
</svg>
|
|
1078
|
-
|
|
1217
|
+
Duplicate
|
|
1079
1218
|
</button>
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1219
|
+
{documentStatus === "published" && (
|
|
1220
|
+
<button
|
|
1221
|
+
type="button"
|
|
1222
|
+
onClick={() => {
|
|
1223
|
+
handleUnpublish();
|
|
1224
|
+
setIsMenuOpen(false);
|
|
1225
|
+
}}
|
|
1226
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1227
|
+
>
|
|
1228
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1229
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
|
1230
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
1231
|
+
</svg>
|
|
1232
|
+
Unpublish
|
|
1233
|
+
</button>
|
|
1234
|
+
)}
|
|
1235
|
+
<div className="h-px bg-[var(--kyro-border)]" />
|
|
1236
|
+
<button
|
|
1237
|
+
type="button"
|
|
1238
|
+
onClick={() => {
|
|
1239
|
+
handleDelete();
|
|
1240
|
+
setIsMenuOpen(false);
|
|
1241
|
+
}}
|
|
1242
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-red-600 hover:bg-red-50 flex items-center gap-3 transition-colors"
|
|
1097
1243
|
>
|
|
1098
|
-
<
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1244
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1245
|
+
<polyline points="3 6 5 6 21 6" />
|
|
1246
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
1247
|
+
</svg>
|
|
1248
|
+
Delete
|
|
1249
|
+
</button>
|
|
1250
|
+
</>
|
|
1251
|
+
)}
|
|
1252
|
+
</div>
|
|
1253
|
+
)}
|
|
1254
|
+
</div>
|
|
1255
|
+
)}
|
|
1256
|
+
|
|
1257
|
+
{showSchedulePicker && (
|
|
1258
|
+
<div ref={scheduleRef} className="relative">
|
|
1259
|
+
<div className="absolute right-0 top-2 p-4 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50">
|
|
1260
|
+
<p className="text-xs font-medium mb-2">Schedule Publish</p>
|
|
1261
|
+
<input
|
|
1262
|
+
type="datetime-local"
|
|
1263
|
+
id="schedule-datetime"
|
|
1264
|
+
className="kyro-form-input text-xs mb-3 w-full"
|
|
1265
|
+
min={new Date().toISOString().slice(0, 16)}
|
|
1266
|
+
/>
|
|
1267
|
+
<div className="flex items-center gap-2 justify-end">
|
|
1268
|
+
<button
|
|
1269
|
+
type="button"
|
|
1270
|
+
onClick={() => setShowSchedulePicker(false)}
|
|
1271
|
+
className="px-3 py-1.5 text-xs kyro-btn rounded-lg"
|
|
1272
|
+
>
|
|
1273
|
+
Cancel
|
|
1274
|
+
</button>
|
|
1275
|
+
<button
|
|
1276
|
+
type="button"
|
|
1277
|
+
onClick={() => {
|
|
1278
|
+
const val = (document.getElementById("schedule-datetime") as HTMLInputElement)?.value;
|
|
1279
|
+
if (val) handleSchedulePublish(val);
|
|
1280
|
+
}}
|
|
1281
|
+
className="px-3 py-1.5 text-xs kyro-btn-success rounded-lg"
|
|
1282
|
+
>
|
|
1283
|
+
Schedule
|
|
1284
|
+
</button>
|
|
1285
|
+
</div>
|
|
1105
1286
|
</div>
|
|
1106
|
-
|
|
1107
|
-
|
|
1287
|
+
</div>
|
|
1288
|
+
)}
|
|
1108
1289
|
</div>
|
|
1109
1290
|
</div>
|
|
1110
1291
|
</header>
|
|
@@ -1207,7 +1388,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1207
1388
|
type="button"
|
|
1208
1389
|
onClick={handleCompareVersions}
|
|
1209
1390
|
disabled={loadingDiffs}
|
|
1210
|
-
className="px-3 py-1.5 rounded-lg
|
|
1391
|
+
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"
|
|
1211
1392
|
>
|
|
1212
1393
|
{loadingDiffs ? "Comparing..." : "Compare"}
|
|
1213
1394
|
</button>
|
|
@@ -1374,7 +1555,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1374
1555
|
<button
|
|
1375
1556
|
type="button"
|
|
1376
1557
|
onClick={() => handleRestoreVersion(v.id)}
|
|
1377
|
-
className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold
|
|
1558
|
+
className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold tracking-wider text-[var(--kyro-text-secondary)] kyro-btn-primary hover:border-[var(--kyro-primary)] transition-all active:scale-95"
|
|
1378
1559
|
>
|
|
1379
1560
|
Restore
|
|
1380
1561
|
</button>
|
|
@@ -1507,42 +1688,77 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1507
1688
|
<div className="flex flex-col h-full">
|
|
1508
1689
|
{layout !== "single" && renderHeader()}
|
|
1509
1690
|
{layout === "single" && (
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1691
|
+
<>
|
|
1692
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
1693
|
+
<div className="flex items-center gap-3 text-[11px] font-medium">
|
|
1694
|
+
{autoSaveStatus === "saving" && (
|
|
1695
|
+
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
1696
|
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
|
|
1697
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
1698
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
1699
|
+
</svg>
|
|
1700
|
+
Saving...
|
|
1701
|
+
</span>
|
|
1702
|
+
)}
|
|
1703
|
+
{autoSaveStatus === "success" && (
|
|
1704
|
+
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
1705
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
1706
|
+
<path d="M20 6L9 17l-5-5" />
|
|
1707
|
+
</svg>
|
|
1708
|
+
{lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Saved"}
|
|
1709
|
+
</span>
|
|
1710
|
+
)}
|
|
1711
|
+
{autoSaveStatus === "retrying" && (
|
|
1712
|
+
<span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
|
|
1713
|
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
|
|
1714
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
1715
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
1716
|
+
</svg>
|
|
1717
|
+
Retrying...
|
|
1718
|
+
</span>
|
|
1719
|
+
)}
|
|
1720
|
+
{autoSaveStatus === "offline" && (
|
|
1721
|
+
<span className="text-[var(--kyro-text-muted)]">Offline — cached locally</span>
|
|
1722
|
+
)}
|
|
1723
|
+
{autoSaveStatus === "error" && (
|
|
1724
|
+
<span className="text-[var(--kyro-danger)]">Save failed</span>
|
|
1725
|
+
)}
|
|
1726
|
+
{autoSaveStatus === "conflict" && (
|
|
1727
|
+
<span className="text-[var(--kyro-danger)]">Conflict detected</span>
|
|
1728
|
+
)}
|
|
1729
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
1730
|
+
<span className="text-[var(--kyro-warning)]">Unsaved changes</span>
|
|
1731
|
+
)}
|
|
1732
|
+
{!hasUnsavedChanges && autoSaveStatus !== "success" && autoSaveStatus !== "saving" && autoSaveStatus !== "error" && (
|
|
1733
|
+
<span className="text-[var(--kyro-success)]">All changes saved</span>
|
|
1734
|
+
)}
|
|
1735
|
+
</div>
|
|
1736
|
+
<span className="text-[11px] text-[var(--kyro-text-muted)] opacity-60">
|
|
1737
|
+
{formData.updatedAt ? `Modified ${new Date(formData.updatedAt as string).toLocaleString()}` : ""}
|
|
1738
|
+
</span>
|
|
1739
|
+
</div>
|
|
1740
|
+
<button
|
|
1741
|
+
id="btn-save"
|
|
1742
|
+
type="button"
|
|
1743
|
+
style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
|
|
1744
|
+
onClick={async () => {
|
|
1745
|
+
console.log("[AutoForm] Hidden save button clicked");
|
|
1746
|
+
try {
|
|
1747
|
+
const response = await saveDocument();
|
|
1525
1748
|
if (response.ok) {
|
|
1526
1749
|
const result = await response.json();
|
|
1527
|
-
const savedData = result.data ||
|
|
1528
|
-
setFormData(savedData);
|
|
1529
|
-
setLastSavedData(savedData);
|
|
1750
|
+
const savedData = result.data || formData;
|
|
1751
|
+
setFormData({ ...formData, ...savedData });
|
|
1752
|
+
setLastSavedData({ ...formData, ...savedData });
|
|
1530
1753
|
onActionSuccess?.("Changes saved");
|
|
1531
|
-
// Trigger a refresh to ensure all global state is updated
|
|
1532
|
-
setTimeout(() => {
|
|
1533
|
-
window.location.reload();
|
|
1534
|
-
}, 1000); // Small delay to let the toast show
|
|
1535
|
-
} else {
|
|
1536
|
-
const errorData = await response.json().catch(() => ({}));
|
|
1537
|
-
console.error("Save global failed:", errorData);
|
|
1538
|
-
onActionError?.(errorData.error || "Save failed");
|
|
1539
1754
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1755
|
+
} catch (e) {
|
|
1756
|
+
console.error("Save error exception:", e);
|
|
1757
|
+
onActionError?.("Save failed: " + (e as Error).message);
|
|
1758
|
+
}
|
|
1759
|
+
}}
|
|
1760
|
+
/>
|
|
1761
|
+
</>
|
|
1546
1762
|
)}
|
|
1547
1763
|
<main className="w-full">
|
|
1548
1764
|
{view === "edit" && renderEditView()}
|
|
@@ -1596,7 +1812,7 @@ function RelationshipField({
|
|
|
1596
1812
|
fetchOptions();
|
|
1597
1813
|
}, [targetCollection]);
|
|
1598
1814
|
|
|
1599
|
-
const getLabel = (opt: unknown) => {
|
|
1815
|
+
const getLabel = (opt: unknown) => {
|
|
1600
1816
|
const o = opt as Record<string, unknown> | undefined;
|
|
1601
1817
|
if (!o) return "";
|
|
1602
1818
|
return String(
|
|
@@ -1658,13 +1874,13 @@ const getLabel = (opt: unknown) => {
|
|
|
1658
1874
|
|
|
1659
1875
|
const filteredOptions = (search
|
|
1660
1876
|
? (options || []).filter((opt) => {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1877
|
+
const o = opt as Record<string, unknown>;
|
|
1878
|
+
const term = search.toLowerCase();
|
|
1879
|
+
const searchableFields = ["title", "name", "label", "filename", "slug"];
|
|
1880
|
+
return searchableFields.some(
|
|
1881
|
+
(key) => o[key] && String(o[key]).toLowerCase().includes(term),
|
|
1882
|
+
);
|
|
1883
|
+
})
|
|
1668
1884
|
: options || []) as Array<Record<string, unknown>>;
|
|
1669
1885
|
|
|
1670
1886
|
return (
|