@kyro-cms/admin 0.9.0 → 0.9.2
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 +11715 -11292
- 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 +564 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.js +11326 -10912
- package/dist/index.js.map +1 -1
- package/package.json +16 -12
- package/src/components/ActionBar.tsx +25 -161
- package/src/components/Admin.tsx +2 -4
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AuditLogsPage.tsx +2 -13
- package/src/components/AutoForm.tsx +572 -461
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +52 -65
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +57 -216
- package/src/components/MediaGallery.tsx +334 -367
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +59 -52
- 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 +433 -55
- 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/RelationshipBlockField.tsx +2 -3
- package/src/components/fields/RelationshipField.tsx +155 -90
- 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/fix_imports.cjs +23 -0
- package/src/components/fix_imports2.cjs +19 -0
- package/src/components/replace_svgs.cjs +63 -0
- package/src/components/ui/Dropdown.tsx +7 -2
- package/src/components/ui/Modal.tsx +24 -27
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/PromptModal.tsx +2 -10
- package/src/components/ui/SlidePanel.tsx +10 -13
- package/src/components/ui/SplitButton.tsx +107 -0
- package/src/components/ui/Toaster.tsx +0 -1
- package/src/components/ui/icons.tsx +110 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/components/users/UsersList.tsx +8 -85
- package/src/hooks/useAutoFormState.ts +187 -196
- package/src/hooks/useQueue.ts +60 -0
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AdminLayout.astro +22 -2
- package/src/layouts/AuthLayout.astro +67 -7
- package/src/lib/autoform-store.ts +90 -53
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +48 -11
- 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/auth/register.astro +5 -2
- 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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ChevronRight, Check, ExternalLink, X } from "./ui/icons";
|
|
1
2
|
import { useState, useRef, useEffect } from "react";
|
|
2
3
|
import type {
|
|
3
4
|
CollectionConfig,
|
|
@@ -18,18 +19,22 @@ import TextField from "./fields/TextField";
|
|
|
18
19
|
import { globals, collections } from "../lib/config";
|
|
19
20
|
import { slugifyText } from "../lib/slugify";
|
|
20
21
|
import { resolveUrl, apiDelete, fetchWithAuth } from "../lib/api";
|
|
22
|
+
import { normalizeUploadFields } from "../lib/normalize-upload-fields";
|
|
21
23
|
import { useAutoFormStore } from "../lib/autoform-store";
|
|
22
24
|
import { useAutoFormState } from "../hooks/useAutoFormState";
|
|
23
|
-
import { useUIStore } from "../lib/stores";
|
|
25
|
+
import { useUIStore, toast } from "../lib/stores";
|
|
26
|
+
import { EmptyState } from "./ui/EmptyState";
|
|
24
27
|
|
|
25
28
|
import { adminPath as ADMIN_BASE, apiPath as API_BASE } from "../lib/paths";
|
|
26
29
|
|
|
27
30
|
import { BlocksField } from "./fields/BlocksField";
|
|
28
|
-
import PortableTextField from "./fields/PortableTextField";
|
|
29
31
|
import { ConfirmModal, Modal as UIModal } from "./ui/Modal";
|
|
30
32
|
import { ListField } from "./fields/ListField";
|
|
31
33
|
import { RelationshipBlockField } from "./fields/RelationshipBlockField";
|
|
32
34
|
import { FieldRenderer } from "./FieldRenderer";
|
|
35
|
+
import { Dropdown, DropdownItem, DropdownSeparator } from "./ui/Dropdown";
|
|
36
|
+
import { SplitButton } from "./ui/SplitButton";
|
|
37
|
+
import type { SplitButtonStatus } from "./ui/SplitButton";
|
|
33
38
|
import { TabsLayout } from "./fields/TabsLayout";
|
|
34
39
|
import { GroupLayout } from "./fields/GroupLayout";
|
|
35
40
|
import { ArrayLayout } from "./fields/ArrayLayout";
|
|
@@ -73,14 +78,17 @@ export function AutoForm({
|
|
|
73
78
|
const config = activeConfig || propConfig;
|
|
74
79
|
|
|
75
80
|
|
|
76
|
-
const { confirm
|
|
81
|
+
const { confirm } = useUIStore();
|
|
77
82
|
|
|
78
83
|
const {
|
|
79
84
|
formData,
|
|
80
85
|
lastSavedData,
|
|
81
86
|
hasUnsavedChanges,
|
|
82
87
|
isAutoSaving,
|
|
88
|
+
backgroundProcessing,
|
|
83
89
|
autoSaveStatus,
|
|
90
|
+
lastSavedAt,
|
|
91
|
+
retryCount,
|
|
84
92
|
sidebarCollapsed,
|
|
85
93
|
setSidebarCollapsed,
|
|
86
94
|
activeTab,
|
|
@@ -114,8 +122,6 @@ export function AutoForm({
|
|
|
114
122
|
setAutoSaveStatus,
|
|
115
123
|
fetchVersions,
|
|
116
124
|
saveDocument,
|
|
117
|
-
publishDocument,
|
|
118
|
-
clearDraftArtifacts,
|
|
119
125
|
autoSaveSkipRef,
|
|
120
126
|
lastAutoSaveTimeRef,
|
|
121
127
|
documentStatus,
|
|
@@ -132,8 +138,36 @@ export function AutoForm({
|
|
|
132
138
|
});
|
|
133
139
|
|
|
134
140
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
141
|
+
const scheduleRef = useRef<HTMLDivElement>(null);
|
|
142
|
+
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
|
|
143
|
+
const [localSaveStatus, setLocalSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
|
144
|
+
const [now, setNow] = useState(Date.now());
|
|
135
145
|
const disabled = propDisabled;
|
|
136
146
|
|
|
147
|
+
// Tick every 10s so the "saved X ago" label stays fresh
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const id = setInterval(() => setNow(Date.now()), 10_000);
|
|
150
|
+
return () => clearInterval(id);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const resolveAdminFlag = (
|
|
154
|
+
value: boolean | ((
|
|
155
|
+
data: Record<string, unknown>,
|
|
156
|
+
siblingData: Record<string, unknown>,
|
|
157
|
+
) => boolean) | undefined,
|
|
158
|
+
currentData: Record<string, unknown>,
|
|
159
|
+
): boolean => {
|
|
160
|
+
if (typeof value === "function") {
|
|
161
|
+
try {
|
|
162
|
+
return value(formData, currentData);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.warn("Error evaluating admin runtime flag:", error);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return Boolean(value);
|
|
169
|
+
};
|
|
170
|
+
|
|
137
171
|
const handleRestoreVersion = (versionId: string) => {
|
|
138
172
|
confirm({
|
|
139
173
|
title: "Restore Version",
|
|
@@ -161,17 +195,18 @@ export function AutoForm({
|
|
|
161
195
|
|
|
162
196
|
const result = await resp.json();
|
|
163
197
|
if (result.data) {
|
|
164
|
-
|
|
165
|
-
|
|
198
|
+
const restoredData = { ...formData, ...result.data };
|
|
199
|
+
setFormData(restoredData);
|
|
200
|
+
useAutoFormStore.getState().loadDocument(restoredData, restoredData);
|
|
166
201
|
onActionSuccess?.("Version restored successfully");
|
|
167
202
|
fetchVersions();
|
|
168
203
|
setView("edit");
|
|
169
204
|
} else {
|
|
170
|
-
|
|
205
|
+
toast.error(result.error || "Failed to restore version");
|
|
171
206
|
}
|
|
172
207
|
} catch (err) {
|
|
173
208
|
console.error("Failed to restore version:", err);
|
|
174
|
-
|
|
209
|
+
toast.error("Failed to restore version");
|
|
175
210
|
}
|
|
176
211
|
}
|
|
177
212
|
});
|
|
@@ -208,13 +243,18 @@ export function AutoForm({
|
|
|
208
243
|
|
|
209
244
|
useEffect(() => {
|
|
210
245
|
const handleShortcuts = (e: KeyboardEvent) => {
|
|
211
|
-
// Cmd/Ctrl + S =
|
|
246
|
+
// Cmd/Ctrl + S = Save Draft
|
|
212
247
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
213
248
|
e.preventDefault();
|
|
214
|
-
(
|
|
249
|
+
handleSaveDraft();
|
|
215
250
|
}
|
|
216
|
-
// Cmd/Ctrl + P =
|
|
217
|
-
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
|
251
|
+
// Cmd/Ctrl + Shift + P = Publish Changes
|
|
252
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "P" || e.key === "p")) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
(document.getElementById("btn-publish") as HTMLButtonElement | null)?.click();
|
|
255
|
+
}
|
|
256
|
+
// Cmd/Ctrl + P (no shift) = Toggle Preview
|
|
257
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "p") {
|
|
218
258
|
e.preventDefault();
|
|
219
259
|
setShowPreview((prev) => !prev);
|
|
220
260
|
}
|
|
@@ -248,17 +288,30 @@ export function AutoForm({
|
|
|
248
288
|
if (isMenuOpen) {
|
|
249
289
|
document.addEventListener("mousedown", handleClickOutside);
|
|
250
290
|
return () =>
|
|
251
|
-
document.
|
|
291
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
252
292
|
}
|
|
253
293
|
}, [isMenuOpen]);
|
|
254
294
|
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
297
|
+
if (scheduleRef.current && !scheduleRef.current.contains(e.target as Node)) {
|
|
298
|
+
setShowSchedulePicker(false);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
if (showSchedulePicker) {
|
|
302
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
303
|
+
return () =>
|
|
304
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
305
|
+
}
|
|
306
|
+
}, [showSchedulePicker]);
|
|
307
|
+
|
|
255
308
|
const handleCreateNew = () => {
|
|
256
309
|
if (hasUnsavedChanges) {
|
|
257
310
|
confirm({
|
|
258
311
|
title: "Unsaved Changes",
|
|
259
312
|
message: "You have unsaved changes. Save before creating new?",
|
|
260
313
|
onConfirm: async () => {
|
|
261
|
-
|
|
314
|
+
await handleSaveDraft();
|
|
262
315
|
await new Promise((r) => setTimeout(r, 1000));
|
|
263
316
|
window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
|
|
264
317
|
},
|
|
@@ -275,11 +328,12 @@ export function AutoForm({
|
|
|
275
328
|
onConfirm: async () => {
|
|
276
329
|
try {
|
|
277
330
|
const { id, createdAt, updatedAt, ...duplicateData } = formData;
|
|
331
|
+
const normalizedData = normalizeUploadFields(duplicateData) as Record<string, unknown>;
|
|
278
332
|
const response = await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
279
333
|
method: "POST",
|
|
280
334
|
headers: { "Content-Type": "application/json" },
|
|
281
335
|
body: JSON.stringify({
|
|
282
|
-
...
|
|
336
|
+
...normalizedData,
|
|
283
337
|
title: `${duplicateData.title || duplicateData.name || "Copy"} (Copy)`,
|
|
284
338
|
slug: `${duplicateData.slug || "copy"}-${Date.now()}`,
|
|
285
339
|
status: "draft",
|
|
@@ -294,16 +348,10 @@ export function AutoForm({
|
|
|
294
348
|
}
|
|
295
349
|
} else {
|
|
296
350
|
const error = await response.json();
|
|
297
|
-
|
|
298
|
-
title: "Error",
|
|
299
|
-
message: error.error || "Failed to duplicate document",
|
|
300
|
-
});
|
|
351
|
+
toast.error(error.error || "Failed to duplicate document");
|
|
301
352
|
}
|
|
302
353
|
} catch (err) {
|
|
303
|
-
|
|
304
|
-
title: "Error",
|
|
305
|
-
message: "Failed to duplicate document",
|
|
306
|
-
});
|
|
354
|
+
toast.error("Failed to duplicate document");
|
|
307
355
|
}
|
|
308
356
|
},
|
|
309
357
|
});
|
|
@@ -319,10 +367,7 @@ export function AutoForm({
|
|
|
319
367
|
await apiDelete(`/api/${collectionSlug}/${formData.id}`);
|
|
320
368
|
window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
|
|
321
369
|
} catch (err) {
|
|
322
|
-
|
|
323
|
-
title: "Error",
|
|
324
|
-
message: (err as Error).message || "Failed to delete document",
|
|
325
|
-
});
|
|
370
|
+
toast.error((err as Error).message || "Failed to delete document");
|
|
326
371
|
}
|
|
327
372
|
},
|
|
328
373
|
});
|
|
@@ -334,32 +379,173 @@ export function AutoForm({
|
|
|
334
379
|
message: "Unpublish this document?",
|
|
335
380
|
onConfirm: async () => {
|
|
336
381
|
try {
|
|
337
|
-
const response = await
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
method: "POST",
|
|
341
|
-
},
|
|
382
|
+
const response = await saveDocument(
|
|
383
|
+
{ ...formData, status: 'draft' } as Record<string, unknown>,
|
|
384
|
+
false,
|
|
342
385
|
);
|
|
343
|
-
if (response
|
|
386
|
+
if (response?.ok) {
|
|
344
387
|
onActionSuccess?.("Document unpublished successfully");
|
|
345
388
|
location.reload();
|
|
346
389
|
} else {
|
|
347
|
-
const error = await response
|
|
348
|
-
|
|
349
|
-
title: "Error",
|
|
350
|
-
message: error.error || "Failed to unpublish",
|
|
351
|
-
});
|
|
390
|
+
const error = await response?.json().catch(() => ({}));
|
|
391
|
+
toast.error(error?.error || "Failed to unpublish");
|
|
352
392
|
}
|
|
353
393
|
} catch (err) {
|
|
354
|
-
|
|
355
|
-
title: "Error",
|
|
356
|
-
message: "Failed to unpublish",
|
|
357
|
-
});
|
|
394
|
+
toast.error("Failed to unpublish");
|
|
358
395
|
}
|
|
359
396
|
},
|
|
360
397
|
});
|
|
361
398
|
};
|
|
362
399
|
|
|
400
|
+
const handleSaveDraft = async () => {
|
|
401
|
+
const isNewDoc = !formData.id;
|
|
402
|
+
autoSaveSkipRef.current = true;
|
|
403
|
+
setLocalSaveStatus("saving");
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
|
|
407
|
+
const isPost = isNewDoc && !globalSlug;
|
|
408
|
+
|
|
409
|
+
const response = isPost
|
|
410
|
+
? await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
411
|
+
method: "POST",
|
|
412
|
+
headers: { "Content-Type": "application/json" },
|
|
413
|
+
body: JSON.stringify(data),
|
|
414
|
+
})
|
|
415
|
+
: await saveDocument(data);
|
|
416
|
+
|
|
417
|
+
if (response.ok) {
|
|
418
|
+
const result = await response.json();
|
|
419
|
+
const savedData = result.data || data;
|
|
420
|
+
setFormData({ ...formData, ...savedData });
|
|
421
|
+
setLastSavedData({ ...formData, ...savedData });
|
|
422
|
+
lastAutoSaveTimeRef.current = Date.now();
|
|
423
|
+
setAutoSaveStatus("success");
|
|
424
|
+
setLocalSaveStatus("saved");
|
|
425
|
+
if (versionsEnabled) fetchVersions();
|
|
426
|
+
setTimeout(() => {
|
|
427
|
+
setAutoSaveStatus("idle");
|
|
428
|
+
setLocalSaveStatus("idle");
|
|
429
|
+
}, 2000);
|
|
430
|
+
onActionSuccess?.(
|
|
431
|
+
isPost ? "Document created successfully" : "Changes saved",
|
|
432
|
+
);
|
|
433
|
+
if (isPost) {
|
|
434
|
+
setTimeout(() => {
|
|
435
|
+
window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
|
|
436
|
+
}, 800);
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
const error = await response.json();
|
|
440
|
+
if (response.status === 409) {
|
|
441
|
+
setAutoSaveStatus("conflict");
|
|
442
|
+
}
|
|
443
|
+
setLocalSaveStatus("error");
|
|
444
|
+
toast.error(error.error || "Failed to save");
|
|
445
|
+
setTimeout(() => setLocalSaveStatus("idle"), 3000);
|
|
446
|
+
}
|
|
447
|
+
} catch (err) {
|
|
448
|
+
setLocalSaveStatus("error");
|
|
449
|
+
toast.error("Failed to save document");
|
|
450
|
+
setTimeout(() => setLocalSaveStatus("idle"), 3000);
|
|
451
|
+
} finally {
|
|
452
|
+
autoSaveSkipRef.current = false;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const handlePublish = async () => {
|
|
457
|
+
const isNewDoc = !formData.id;
|
|
458
|
+
autoSaveSkipRef.current = true;
|
|
459
|
+
setLocalSaveStatus("saving");
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
if (isNewDoc && !globalSlug) {
|
|
463
|
+
// Create then immediately publish
|
|
464
|
+
const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
|
|
465
|
+
const response = await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: { "Content-Type": "application/json" },
|
|
468
|
+
body: JSON.stringify(data),
|
|
469
|
+
});
|
|
470
|
+
if (!response.ok) {
|
|
471
|
+
const error = await response.json().catch(() => ({}));
|
|
472
|
+
if (response.status === 409) setAutoSaveStatus("conflict");
|
|
473
|
+
setLocalSaveStatus("error");
|
|
474
|
+
toast.error(error.error || "Failed to create document");
|
|
475
|
+
setTimeout(() => setLocalSaveStatus("idle"), 3000);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const result = await response.json();
|
|
479
|
+
const savedData = result.data || data;
|
|
480
|
+
setFormData({ ...formData, ...savedData });
|
|
481
|
+
setLastSavedData({ ...formData, ...savedData });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Save and publish (X-Draft: false writes to main doc + versions table)
|
|
485
|
+
const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
|
|
486
|
+
const response = await saveDocument(data, false);
|
|
487
|
+
|
|
488
|
+
if (response?.ok) {
|
|
489
|
+
setLocalSaveStatus("saved");
|
|
490
|
+
onActionSuccess?.("Published successfully");
|
|
491
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
492
|
+
location.reload();
|
|
493
|
+
} else {
|
|
494
|
+
const error = await response?.json().catch(() => ({}));
|
|
495
|
+
if (response?.status === 409) setAutoSaveStatus("conflict");
|
|
496
|
+
setLocalSaveStatus("error");
|
|
497
|
+
toast.error(error?.error || "Failed to publish");
|
|
498
|
+
setTimeout(() => setLocalSaveStatus("idle"), 3000);
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
setLocalSaveStatus("error");
|
|
502
|
+
toast.error("Failed to publish");
|
|
503
|
+
setTimeout(() => setLocalSaveStatus("idle"), 3000);
|
|
504
|
+
} finally {
|
|
505
|
+
autoSaveSkipRef.current = false;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const handleSchedulePublish = async (scheduledFor: string) => {
|
|
510
|
+
const isNewDoc = !formData.id;
|
|
511
|
+
// Save the document first with _schedulePublishAt metadata
|
|
512
|
+
autoSaveSkipRef.current = true;
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const data = {
|
|
516
|
+
...normalizeUploadFields({ ...formData }) as Record<string, unknown>,
|
|
517
|
+
_schedulePublishAt: scheduledFor,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
if (isNewDoc && !globalSlug) {
|
|
521
|
+
const response = await fetchWithAuth(`/api/${collectionSlug}`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
headers: { "Content-Type": "application/json" },
|
|
524
|
+
body: JSON.stringify(data),
|
|
525
|
+
});
|
|
526
|
+
if (!response.ok) {
|
|
527
|
+
const err = await response.json().catch(() => ({}));
|
|
528
|
+
toast.error(err.error || "Failed to schedule publish");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
const response = await saveDocument(data);
|
|
533
|
+
if (!response.ok) {
|
|
534
|
+
const err = await response.json().catch(() => ({}));
|
|
535
|
+
toast.error(err.error || "Failed to schedule publish");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
onActionSuccess?.(`Scheduled publish for ${new Date(scheduledFor).toLocaleString()}`);
|
|
541
|
+
setShowSchedulePicker(false);
|
|
542
|
+
} catch {
|
|
543
|
+
toast.error("Failed to schedule publish");
|
|
544
|
+
} finally {
|
|
545
|
+
autoSaveSkipRef.current = false;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
363
549
|
const handleFieldChange = (fieldName: string, value: unknown) => {
|
|
364
550
|
setField(fieldName, value);
|
|
365
551
|
};
|
|
@@ -369,22 +555,54 @@ export function AutoForm({
|
|
|
369
555
|
parentData?: Record<string, unknown>,
|
|
370
556
|
onParentChange?: (val: unknown) => void,
|
|
371
557
|
): React.ReactNode => {
|
|
372
|
-
if (field.admin?.hidden) return null;
|
|
373
|
-
|
|
374
558
|
const currentData = parentData !== undefined ? parentData : formData;
|
|
559
|
+
const isHidden = resolveAdminFlag((field.hidden !== undefined ? field.hidden : field.admin?.hidden) as any, currentData);
|
|
560
|
+
if (isHidden) return null;
|
|
561
|
+
|
|
562
|
+
const isReadOnly = resolveAdminFlag((field.readOnly !== undefined ? field.readOnly : field.admin?.readOnly) as any, currentData);
|
|
563
|
+
const effectiveDisabled = Boolean(disabled || isReadOnly);
|
|
375
564
|
|
|
376
565
|
// Evaluate display condition if present
|
|
377
566
|
// For conditional fields, pass formData as the root context (first arg)
|
|
378
567
|
// and currentData as the sibling context (second arg)
|
|
379
|
-
if (field.admin?.condition
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
568
|
+
if (field.admin?.condition) {
|
|
569
|
+
if (typeof field.admin.condition === "function") {
|
|
570
|
+
try {
|
|
571
|
+
// Compatibility wrapper: pass { values: formData, ...formData } to support both old and new signatures
|
|
572
|
+
const evalData = { values: formData || {}, ...(formData || {}) };
|
|
573
|
+
const shouldShow = field.admin.condition(evalData, currentData);
|
|
574
|
+
if (!shouldShow) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
} catch (e) {
|
|
578
|
+
console.warn(`Condition error for field ${field.name}:`, e);
|
|
579
|
+
// Show the field if there's an error evaluating the condition
|
|
580
|
+
}
|
|
581
|
+
} else if (typeof field.admin.condition === "object") {
|
|
582
|
+
try {
|
|
583
|
+
const cond = field.admin.condition as any;
|
|
584
|
+
const targetField = cond.field;
|
|
585
|
+
|
|
586
|
+
// Get target field value, prioritizing sibling context (currentData) then root context (formData)
|
|
587
|
+
const val = (currentData && currentData[targetField] !== undefined)
|
|
588
|
+
? currentData[targetField]
|
|
589
|
+
: (formData && formData[targetField] !== undefined ? formData[targetField] : undefined);
|
|
590
|
+
|
|
591
|
+
let shouldShow = true;
|
|
592
|
+
if ("equals" in cond) {
|
|
593
|
+
shouldShow = val === cond.equals;
|
|
594
|
+
} else if ("notEquals" in cond) {
|
|
595
|
+
shouldShow = val !== cond.notEquals;
|
|
596
|
+
} else if ("in" in cond && Array.isArray(cond.in)) {
|
|
597
|
+
shouldShow = cond.in.includes(val);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!shouldShow) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
} catch (e) {
|
|
604
|
+
console.warn(`Declarative condition error for field ${field.name}:`, e);
|
|
384
605
|
}
|
|
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
606
|
}
|
|
389
607
|
}
|
|
390
608
|
|
|
@@ -399,7 +617,7 @@ export function AutoForm({
|
|
|
399
617
|
}
|
|
400
618
|
};
|
|
401
619
|
|
|
402
|
-
if (field.type === "row" && "fields" in field) {
|
|
620
|
+
if (field.type === "row" && "fields" in field) {
|
|
403
621
|
const rowFields = (field as Field & { fields?: Field[] }).fields;
|
|
404
622
|
return (
|
|
405
623
|
<div
|
|
@@ -410,7 +628,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
410
628
|
const fAdmin = f.admin || {};
|
|
411
629
|
const actionUrl = fAdmin?.action as string | undefined;
|
|
412
630
|
|
|
413
|
-
if (f.type === "button" && actionUrl) {
|
|
631
|
+
if ((f.type === "button" || f.type === "action") && actionUrl) {
|
|
414
632
|
const siblingEmailField = rowFields?.find(
|
|
415
633
|
(ff: Field) => ff.type === "email",
|
|
416
634
|
);
|
|
@@ -465,8 +683,8 @@ if (field.type === "row" && "fields" in field) {
|
|
|
465
683
|
}
|
|
466
684
|
}}
|
|
467
685
|
//@ts-ignore
|
|
468
|
-
disabled={loadingFields[f.name as string] ||
|
|
469
|
-
className="
|
|
686
|
+
disabled={loadingFields[f.name as string] || effectiveDisabled}
|
|
687
|
+
className="kyro-btn kyro-btn-primary px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
470
688
|
>
|
|
471
689
|
{loadingFields[f.name as string] ? "Sending..." : f.label || "Click"}
|
|
472
690
|
</button>
|
|
@@ -477,7 +695,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
477
695
|
return (
|
|
478
696
|
<div
|
|
479
697
|
key={f.name}
|
|
480
|
-
className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
|
|
698
|
+
className={f.type === "button" || f.type === "action" ? "flex-shrink-0" : "flex-1"}
|
|
481
699
|
style={
|
|
482
700
|
fAdmin?.width ? { width: fAdmin.width as string, flex: "none" } : {}
|
|
483
701
|
}
|
|
@@ -497,11 +715,10 @@ if (field.type === "row" && "fields" in field) {
|
|
|
497
715
|
key={field.name || `tabs-${Math.random()}`}
|
|
498
716
|
field={field}
|
|
499
717
|
formData={formData}
|
|
500
|
-
onTabDataChange={(
|
|
501
|
-
|
|
502
|
-
updateTabData(field.name as string, newTabData);
|
|
718
|
+
onTabDataChange={(tabData) => {
|
|
719
|
+
setField(field.name!, tabData);
|
|
503
720
|
}}
|
|
504
|
-
renderField={renderField}
|
|
721
|
+
renderField={(f, parentData, onChange) => renderField(f, parentData, onChange)}
|
|
505
722
|
/>
|
|
506
723
|
);
|
|
507
724
|
|
|
@@ -524,19 +741,20 @@ if (field.type === "row" && "fields" in field) {
|
|
|
524
741
|
value={value as unknown[]}
|
|
525
742
|
onChange={onFieldChange}
|
|
526
743
|
renderField={renderField}
|
|
527
|
-
disabled={
|
|
744
|
+
disabled={effectiveDisabled}
|
|
528
745
|
/>
|
|
529
746
|
);
|
|
530
747
|
|
|
531
748
|
|
|
532
|
-
case "button":
|
|
749
|
+
case "button":
|
|
750
|
+
case "action": {
|
|
533
751
|
const fieldName = field.name as string;
|
|
534
752
|
const isLoading = loadingFields[fieldName];
|
|
535
753
|
return (
|
|
536
754
|
<div key={fieldName} className="kyro-form-field">
|
|
537
755
|
<button
|
|
538
756
|
type="button"
|
|
539
|
-
disabled={isLoading ||
|
|
757
|
+
disabled={isLoading || effectiveDisabled}
|
|
540
758
|
onClick={async () => {
|
|
541
759
|
const action = (field.admin?.action || (field as Record<string, unknown>).action) as string | undefined;
|
|
542
760
|
const method =
|
|
@@ -630,7 +848,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
630
848
|
value={value}
|
|
631
849
|
onChange={onFieldChange}
|
|
632
850
|
error={error}
|
|
633
|
-
disabled={
|
|
851
|
+
disabled={effectiveDisabled}
|
|
634
852
|
/>
|
|
635
853
|
);
|
|
636
854
|
}
|
|
@@ -638,13 +856,13 @@ if (field.type === "row" && "fields" in field) {
|
|
|
638
856
|
|
|
639
857
|
const renderHeader = () => {
|
|
640
858
|
const docTitle = String(
|
|
641
|
-
|
|
642
|
-
formData.title ||
|
|
643
|
-
formData.name ||
|
|
859
|
+
(formData.mainTabs as { title?: string })?.title ||
|
|
860
|
+
(typeof formData.title === "object" ? "" : formData.title) ||
|
|
861
|
+
(typeof formData.name === "object" ? "" : formData.name) ||
|
|
644
862
|
"Untitled",
|
|
645
|
-
|
|
646
|
-
// Use
|
|
647
|
-
const docStatus = documentStatus ?? formData.
|
|
863
|
+
);
|
|
864
|
+
// Use status from the document (merged from version table on draft reads)
|
|
865
|
+
const docStatus = documentStatus ?? formData.status ?? 'draft';
|
|
648
866
|
const isNew = !formData.id;
|
|
649
867
|
const lastModified = formData.updatedAt
|
|
650
868
|
? new Date(formData.updatedAt as string).toLocaleString()
|
|
@@ -653,52 +871,44 @@ if (field.type === "row" && "fields" in field) {
|
|
|
653
871
|
? new Date(formData.createdAt as string).toLocaleString()
|
|
654
872
|
: "Just now";
|
|
655
873
|
|
|
874
|
+
const isDraftMode = !formData.id || documentStatus === 'draft';
|
|
875
|
+
|
|
656
876
|
// Status label shown in the header
|
|
657
877
|
const statusLabel = hasUnpublishedChanges
|
|
658
|
-
?
|
|
659
|
-
? 'Draft'
|
|
660
|
-
: 'Published (unpublished changes)'
|
|
878
|
+
? 'Draft (unpublished changes)'
|
|
661
879
|
: docStatus === 'published'
|
|
662
880
|
? 'Published'
|
|
663
881
|
: 'Draft';
|
|
664
882
|
|
|
665
|
-
const statusColor = docStatus === 'published' && !
|
|
883
|
+
const statusColor = docStatus === 'published' && !hasUnsavedChanges
|
|
666
884
|
? 'bg-[var(--kyro-success)]'
|
|
667
885
|
: hasUnpublishedChanges
|
|
668
886
|
? 'bg-[var(--kyro-warning)]'
|
|
669
887
|
: 'bg-[var(--kyro-text-muted)]';
|
|
670
888
|
|
|
889
|
+
const statusBadgeBg = docStatus === 'published' && !hasUnpublishedChanges
|
|
890
|
+
? 'bg-[var(--kyro-success)]/10 text-[var(--kyro-success)] border-[var(--kyro-success)]/20'
|
|
891
|
+
: hasUnpublishedChanges
|
|
892
|
+
? 'bg-[var(--kyro-warning)]/10 text-[var(--kyro-warning)] border-[var(--kyro-warning)]/20'
|
|
893
|
+
: 'bg-[var(--kyro-text-muted)]/10 text-[var(--kyro-text-muted)] border-[var(--kyro-text-muted)]/20';
|
|
894
|
+
|
|
671
895
|
return (
|
|
672
896
|
<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-
|
|
897
|
+
<div className="flex flex-col gap-2">
|
|
898
|
+
<div className="flex items-center gap-3">
|
|
675
899
|
<a
|
|
676
900
|
href={`/${collectionSlug}`}
|
|
677
901
|
className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
|
|
678
902
|
>
|
|
679
|
-
<
|
|
680
|
-
className="w-4 h-4"
|
|
681
|
-
fill="none"
|
|
682
|
-
stroke="currentColor"
|
|
683
|
-
viewBox="0 0 24 24"
|
|
684
|
-
>
|
|
685
|
-
<path
|
|
686
|
-
strokeLinecap="round"
|
|
687
|
-
strokeLinejoin="round"
|
|
688
|
-
strokeWidth="2.5"
|
|
689
|
-
d="M15 19l-7-7 7-7"
|
|
690
|
-
/>
|
|
691
|
-
</svg>
|
|
903
|
+
<ChevronRight className="w-4 h-4" />
|
|
692
904
|
</a>
|
|
693
905
|
<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
|
-
/>
|
|
906
|
+
<span className={`inline-flex items-center gap-1.5 px-2 rounded-full text-[10px] font-regular border ${statusBadgeBg}`}>
|
|
907
|
+
<span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
|
|
700
908
|
{statusLabel}
|
|
701
909
|
</span>
|
|
910
|
+
</div>
|
|
911
|
+
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
702
912
|
{autoSaveStatus === "saving" && (
|
|
703
913
|
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
704
914
|
<svg
|
|
@@ -725,26 +935,63 @@ if (field.type === "row" && "fields" in field) {
|
|
|
725
935
|
)}
|
|
726
936
|
{autoSaveStatus === "success" && (
|
|
727
937
|
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
728
|
-
<
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
<path d="
|
|
938
|
+
<Check className="w-4 h-4" />
|
|
939
|
+
{lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Draft saved"}
|
|
940
|
+
</span>
|
|
941
|
+
)}
|
|
942
|
+
{autoSaveStatus === "retrying" && (
|
|
943
|
+
<span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
|
|
944
|
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
|
|
945
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
946
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
737
947
|
</svg>
|
|
738
|
-
|
|
948
|
+
Retrying save ({retryCount}/5)
|
|
949
|
+
</span>
|
|
950
|
+
)}
|
|
951
|
+
{autoSaveStatus === "offline" && (
|
|
952
|
+
<span className="text-[var(--kyro-text-muted)] flex items-center gap-1.5">
|
|
953
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
954
|
+
<path d="M10.61 10.61a3 3 0 0 0 4.24 4.24" />
|
|
955
|
+
<path d="M13.36 13.36a3 3 0 0 0-4.24-4.24" />
|
|
956
|
+
<path d="m2 2 20 20" />
|
|
957
|
+
<path d="M18.36 5.64a9 9 0 0 0-12.72 0" />
|
|
958
|
+
<path d="M22.61 1.39a15 15 0 0 0-21.22 0" />
|
|
959
|
+
</svg>
|
|
960
|
+
Offline — cached locally
|
|
739
961
|
</span>
|
|
740
962
|
)}
|
|
741
963
|
{autoSaveStatus === "error" && (
|
|
742
964
|
<span className="text-[var(--kyro-danger)]">Draft save failed</span>
|
|
743
965
|
)}
|
|
744
966
|
{autoSaveStatus === "conflict" && (
|
|
745
|
-
<
|
|
967
|
+
<div className="flex items-center gap-3">
|
|
968
|
+
<span className="text-[var(--kyro-danger)] font-semibold">Conflict detected</span>
|
|
969
|
+
<span className="opacity-30">—</span>
|
|
970
|
+
<button
|
|
971
|
+
type="button"
|
|
972
|
+
onClick={async () => {
|
|
973
|
+
// Keep mine: force save and overwrite server
|
|
974
|
+
await saveDocument(formData);
|
|
975
|
+
setAutoSaveStatus("success");
|
|
976
|
+
}}
|
|
977
|
+
className="text-[var(--kyro-primary)] hover:underline"
|
|
978
|
+
>
|
|
979
|
+
Keep my changes
|
|
980
|
+
</button>
|
|
981
|
+
<span className="opacity-30">|</span>
|
|
982
|
+
<button
|
|
983
|
+
type="button"
|
|
984
|
+
onClick={() => {
|
|
985
|
+
// Reload server version
|
|
986
|
+
window.location.reload();
|
|
987
|
+
}}
|
|
988
|
+
className="text-[var(--kyro-danger)] hover:underline"
|
|
989
|
+
>
|
|
990
|
+
Reload server version
|
|
991
|
+
</button>
|
|
992
|
+
</div>
|
|
746
993
|
)}
|
|
747
|
-
{hasUnsavedChanges && autoSaveStatus !== "saving" && (
|
|
994
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
748
995
|
<>
|
|
749
996
|
<span className="opacity-30">—</span>
|
|
750
997
|
<button
|
|
@@ -752,7 +999,6 @@ if (field.type === "row" && "fields" in field) {
|
|
|
752
999
|
onClick={async () => {
|
|
753
1000
|
setFormData(lastSavedData);
|
|
754
1001
|
markSaved();
|
|
755
|
-
await clearDraftArtifacts();
|
|
756
1002
|
}}
|
|
757
1003
|
className="text-[var(--kyro-primary)] hover:underline"
|
|
758
1004
|
>
|
|
@@ -760,6 +1006,19 @@ if (field.type === "row" && "fields" in field) {
|
|
|
760
1006
|
</button>
|
|
761
1007
|
</>
|
|
762
1008
|
)}
|
|
1009
|
+
{/* Live auto-save timestamp */}
|
|
1010
|
+
{lastSavedAt && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "success" && (
|
|
1011
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1012
|
+
Draft saved {(() => {
|
|
1013
|
+
const diffMs = now - lastSavedAt;
|
|
1014
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
1015
|
+
const diffSec = Math.floor(diffMs / 1_000);
|
|
1016
|
+
if (diffMin >= 1) return `${diffMin}m ago`;
|
|
1017
|
+
if (diffSec >= 5) return `${diffSec}s ago`;
|
|
1018
|
+
return "just now";
|
|
1019
|
+
})()}
|
|
1020
|
+
</span>
|
|
1021
|
+
)}
|
|
763
1022
|
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
764
1023
|
Modified {lastModified}
|
|
765
1024
|
</span>
|
|
@@ -789,19 +1048,10 @@ if (field.type === "row" && "fields" in field) {
|
|
|
789
1048
|
<button
|
|
790
1049
|
type="button"
|
|
791
1050
|
onClick={() => setShowPreview(!showPreview)}
|
|
792
|
-
className={`p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "
|
|
1051
|
+
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
1052
|
title="Live Preview"
|
|
794
1053
|
>
|
|
795
|
-
<
|
|
796
|
-
width="20"
|
|
797
|
-
height="20"
|
|
798
|
-
viewBox="0 0 24 24"
|
|
799
|
-
fill="none"
|
|
800
|
-
stroke="currentColor"
|
|
801
|
-
strokeWidth="2"
|
|
802
|
-
>
|
|
803
|
-
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
|
|
804
|
-
</svg>
|
|
1054
|
+
<ExternalLink className="w-4 h-4" />
|
|
805
1055
|
{showPreview && (
|
|
806
1056
|
<span className="text-[10px] font-bold tracking-widest pr-1">
|
|
807
1057
|
Active
|
|
@@ -813,7 +1063,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
813
1063
|
onClick={() => {
|
|
814
1064
|
window.dispatchEvent(new CustomEvent("toggle-sidebar"));
|
|
815
1065
|
}}
|
|
816
|
-
className={`p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "
|
|
1066
|
+
className={`kyro-btn p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
817
1067
|
title="Toggle Sidebar"
|
|
818
1068
|
>
|
|
819
1069
|
<svg
|
|
@@ -829,282 +1079,138 @@ if (field.type === "row" && "fields" in field) {
|
|
|
829
1079
|
</svg>
|
|
830
1080
|
</button>
|
|
831
1081
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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;
|
|
1082
|
+
{/* ── Publish button (no dropdown) ──────────────────────────────── */}
|
|
1083
|
+
<SplitButton
|
|
1084
|
+
status={documentStatus as SplitButtonStatus}
|
|
1085
|
+
saveStatus={localSaveStatus}
|
|
1086
|
+
hasChanges={hasUnsavedChanges}
|
|
1087
|
+
onPublish={handlePublish}
|
|
1088
|
+
disabled={localSaveStatus === "saving"}
|
|
1089
|
+
/>
|
|
854
1090
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
setTimeout(() => {
|
|
882
|
-
window.location.href = `/${collectionSlug}`;
|
|
883
|
-
}, 800);
|
|
1091
|
+
{/* ── Kebab: document management actions ───────────────────────── */}
|
|
1092
|
+
{!isNew && (
|
|
1093
|
+
<Dropdown
|
|
1094
|
+
trigger={
|
|
1095
|
+
<button
|
|
1096
|
+
type="button"
|
|
1097
|
+
className="kyro-btn p-2.5 rounded-xl border border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)] transition-all"
|
|
1098
|
+
title="More actions"
|
|
1099
|
+
>
|
|
1100
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
1101
|
+
<circle cx="12" cy="5" r="1.5" />
|
|
1102
|
+
<circle cx="12" cy="12" r="1.5" />
|
|
1103
|
+
<circle cx="12" cy="19" r="1.5" />
|
|
1104
|
+
</svg>
|
|
1105
|
+
</button>
|
|
1106
|
+
}
|
|
1107
|
+
direction="down"
|
|
1108
|
+
>
|
|
1109
|
+
{!globalSlug && (
|
|
1110
|
+
<DropdownItem
|
|
1111
|
+
onClick={handleCreateNew}
|
|
1112
|
+
icon={
|
|
1113
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1114
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
1115
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
1116
|
+
</svg>
|
|
884
1117
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1118
|
+
>
|
|
1119
|
+
Create New
|
|
1120
|
+
</DropdownItem>
|
|
1121
|
+
)}
|
|
1122
|
+
{!globalSlug && (
|
|
1123
|
+
<DropdownItem
|
|
1124
|
+
onClick={handleDuplicate}
|
|
1125
|
+
icon={
|
|
1126
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1127
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
1128
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
1129
|
+
</svg>
|
|
889
1130
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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");
|
|
1131
|
+
>
|
|
1132
|
+
Duplicate
|
|
1133
|
+
</DropdownItem>
|
|
1134
|
+
)}
|
|
1135
|
+
<DropdownItem
|
|
1136
|
+
onClick={() => setShowSchedulePicker(true)}
|
|
1137
|
+
icon={
|
|
1138
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1139
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
1140
|
+
<line x1="16" y1="2" x2="16" y2="6" />
|
|
1141
|
+
<line x1="8" y1="2" x2="8" y2="6" />
|
|
1142
|
+
<line x1="3" y1="10" x2="21" y2="10" />
|
|
1143
|
+
</svg>
|
|
926
1144
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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);
|
|
1145
|
+
>
|
|
1146
|
+
Schedule Publish
|
|
1147
|
+
</DropdownItem>
|
|
1148
|
+
{documentStatus === "published" && (
|
|
1149
|
+
<DropdownItem
|
|
1150
|
+
onClick={handleUnpublish}
|
|
1151
|
+
icon={
|
|
1152
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1153
|
+
<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" />
|
|
1154
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
1155
|
+
</svg>
|
|
949
1156
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1157
|
+
>
|
|
1158
|
+
Unpublish
|
|
1159
|
+
</DropdownItem>
|
|
1160
|
+
)}
|
|
1161
|
+
{!globalSlug && (
|
|
1162
|
+
<>
|
|
1163
|
+
<DropdownSeparator />
|
|
1164
|
+
<DropdownItem
|
|
1165
|
+
onClick={handleDelete}
|
|
1166
|
+
danger
|
|
1167
|
+
icon={
|
|
1168
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1169
|
+
<polyline points="3 6 5 6 21 6" />
|
|
1170
|
+
<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" />
|
|
1171
|
+
</svg>
|
|
961
1172
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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>
|
|
1173
|
+
>
|
|
1174
|
+
Delete
|
|
1175
|
+
</DropdownItem>
|
|
1176
|
+
</>
|
|
1177
|
+
)}
|
|
1178
|
+
</Dropdown>
|
|
984
1179
|
)}
|
|
985
1180
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
1003
|
-
</svg>
|
|
1004
|
-
</button>
|
|
1005
|
-
{isMenuOpen && (
|
|
1006
|
-
<div className="absolute right-0 mt-2 w-48 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 overflow-hidden">
|
|
1007
|
-
<button
|
|
1008
|
-
type="button"
|
|
1009
|
-
onClick={() => {
|
|
1010
|
-
handleCreateNew();
|
|
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"
|
|
1014
|
-
>
|
|
1015
|
-
<svg
|
|
1016
|
-
width="16"
|
|
1017
|
-
height="16"
|
|
1018
|
-
viewBox="0 0 24 24"
|
|
1019
|
-
fill="none"
|
|
1020
|
-
stroke="currentColor"
|
|
1021
|
-
strokeWidth="2"
|
|
1181
|
+
|
|
1182
|
+
{showSchedulePicker && (
|
|
1183
|
+
<div ref={scheduleRef} className="relative">
|
|
1184
|
+
<div className="absolute right-0 top-2 p-4 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50">
|
|
1185
|
+
<p className="text-xs font-medium mb-2">Schedule Publish</p>
|
|
1186
|
+
<input
|
|
1187
|
+
type="datetime-local"
|
|
1188
|
+
id="schedule-datetime"
|
|
1189
|
+
className="kyro-form-input text-xs mb-3 w-full"
|
|
1190
|
+
min={new Date().toISOString().slice(0, 16)}
|
|
1191
|
+
/>
|
|
1192
|
+
<div className="flex items-center gap-2 justify-end">
|
|
1193
|
+
<button
|
|
1194
|
+
type="button"
|
|
1195
|
+
onClick={() => setShowSchedulePicker(false)}
|
|
1196
|
+
className="px-3 py-1.5 text-xs kyro-btn rounded-lg"
|
|
1022
1197
|
>
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
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
|
-
>
|
|
1038
|
-
<svg
|
|
1039
|
-
width="16"
|
|
1040
|
-
height="16"
|
|
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>
|
|
1055
|
-
</svg>
|
|
1056
|
-
Duplicate
|
|
1057
|
-
</button>
|
|
1058
|
-
{formData._status === "published" && (
|
|
1059
|
-
<button
|
|
1060
|
-
type="button"
|
|
1061
|
-
onClick={() => {
|
|
1062
|
-
handleUnpublish();
|
|
1063
|
-
setIsMenuOpen(false);
|
|
1064
|
-
}}
|
|
1065
|
-
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
|
-
>
|
|
1067
|
-
<svg
|
|
1068
|
-
width="16"
|
|
1069
|
-
height="16"
|
|
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>
|
|
1077
|
-
</svg>
|
|
1078
|
-
Unpublish
|
|
1079
|
-
</button>
|
|
1080
|
-
)}
|
|
1081
|
-
<div className="h-px bg-[var(--kyro-border)]" />
|
|
1082
|
-
<button
|
|
1083
|
-
type="button"
|
|
1084
|
-
onClick={() => {
|
|
1085
|
-
handleDelete();
|
|
1086
|
-
setIsMenuOpen(false);
|
|
1087
|
-
}}
|
|
1088
|
-
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"
|
|
1089
|
-
>
|
|
1090
|
-
<svg
|
|
1091
|
-
width="16"
|
|
1092
|
-
height="16"
|
|
1093
|
-
viewBox="0 0 24 24"
|
|
1094
|
-
fill="none"
|
|
1095
|
-
stroke="currentColor"
|
|
1096
|
-
strokeWidth="2"
|
|
1097
|
-
>
|
|
1098
|
-
<polyline points="3 6 5 6 21 6"></polyline>
|
|
1099
|
-
<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"></path>
|
|
1100
|
-
</svg>
|
|
1101
|
-
Delete
|
|
1102
|
-
</button>
|
|
1103
|
-
</>
|
|
1104
|
-
)}
|
|
1198
|
+
Cancel
|
|
1199
|
+
</button>
|
|
1200
|
+
<button
|
|
1201
|
+
type="button"
|
|
1202
|
+
onClick={() => {
|
|
1203
|
+
const val = (document.getElementById("schedule-datetime") as HTMLInputElement)?.value;
|
|
1204
|
+
if (val) handleSchedulePublish(val);
|
|
1205
|
+
}}
|
|
1206
|
+
className="px-3 py-1.5 text-xs kyro-btn-success rounded-lg"
|
|
1207
|
+
>
|
|
1208
|
+
Schedule
|
|
1209
|
+
</button>
|
|
1210
|
+
</div>
|
|
1105
1211
|
</div>
|
|
1106
|
-
|
|
1107
|
-
|
|
1212
|
+
</div>
|
|
1213
|
+
)}
|
|
1108
1214
|
</div>
|
|
1109
1215
|
</div>
|
|
1110
1216
|
</header>
|
|
@@ -1207,7 +1313,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1207
1313
|
type="button"
|
|
1208
1314
|
onClick={handleCompareVersions}
|
|
1209
1315
|
disabled={loadingDiffs}
|
|
1210
|
-
className="px-3 py-1.5 rounded-lg
|
|
1316
|
+
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
1317
|
>
|
|
1212
1318
|
{loadingDiffs ? "Comparing..." : "Compare"}
|
|
1213
1319
|
</button>
|
|
@@ -1241,15 +1347,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1241
1347
|
onClick={() => setCompareDiffs([])}
|
|
1242
1348
|
className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
|
|
1243
1349
|
>
|
|
1244
|
-
<
|
|
1245
|
-
className="w-3.5 h-3.5"
|
|
1246
|
-
viewBox="0 0 24 24"
|
|
1247
|
-
fill="none"
|
|
1248
|
-
stroke="currentColor"
|
|
1249
|
-
strokeWidth="2.5"
|
|
1250
|
-
>
|
|
1251
|
-
<path d="M18 6L6 18M6 6l12 12" />
|
|
1252
|
-
</svg>
|
|
1350
|
+
<X className="w-4 h-4" />
|
|
1253
1351
|
</button>
|
|
1254
1352
|
</div>
|
|
1255
1353
|
<div className="max-h-[400px] overflow-y-auto">
|
|
@@ -1316,15 +1414,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1316
1414
|
}`}
|
|
1317
1415
|
>
|
|
1318
1416
|
{isSelected && (
|
|
1319
|
-
<
|
|
1320
|
-
className="w-full h-full text-white p-0.5"
|
|
1321
|
-
viewBox="0 0 24 24"
|
|
1322
|
-
fill="none"
|
|
1323
|
-
stroke="currentColor"
|
|
1324
|
-
strokeWidth="3"
|
|
1325
|
-
>
|
|
1326
|
-
<path d="M20 6L9 17l-5-5" />
|
|
1327
|
-
</svg>
|
|
1417
|
+
<Check className="w-4 h-4" />
|
|
1328
1418
|
)}
|
|
1329
1419
|
</div>
|
|
1330
1420
|
) : (
|
|
@@ -1374,7 +1464,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1374
1464
|
<button
|
|
1375
1465
|
type="button"
|
|
1376
1466
|
onClick={() => handleRestoreVersion(v.id)}
|
|
1377
|
-
className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold
|
|
1467
|
+
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
1468
|
>
|
|
1379
1469
|
Restore
|
|
1380
1470
|
</button>
|
|
@@ -1464,16 +1554,7 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1464
1554
|
className={`w-4 h-4 rounded border transition-all flex items-center justify-center ${item.checked ? "bg-[var(--kyro-primary)] border-[var(--kyro-primary)]" : "border-[var(--kyro-border)] group-hover:border-[var(--kyro-text-secondary)]"}`}
|
|
1465
1555
|
>
|
|
1466
1556
|
{item.checked && (
|
|
1467
|
-
<
|
|
1468
|
-
width="10"
|
|
1469
|
-
height="10"
|
|
1470
|
-
viewBox="0 0 24 24"
|
|
1471
|
-
fill="none"
|
|
1472
|
-
stroke="white"
|
|
1473
|
-
strokeWidth="4"
|
|
1474
|
-
>
|
|
1475
|
-
<path d="M20 6L9 17l-5-5" />
|
|
1476
|
-
</svg>
|
|
1557
|
+
<Check className="w-4 h-4" />
|
|
1477
1558
|
)}
|
|
1478
1559
|
</div>
|
|
1479
1560
|
<span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
|
|
@@ -1507,42 +1588,74 @@ if (field.type === "row" && "fields" in field) {
|
|
|
1507
1588
|
<div className="flex flex-col h-full">
|
|
1508
1589
|
{layout !== "single" && renderHeader()}
|
|
1509
1590
|
{layout === "single" && (
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1591
|
+
<>
|
|
1592
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
1593
|
+
<div className="flex items-center gap-3 text-[11px] font-medium">
|
|
1594
|
+
{autoSaveStatus === "saving" && (
|
|
1595
|
+
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
1596
|
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
|
|
1597
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
1598
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
1599
|
+
</svg>
|
|
1600
|
+
Saving...
|
|
1601
|
+
</span>
|
|
1602
|
+
)}
|
|
1603
|
+
{autoSaveStatus === "success" && (
|
|
1604
|
+
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
1605
|
+
<Check className="w-4 h-4" />
|
|
1606
|
+
{lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Saved"}
|
|
1607
|
+
</span>
|
|
1608
|
+
)}
|
|
1609
|
+
{autoSaveStatus === "retrying" && (
|
|
1610
|
+
<span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
|
|
1611
|
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24" fill="none">
|
|
1612
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
1613
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
1614
|
+
</svg>
|
|
1615
|
+
Retrying...
|
|
1616
|
+
</span>
|
|
1617
|
+
)}
|
|
1618
|
+
{autoSaveStatus === "offline" && (
|
|
1619
|
+
<span className="text-[var(--kyro-text-muted)]">Offline — cached locally</span>
|
|
1620
|
+
)}
|
|
1621
|
+
{autoSaveStatus === "error" && (
|
|
1622
|
+
<span className="text-[var(--kyro-danger)]">Save failed</span>
|
|
1623
|
+
)}
|
|
1624
|
+
{autoSaveStatus === "conflict" && (
|
|
1625
|
+
<span className="text-[var(--kyro-danger)]">Conflict detected</span>
|
|
1626
|
+
)}
|
|
1627
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
1628
|
+
<span className="text-[var(--kyro-warning)]">Unsaved changes</span>
|
|
1629
|
+
)}
|
|
1630
|
+
{!hasUnsavedChanges && autoSaveStatus !== "success" && autoSaveStatus !== "saving" && autoSaveStatus !== "error" && (
|
|
1631
|
+
<span className="text-[var(--kyro-success)]">All changes saved</span>
|
|
1632
|
+
)}
|
|
1633
|
+
</div>
|
|
1634
|
+
<span className="text-[11px] text-[var(--kyro-text-muted)] opacity-60">
|
|
1635
|
+
{formData.updatedAt ? `Modified ${new Date(formData.updatedAt as string).toLocaleString()}` : ""}
|
|
1636
|
+
</span>
|
|
1637
|
+
</div>
|
|
1638
|
+
<button
|
|
1639
|
+
id="btn-save"
|
|
1640
|
+
type="button"
|
|
1641
|
+
style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
|
|
1642
|
+
onClick={async () => {
|
|
1643
|
+
try {
|
|
1644
|
+
const response = await saveDocument();
|
|
1525
1645
|
if (response.ok) {
|
|
1526
1646
|
const result = await response.json();
|
|
1527
|
-
const savedData = result.data ||
|
|
1528
|
-
setFormData(savedData);
|
|
1529
|
-
setLastSavedData(savedData);
|
|
1647
|
+
const savedData = result.data || formData;
|
|
1648
|
+
setFormData({ ...formData, ...savedData });
|
|
1649
|
+
setLastSavedData({ ...formData, ...savedData });
|
|
1530
1650
|
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
1651
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1652
|
+
} catch (e) {
|
|
1653
|
+
console.error("Save error exception:", e);
|
|
1654
|
+
onActionError?.("Save failed: " + (e as Error).message);
|
|
1655
|
+
}
|
|
1656
|
+
}}
|
|
1657
|
+
/>
|
|
1658
|
+
</>
|
|
1546
1659
|
)}
|
|
1547
1660
|
<main className="w-full">
|
|
1548
1661
|
{view === "edit" && renderEditView()}
|
|
@@ -1596,7 +1709,7 @@ function RelationshipField({
|
|
|
1596
1709
|
fetchOptions();
|
|
1597
1710
|
}, [targetCollection]);
|
|
1598
1711
|
|
|
1599
|
-
const getLabel = (opt: unknown) => {
|
|
1712
|
+
const getLabel = (opt: unknown) => {
|
|
1600
1713
|
const o = opt as Record<string, unknown> | undefined;
|
|
1601
1714
|
if (!o) return "";
|
|
1602
1715
|
return String(
|
|
@@ -1658,13 +1771,13 @@ const getLabel = (opt: unknown) => {
|
|
|
1658
1771
|
|
|
1659
1772
|
const filteredOptions = (search
|
|
1660
1773
|
? (options || []).filter((opt) => {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1774
|
+
const o = opt as Record<string, unknown>;
|
|
1775
|
+
const term = search.toLowerCase();
|
|
1776
|
+
const searchableFields = ["title", "name", "label", "filename", "slug"];
|
|
1777
|
+
return searchableFields.some(
|
|
1778
|
+
(key) => o[key] && String(o[key]).toLowerCase().includes(term),
|
|
1779
|
+
);
|
|
1780
|
+
})
|
|
1668
1781
|
: options || []) as Array<Record<string, unknown>>;
|
|
1669
1782
|
|
|
1670
1783
|
return (
|
|
@@ -1727,9 +1840,7 @@ const getLabel = (opt: unknown) => {
|
|
|
1727
1840
|
{loading ? (
|
|
1728
1841
|
<div className="kyro-relation-modal-empty">Loading...</div>
|
|
1729
1842
|
) : filteredOptions.length === 0 ? (
|
|
1730
|
-
<
|
|
1731
|
-
No results found.
|
|
1732
|
-
</div>
|
|
1843
|
+
<EmptyState title="No results found." />
|
|
1733
1844
|
) : (
|
|
1734
1845
|
filteredOptions.map((opt) => {
|
|
1735
1846
|
const o = opt as { id?: string };
|