@kyro-cms/admin 0.1.7 → 0.1.8
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/package.json +5 -3
- package/src/components/Admin.tsx +1 -1
- package/src/components/AutoForm.tsx +966 -337
- package/src/components/CreateView.tsx +1 -1
- package/src/components/DetailView.tsx +1 -1
- package/src/components/EnhancedListView.tsx +156 -52
- package/src/components/ListView.tsx +1 -1
- package/src/components/Modal.tsx +65 -8
- package/src/components/Sidebar.astro +2 -2
- package/src/components/ThemeProvider.tsx +8 -2
- package/src/components/blocks/AccordionBlock.tsx +20 -52
- package/src/components/blocks/ArrayBlock.tsx +40 -31
- package/src/components/blocks/BlockEditModal.tsx +170 -581
- package/src/components/blocks/ButtonBlock.tsx +27 -128
- package/src/components/blocks/CodeBlock.tsx +88 -40
- package/src/components/blocks/ColumnsBlock.tsx +27 -85
- package/src/components/blocks/FileBlock.tsx +38 -39
- package/src/components/blocks/HeadingBlock.tsx +9 -31
- package/src/components/blocks/HeroBlock.tsx +42 -100
- package/src/components/blocks/ImageBlock.tsx +6 -7
- package/src/components/blocks/LinkBlock.tsx +27 -33
- package/src/components/blocks/ListBlock.tsx +47 -26
- package/src/components/blocks/RelationshipBlock.tsx +26 -233
- package/src/components/blocks/RichTextBlock.tsx +66 -0
- package/src/components/blocks/VStackBlock.tsx +23 -37
- package/src/components/blocks/VideoBlock.tsx +52 -32
- package/src/components/fields/AccordionField.tsx +213 -0
- package/src/components/fields/ArrayField.tsx +241 -0
- package/src/components/fields/BlocksField.tsx +5 -5
- package/src/components/fields/ButtonField.tsx +53 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/ChildrenField.tsx +48 -0
- package/src/components/fields/CodeField.tsx +154 -94
- package/src/components/fields/ColumnsField.tsx +137 -0
- package/src/components/fields/DateField.tsx +9 -24
- package/src/components/fields/EditorClient.tsx +426 -160
- package/src/components/fields/HeadingField.tsx +31 -0
- package/src/components/fields/HeroField.tsx +101 -0
- package/src/components/fields/JSONField.tsx +7 -27
- package/src/components/fields/LinkField.tsx +81 -0
- package/src/components/fields/ListField.tsx +74 -0
- package/src/components/fields/MarkdownField.tsx +4 -26
- package/src/components/fields/NumberField.tsx +9 -27
- package/src/components/fields/PortableTextField.tsx +61 -49
- package/src/components/fields/RelationshipBlockField.tsx +233 -0
- package/src/components/fields/RelationshipField.tsx +59 -13
- package/src/components/fields/SelectField.tsx +6 -4
- package/src/components/fields/TextField.tsx +9 -24
- package/src/components/fields/UploadField.tsx +613 -0
- package/src/components/fields/VideoField.tsx +73 -0
- package/src/components/fields/extensions/blockComponents.tsx +11 -1
- package/src/components/fields/extensions/blocksStore.ts +1 -1
- package/src/components/fields/index.ts +12 -1
- package/src/components/layout/Layout.tsx +1 -1
- package/src/lib/api.ts +163 -0
- package/src/lib/config.ts +1 -1
- package/src/lib/dataStore.ts +87 -30
- package/src/lib/date-utils.ts +69 -0
- package/src/lib/db/version-adapter.ts +248 -0
- package/src/lib/i18n.tsx +353 -0
- package/src/lib/slugify.ts +15 -0
- package/src/lib/validation.ts +250 -0
- package/src/pages/api/[collection]/[id]/publish.ts +12 -4
- package/src/pages/api/[collection]/[id]/versions.ts +39 -9
- package/src/pages/api/[collection]/[id].ts +13 -1
- package/src/pages/api/[collection]/index.ts +5 -6
- package/src/styles/main.css +12 -2
- package/src/components/blocks/BlockEditModal.MARKER +0 -12
- package/src/components/fields/FileField.tsx +0 -390
- package/src/components/fields/HybridContentField.tsx +0 -109
- package/src/components/fields/ImageField.tsx +0 -429
|
@@ -4,34 +4,20 @@ import type {
|
|
|
4
4
|
GlobalConfig,
|
|
5
5
|
Field,
|
|
6
6
|
Block,
|
|
7
|
-
} from "@kyro-cms/core";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
} from "@kyro-cms/core/client";
|
|
8
|
+
import { UploadField } from "./fields/UploadField";
|
|
9
|
+
import { CodeField } from "./fields";
|
|
10
10
|
import NumberField from "./fields/NumberField";
|
|
11
11
|
import CheckboxField from "./fields/CheckboxField";
|
|
12
12
|
import SelectField from "./fields/SelectField";
|
|
13
13
|
import DateField from "./fields/DateField";
|
|
14
14
|
import { MarkdownField } from "./fields/MarkdownField";
|
|
15
|
-
import CodeMirror from "@uiw/react-codemirror";
|
|
16
|
-
import { json } from "@codemirror/lang-json";
|
|
17
|
-
import { javascript } from "@codemirror/lang-javascript";
|
|
18
|
-
import { aura } from "@uiw/codemirror-theme-aura";
|
|
19
15
|
import { globals, collections } from "@/lib/config";
|
|
16
|
+
import { slugifyText } from "@/lib/slugify";
|
|
20
17
|
|
|
21
18
|
import { BlocksField } from "./fields/BlocksField";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!text) return "";
|
|
25
|
-
return text
|
|
26
|
-
.toString()
|
|
27
|
-
.toLowerCase()
|
|
28
|
-
.trim()
|
|
29
|
-
.replace(/\s+/g, "-") // Replace spaces with -
|
|
30
|
-
.replace(/[^\w-]+/g, "") // Remove all non-word chars
|
|
31
|
-
.replace(/--+/g, "-") // Replace multiple - with single -
|
|
32
|
-
.replace(/^-+/, "") // Trim - from start of text
|
|
33
|
-
.replace(/-+$/, ""); // Trim - from end of text
|
|
34
|
-
}
|
|
19
|
+
import PortableTextField from "./fields/PortableTextField";
|
|
20
|
+
import { ConfirmModal, UIModal } from "./Modal";
|
|
35
21
|
|
|
36
22
|
interface AutoFormProps {
|
|
37
23
|
config: CollectionConfig | GlobalConfig;
|
|
@@ -172,33 +158,158 @@ export function AutoForm({
|
|
|
172
158
|
const [loadingFields, setLoadingFields] = useState<Record<string, boolean>>(
|
|
173
159
|
{},
|
|
174
160
|
);
|
|
161
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
162
|
+
const [compareMode, setCompareMode] = useState(false);
|
|
163
|
+
const [compareSelected, setCompareSelected] = useState<string[]>([]);
|
|
164
|
+
const [compareDiffs, setCompareDiffs] = useState<any[]>([]);
|
|
165
|
+
const [loadingDiffs, setLoadingDiffs] = useState(false);
|
|
166
|
+
const [confirmModal, setConfirmModal] = useState<{
|
|
167
|
+
open: boolean;
|
|
168
|
+
title: string;
|
|
169
|
+
message: string;
|
|
170
|
+
onConfirm: () => void;
|
|
171
|
+
danger?: boolean;
|
|
172
|
+
}>({ open: false, title: "", message: "", onConfirm: () => {} });
|
|
173
|
+
const [alertModal, setAlertModal] = useState<{
|
|
174
|
+
open: boolean;
|
|
175
|
+
title: string;
|
|
176
|
+
message: string;
|
|
177
|
+
}>({ open: false, title: "", message: "" });
|
|
178
|
+
const [lastSavedData, setLastSavedData] = useState<Record<string, any>>({});
|
|
179
|
+
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
|
180
|
+
const [autoSaveStatus, setAutoSaveStatus] = useState<
|
|
181
|
+
"idle" | "saving" | "saved" | "error"
|
|
182
|
+
>("idle");
|
|
183
|
+
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
184
|
+
const lastAutoSaveTimeRef = useRef<number>(0);
|
|
185
|
+
const autoSaveSkipRef = useRef<boolean>(false);
|
|
175
186
|
|
|
176
187
|
const disabled = propDisabled;
|
|
177
188
|
|
|
178
|
-
|
|
189
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
const handleToggle = () => {
|
|
193
|
+
setSidebarCollapsed((prev) => !prev);
|
|
194
|
+
};
|
|
195
|
+
window.addEventListener("toggle-sidebar", handleToggle);
|
|
196
|
+
return () => window.removeEventListener("toggle-sidebar", handleToggle);
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
// Track unsaved changes (compare against last saved state)
|
|
179
200
|
useEffect(() => {
|
|
180
201
|
const isDifferent =
|
|
181
|
-
JSON.stringify(formData) !== JSON.stringify(
|
|
202
|
+
JSON.stringify(formData) !== JSON.stringify(lastSavedData);
|
|
182
203
|
setHasUnsavedChanges(isDifferent);
|
|
183
|
-
}, [formData,
|
|
204
|
+
}, [formData, lastSavedData]);
|
|
184
205
|
|
|
185
|
-
// Auto-generate slug from
|
|
206
|
+
// Auto-generate slug from configured source field if locked
|
|
186
207
|
useEffect(() => {
|
|
187
|
-
|
|
188
|
-
|
|
208
|
+
const slugField = config.fields.find(
|
|
209
|
+
(f: any) => f.name === "slug" && f.admin?.autoGenerate,
|
|
210
|
+
);
|
|
211
|
+
if (!slugField?.admin?.autoGenerate) return;
|
|
212
|
+
const sourceField: string = slugField.admin.autoGenerate;
|
|
213
|
+
if (isSlugLocked && formData[sourceField]) {
|
|
214
|
+
const newSlug = slugifyText(formData[sourceField]);
|
|
189
215
|
if (newSlug !== formData.slug) {
|
|
190
216
|
setFormData((prev) => ({ ...prev, slug: newSlug }));
|
|
191
217
|
}
|
|
192
218
|
}
|
|
193
|
-
}, [
|
|
219
|
+
}, [
|
|
220
|
+
formData.title,
|
|
221
|
+
formData.name,
|
|
222
|
+
formData.label,
|
|
223
|
+
isSlugLocked,
|
|
224
|
+
config.fields,
|
|
225
|
+
]);
|
|
194
226
|
|
|
195
227
|
// Sync prop changes to local state
|
|
196
228
|
useEffect(() => {
|
|
197
229
|
if (initialData && Object.keys(initialData).length > 0) {
|
|
198
230
|
setFormData(initialData);
|
|
231
|
+
setLastSavedData(initialData);
|
|
199
232
|
}
|
|
200
233
|
}, [initialData]);
|
|
201
234
|
|
|
235
|
+
// Auto-save with Strategy 3: 1s debounce, lastSavedData comparison, 15s hard throttle
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!formData.id || sidebarCollapsed) return;
|
|
238
|
+
|
|
239
|
+
if (autoSaveTimerRef.current) {
|
|
240
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
const timeSinceLastSave = now - lastAutoSaveTimeRef.current;
|
|
245
|
+
const hasChanges =
|
|
246
|
+
JSON.stringify(formData) !== JSON.stringify(lastSavedData);
|
|
247
|
+
|
|
248
|
+
if (!hasChanges) {
|
|
249
|
+
setAutoSaveStatus("idle");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (timeSinceLastSave < 15000 && lastAutoSaveTimeRef.current > 0) {
|
|
254
|
+
const remainingTime = Math.max(1000, 15000 - timeSinceLastSave);
|
|
255
|
+
autoSaveTimerRef.current = setTimeout(async () => {
|
|
256
|
+
await performAutoSave();
|
|
257
|
+
}, remainingTime);
|
|
258
|
+
} else {
|
|
259
|
+
autoSaveTimerRef.current = setTimeout(async () => {
|
|
260
|
+
await performAutoSave();
|
|
261
|
+
}, 1000);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
if (autoSaveTimerRef.current) {
|
|
266
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}, [formData]);
|
|
270
|
+
|
|
271
|
+
const performAutoSave = async () => {
|
|
272
|
+
if (autoSaveSkipRef.current) return;
|
|
273
|
+
if (JSON.stringify(formData) === JSON.stringify(lastSavedData)) return;
|
|
274
|
+
|
|
275
|
+
setIsAutoSaving(true);
|
|
276
|
+
setAutoSaveStatus("saving");
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const { id, createdAt, updatedAt, ...rest } = formData;
|
|
280
|
+
const saveData = {
|
|
281
|
+
...rest,
|
|
282
|
+
_changeDescription: "Auto-saved",
|
|
283
|
+
status: formData.status === "published" ? "draft" : formData.status,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const response = await fetch(`/api/${collectionSlug}/${formData.id}`, {
|
|
287
|
+
method: "PATCH",
|
|
288
|
+
credentials: "include",
|
|
289
|
+
headers: { "Content-Type": "application/json" },
|
|
290
|
+
body: JSON.stringify(saveData),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (response.ok) {
|
|
294
|
+
const result = await response.json();
|
|
295
|
+
setLastSavedData(result.data || formData);
|
|
296
|
+
lastAutoSaveTimeRef.current = Date.now();
|
|
297
|
+
setAutoSaveStatus("saved");
|
|
298
|
+
fetchVersions();
|
|
299
|
+
setTimeout(() => setAutoSaveStatus("idle"), 2000);
|
|
300
|
+
} else {
|
|
301
|
+
setAutoSaveStatus("error");
|
|
302
|
+
setTimeout(() => setAutoSaveStatus("idle"), 3000);
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.error("Auto-save failed:", err);
|
|
306
|
+
setAutoSaveStatus("error");
|
|
307
|
+
setTimeout(() => setAutoSaveStatus("idle"), 3000);
|
|
308
|
+
} finally {
|
|
309
|
+
setIsAutoSaving(false);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
202
313
|
// Sync to hidden input for Astro form submission
|
|
203
314
|
useEffect(() => {
|
|
204
315
|
const hiddenInput = document.getElementById(
|
|
@@ -256,6 +367,35 @@ export function AutoForm({
|
|
|
256
367
|
}
|
|
257
368
|
};
|
|
258
369
|
|
|
370
|
+
const handleCompareVersions = async () => {
|
|
371
|
+
if (compareSelected.length !== 2) return;
|
|
372
|
+
setLoadingDiffs(true);
|
|
373
|
+
try {
|
|
374
|
+
const resp = await fetch(
|
|
375
|
+
`/api/${collectionSlug}/${formData.id}/versions?compareA=${compareSelected[0]}&compareB=${compareSelected[1]}`,
|
|
376
|
+
);
|
|
377
|
+
const data = await resp.json();
|
|
378
|
+
setCompareDiffs(data.diffs || []);
|
|
379
|
+
} catch (e) {
|
|
380
|
+
console.error("Compare failed:", e);
|
|
381
|
+
setCompareDiffs([]);
|
|
382
|
+
} finally {
|
|
383
|
+
setLoadingDiffs(false);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const toggleCompareSelection = (versionId: string) => {
|
|
388
|
+
setCompareSelected((prev) => {
|
|
389
|
+
if (prev.includes(versionId)) {
|
|
390
|
+
return prev.filter((id) => id !== versionId);
|
|
391
|
+
}
|
|
392
|
+
if (prev.length >= 2) {
|
|
393
|
+
return [prev[1], versionId];
|
|
394
|
+
}
|
|
395
|
+
return [...prev, versionId];
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
|
|
259
399
|
useEffect(() => {
|
|
260
400
|
const handleShortcuts = (e: KeyboardEvent) => {
|
|
261
401
|
// Cmd/Ctrl + S = Publish
|
|
@@ -282,6 +422,156 @@ export function AutoForm({
|
|
|
282
422
|
return () => window.removeEventListener("keydown", handleShortcuts);
|
|
283
423
|
}, []);
|
|
284
424
|
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
427
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
428
|
+
setIsMenuOpen(false);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
if (isMenuOpen) {
|
|
432
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
433
|
+
return () =>
|
|
434
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
435
|
+
}
|
|
436
|
+
}, [isMenuOpen]);
|
|
437
|
+
|
|
438
|
+
const handleCreateNew = () => {
|
|
439
|
+
if (hasUnsavedChanges) {
|
|
440
|
+
setConfirmModal({
|
|
441
|
+
open: true,
|
|
442
|
+
title: "Unsaved Changes",
|
|
443
|
+
message: "You have unsaved changes. Save before creating new?",
|
|
444
|
+
onConfirm: async () => {
|
|
445
|
+
(document.getElementById("btn-save") as any)?.click();
|
|
446
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
447
|
+
window.location.href = `/${collectionSlug}/new`;
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
} else {
|
|
451
|
+
window.location.href = `/${collectionSlug}/new`;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const handleDuplicate = () => {
|
|
456
|
+
setConfirmModal({
|
|
457
|
+
open: true,
|
|
458
|
+
title: "Duplicate Document",
|
|
459
|
+
message: "Create a duplicate of this document?",
|
|
460
|
+
onConfirm: async () => {
|
|
461
|
+
const { id, createdAt, updatedAt, status, ...rest } = formData;
|
|
462
|
+
const duplicateData = {
|
|
463
|
+
...rest,
|
|
464
|
+
title: `${rest.title || rest.name || "Untitled"} (Copy)`,
|
|
465
|
+
};
|
|
466
|
+
try {
|
|
467
|
+
const response = await fetch(`/api/${collectionSlug}`, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
credentials: "include",
|
|
470
|
+
headers: { "Content-Type": "application/json" },
|
|
471
|
+
body: JSON.stringify(duplicateData),
|
|
472
|
+
});
|
|
473
|
+
if (response.ok) {
|
|
474
|
+
const result = await response.json();
|
|
475
|
+
window.location.href = `/${collectionSlug}/${result.data.id}`;
|
|
476
|
+
} else {
|
|
477
|
+
const error = await response.json();
|
|
478
|
+
setAlertModal({
|
|
479
|
+
open: true,
|
|
480
|
+
title: "Error",
|
|
481
|
+
message: error.error || "Failed to duplicate document",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
setAlertModal({
|
|
486
|
+
open: true,
|
|
487
|
+
title: "Error",
|
|
488
|
+
message: "Failed to duplicate document",
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const handleDelete = () => {
|
|
496
|
+
setConfirmModal({
|
|
497
|
+
open: true,
|
|
498
|
+
title: "Delete Document",
|
|
499
|
+
message: "Delete this document? This cannot be undone.",
|
|
500
|
+
danger: true,
|
|
501
|
+
onConfirm: () => {
|
|
502
|
+
setConfirmModal({
|
|
503
|
+
open: true,
|
|
504
|
+
title: "Confirm Deletion",
|
|
505
|
+
message: "Are you absolutely sure?",
|
|
506
|
+
danger: true,
|
|
507
|
+
onConfirm: async () => {
|
|
508
|
+
try {
|
|
509
|
+
const response = await fetch(
|
|
510
|
+
`/api/${collectionSlug}/${formData.id}`,
|
|
511
|
+
{
|
|
512
|
+
method: "DELETE",
|
|
513
|
+
credentials: "include",
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
if (response.ok) {
|
|
517
|
+
window.location.href = `/${collectionSlug}`;
|
|
518
|
+
} else {
|
|
519
|
+
const error = await response.json();
|
|
520
|
+
setAlertModal({
|
|
521
|
+
open: true,
|
|
522
|
+
title: "Error",
|
|
523
|
+
message: error.error || "Failed to delete document",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
setAlertModal({
|
|
528
|
+
open: true,
|
|
529
|
+
title: "Error",
|
|
530
|
+
message: "Failed to delete document",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const handleUnpublish = () => {
|
|
540
|
+
setConfirmModal({
|
|
541
|
+
open: true,
|
|
542
|
+
title: "Unpublish Document",
|
|
543
|
+
message: "Unpublish this document?",
|
|
544
|
+
onConfirm: async () => {
|
|
545
|
+
try {
|
|
546
|
+
const response = await fetch(
|
|
547
|
+
`/api/${collectionSlug}/${formData.id}/unpublish`,
|
|
548
|
+
{
|
|
549
|
+
method: "POST",
|
|
550
|
+
credentials: "include",
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
if (response.ok) {
|
|
554
|
+
onActionSuccess?.("Document unpublished successfully");
|
|
555
|
+
location.reload();
|
|
556
|
+
} else {
|
|
557
|
+
const error = await response.json();
|
|
558
|
+
setAlertModal({
|
|
559
|
+
open: true,
|
|
560
|
+
title: "Error",
|
|
561
|
+
message: error.error || "Failed to unpublish",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
} catch (err) {
|
|
565
|
+
setAlertModal({
|
|
566
|
+
open: true,
|
|
567
|
+
title: "Error",
|
|
568
|
+
message: "Failed to unpublish",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
};
|
|
574
|
+
|
|
285
575
|
const handleFieldChange = (fieldName: string, value: any) => {
|
|
286
576
|
setFormData((prev) => ({
|
|
287
577
|
...prev,
|
|
@@ -389,6 +679,7 @@ export function AutoForm({
|
|
|
389
679
|
}));
|
|
390
680
|
}
|
|
391
681
|
}}
|
|
682
|
+
//@ts-ignore
|
|
392
683
|
disabled={loadingFields[f.name!] || disabled}
|
|
393
684
|
className="bg-[var(--kyro-primary)] text-white px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
394
685
|
>
|
|
@@ -463,7 +754,6 @@ export function AutoForm({
|
|
|
463
754
|
</div>
|
|
464
755
|
);
|
|
465
756
|
}
|
|
466
|
-
|
|
467
757
|
case "text":
|
|
468
758
|
case "email":
|
|
469
759
|
const textValue = currentData[field.name!];
|
|
@@ -493,13 +783,18 @@ export function AutoForm({
|
|
|
493
783
|
chars[Math.floor(Math.random() * chars.length)];
|
|
494
784
|
}
|
|
495
785
|
onFieldChange(`kyro_${suffix}`);
|
|
786
|
+
} else if (field.admin?.autoGenerate) {
|
|
787
|
+
onFieldChange(
|
|
788
|
+
slugifyText(
|
|
789
|
+
formData[field.admin!.autoGenerate as string] || "",
|
|
790
|
+
),
|
|
791
|
+
);
|
|
496
792
|
} else if (
|
|
497
793
|
field.admin?.readOnly &&
|
|
498
794
|
textValue &&
|
|
499
795
|
!isKeyHidden
|
|
500
796
|
) {
|
|
501
797
|
await navigator.clipboard.writeText(String(textValue));
|
|
502
|
-
// Store the actual key in a temp var and show hidden
|
|
503
798
|
const actualKey = textValue;
|
|
504
799
|
onFieldChange(actualKey + "__COPIED__");
|
|
505
800
|
setTimeout(
|
|
@@ -512,7 +807,9 @@ export function AutoForm({
|
|
|
512
807
|
title={
|
|
513
808
|
field.admin?.autoGenerate === "key"
|
|
514
809
|
? "Generate new key"
|
|
515
|
-
:
|
|
810
|
+
: field.admin?.autoGenerate
|
|
811
|
+
? `Generate from ${field.admin.autoGenerate}`
|
|
812
|
+
: "Copy to clipboard"
|
|
516
813
|
}
|
|
517
814
|
>
|
|
518
815
|
{field.admin?.autoGenerate === "key" ? (
|
|
@@ -526,6 +823,17 @@ export function AutoForm({
|
|
|
526
823
|
>
|
|
527
824
|
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
528
825
|
</svg>
|
|
826
|
+
) : field.admin?.autoGenerate ? (
|
|
827
|
+
<svg
|
|
828
|
+
width="14"
|
|
829
|
+
height="14"
|
|
830
|
+
viewBox="0 0 24 24"
|
|
831
|
+
fill="none"
|
|
832
|
+
stroke="currentColor"
|
|
833
|
+
strokeWidth="2"
|
|
834
|
+
>
|
|
835
|
+
<path d="M12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
836
|
+
</svg>
|
|
529
837
|
) : (
|
|
530
838
|
<svg
|
|
531
839
|
width="14"
|
|
@@ -557,7 +865,12 @@ export function AutoForm({
|
|
|
557
865
|
<button
|
|
558
866
|
type="button"
|
|
559
867
|
onClick={() =>
|
|
560
|
-
onFieldChange(
|
|
868
|
+
onFieldChange(
|
|
869
|
+
slugifyText(
|
|
870
|
+
formData[field.admin?.autoGenerate || "title"] ||
|
|
871
|
+
"",
|
|
872
|
+
),
|
|
873
|
+
)
|
|
561
874
|
}
|
|
562
875
|
className="p-1 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)]"
|
|
563
876
|
>
|
|
@@ -658,32 +971,8 @@ export function AutoForm({
|
|
|
658
971
|
case "textarea":
|
|
659
972
|
return (
|
|
660
973
|
<div key={field.name} className="kyro-form-field">
|
|
661
|
-
<label className="kyro-form-label
|
|
974
|
+
<label className="kyro-form-label">
|
|
662
975
|
{field.label || field.name}
|
|
663
|
-
{field.admin?.autoGenerate && (
|
|
664
|
-
<button
|
|
665
|
-
type="button"
|
|
666
|
-
onClick={() =>
|
|
667
|
-
onFieldChange(
|
|
668
|
-
stripHtml(
|
|
669
|
-
formData[field.admin!.autoGenerate!] || "",
|
|
670
|
-
).slice(0, 160),
|
|
671
|
-
)
|
|
672
|
-
}
|
|
673
|
-
className="p-1 text-[var(--kyro-text-secondary)]"
|
|
674
|
-
>
|
|
675
|
-
<svg
|
|
676
|
-
width="14"
|
|
677
|
-
height="14"
|
|
678
|
-
viewBox="0 0 24 24"
|
|
679
|
-
fill="none"
|
|
680
|
-
stroke="currentColor"
|
|
681
|
-
strokeWidth="2"
|
|
682
|
-
>
|
|
683
|
-
<path d="M12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
684
|
-
</svg>
|
|
685
|
-
</button>
|
|
686
|
-
)}
|
|
687
976
|
</label>
|
|
688
977
|
<textarea
|
|
689
978
|
className="kyro-form-input kyro-form-textarea"
|
|
@@ -716,27 +1005,24 @@ export function AutoForm({
|
|
|
716
1005
|
);
|
|
717
1006
|
|
|
718
1007
|
case "richtext":
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
placeholder="Enter content..."
|
|
738
|
-
/>
|
|
739
|
-
</div>
|
|
1008
|
+
return (field as any).hasBlocks === false ? (
|
|
1009
|
+
<PortableTextField
|
|
1010
|
+
key={field.name}
|
|
1011
|
+
field={field as any}
|
|
1012
|
+
value={value}
|
|
1013
|
+
onChange={(newValue: any) => onFieldChange(newValue)}
|
|
1014
|
+
disabled={disabled}
|
|
1015
|
+
error={error}
|
|
1016
|
+
/>
|
|
1017
|
+
) : (
|
|
1018
|
+
<BlocksField
|
|
1019
|
+
key={field.name}
|
|
1020
|
+
field={field as any}
|
|
1021
|
+
value={value}
|
|
1022
|
+
onChange={(newValue: any) => onFieldChange(newValue)}
|
|
1023
|
+
disabled={disabled}
|
|
1024
|
+
error={error}
|
|
1025
|
+
/>
|
|
740
1026
|
);
|
|
741
1027
|
|
|
742
1028
|
case "group":
|
|
@@ -760,45 +1046,67 @@ export function AutoForm({
|
|
|
760
1046
|
case "array":
|
|
761
1047
|
if ("fields" in field) {
|
|
762
1048
|
const items = Array.isArray(value) ? value : [];
|
|
1049
|
+
const labelField = (field as any).fields?.[0]?.name || "user";
|
|
1050
|
+
const isRelationship =
|
|
1051
|
+
(field as any).fields?.[0]?.type === "relationship";
|
|
763
1052
|
return (
|
|
764
1053
|
<div key={field.name} className="kyro-form-field">
|
|
765
1054
|
<label className="kyro-form-label">
|
|
766
1055
|
{field.label || field.name}
|
|
767
1056
|
</label>
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1057
|
+
{isRelationship ? (
|
|
1058
|
+
<RelationshipField
|
|
1059
|
+
field={{
|
|
1060
|
+
name: labelField,
|
|
1061
|
+
relationTo: (field as any).fields[0].relationTo,
|
|
1062
|
+
hasMany: true,
|
|
1063
|
+
label: (field as any).fields[0].label,
|
|
1064
|
+
}}
|
|
1065
|
+
value={items.map((i: any) => i[labelField]).filter(Boolean)}
|
|
1066
|
+
onChange={(newValue: any) => {
|
|
1067
|
+
const newItems = (newValue || []).map((id: string) => ({
|
|
1068
|
+
[labelField]: id,
|
|
1069
|
+
}));
|
|
1070
|
+
onFieldChange(newItems);
|
|
1071
|
+
}}
|
|
1072
|
+
disabled={disabled}
|
|
1073
|
+
/>
|
|
1074
|
+
) : (
|
|
1075
|
+
<div className="kyro-form-array">
|
|
1076
|
+
{items.map((item: any, index: number) => (
|
|
1077
|
+
<div key={index} className="kyro-form-array-item">
|
|
1078
|
+
<div className="flex justify-between mb-2">
|
|
1079
|
+
<span className="text-xs font-bold opacity-50">
|
|
1080
|
+
Item {index + 1}
|
|
1081
|
+
</span>
|
|
1082
|
+
<button
|
|
1083
|
+
type="button"
|
|
1084
|
+
className="text-red-500"
|
|
1085
|
+
onClick={() =>
|
|
1086
|
+
onFieldChange(items.filter((_, i) => i !== index))
|
|
1087
|
+
}
|
|
1088
|
+
>
|
|
1089
|
+
Remove
|
|
1090
|
+
</button>
|
|
1091
|
+
</div>
|
|
1092
|
+
{(field as any).fields.map((f: Field) =>
|
|
1093
|
+
renderField(f, item, (newItem) => {
|
|
1094
|
+
const newItems = [...items];
|
|
1095
|
+
newItems[index] = newItem;
|
|
1096
|
+
onFieldChange(newItems);
|
|
1097
|
+
}),
|
|
1098
|
+
)}
|
|
784
1099
|
</div>
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
</
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
type="button"
|
|
796
|
-
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
797
|
-
onClick={() => onFieldChange([...items, {}])}
|
|
798
|
-
>
|
|
799
|
-
Add Item
|
|
800
|
-
</button>
|
|
801
|
-
</div>
|
|
1100
|
+
))}
|
|
1101
|
+
<button
|
|
1102
|
+
type="button"
|
|
1103
|
+
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
1104
|
+
onClick={() => onFieldChange([...items, {}])}
|
|
1105
|
+
>
|
|
1106
|
+
Add Item
|
|
1107
|
+
</button>
|
|
1108
|
+
</div>
|
|
1109
|
+
)}
|
|
802
1110
|
</div>
|
|
803
1111
|
);
|
|
804
1112
|
}
|
|
@@ -806,31 +1114,14 @@ export function AutoForm({
|
|
|
806
1114
|
|
|
807
1115
|
case "blocks":
|
|
808
1116
|
return (
|
|
809
|
-
<
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
typeof value === "string"
|
|
818
|
-
? value
|
|
819
|
-
: value
|
|
820
|
-
? JSON.stringify(value, null, 2)
|
|
821
|
-
: ""
|
|
822
|
-
}
|
|
823
|
-
onChange={(e) => {
|
|
824
|
-
try {
|
|
825
|
-
onFieldChange(JSON.parse(e.target.value));
|
|
826
|
-
} catch {
|
|
827
|
-
onFieldChange(e.target.value);
|
|
828
|
-
}
|
|
829
|
-
}}
|
|
830
|
-
disabled={disabled}
|
|
831
|
-
placeholder="Blocks data (JSON)..."
|
|
832
|
-
/>
|
|
833
|
-
</div>
|
|
1117
|
+
<BlocksField
|
|
1118
|
+
key={field.name}
|
|
1119
|
+
field={field as any}
|
|
1120
|
+
value={value}
|
|
1121
|
+
onChange={(newValue: any) => onFieldChange(newValue)}
|
|
1122
|
+
disabled={disabled}
|
|
1123
|
+
error={error}
|
|
1124
|
+
/>
|
|
834
1125
|
);
|
|
835
1126
|
|
|
836
1127
|
case "number":
|
|
@@ -1087,10 +1378,23 @@ export function AutoForm({
|
|
|
1087
1378
|
/>
|
|
1088
1379
|
);
|
|
1089
1380
|
|
|
1090
|
-
case "
|
|
1381
|
+
case "code":
|
|
1382
|
+
return (
|
|
1383
|
+
<CodeField
|
|
1384
|
+
key={field.name}
|
|
1385
|
+
field={field as any}
|
|
1386
|
+
value={value || ""}
|
|
1387
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
1388
|
+
disabled={disabled}
|
|
1389
|
+
error={error}
|
|
1390
|
+
/>
|
|
1391
|
+
);
|
|
1392
|
+
|
|
1393
|
+
// @ts-ignore - 'image' is supported but not in the standard union yet
|
|
1091
1394
|
case "image":
|
|
1395
|
+
case "upload":
|
|
1092
1396
|
return (
|
|
1093
|
-
<
|
|
1397
|
+
<UploadField
|
|
1094
1398
|
key={field.name}
|
|
1095
1399
|
field={field as any}
|
|
1096
1400
|
value={value}
|
|
@@ -1107,6 +1411,7 @@ export function AutoForm({
|
|
|
1107
1411
|
const renderHeader = () => {
|
|
1108
1412
|
const docTitle = formData.title || formData.name || "Untitled";
|
|
1109
1413
|
const status = formData.status || "draft";
|
|
1414
|
+
const isNew = !formData.id;
|
|
1110
1415
|
const lastModified = formData.updatedAt
|
|
1111
1416
|
? new Date(formData.updatedAt).toLocaleString()
|
|
1112
1417
|
: "Just now";
|
|
@@ -1141,19 +1446,61 @@ export function AutoForm({
|
|
|
1141
1446
|
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
1142
1447
|
<span className="flex items-center gap-1.5 capitalize">
|
|
1143
1448
|
<span
|
|
1144
|
-
className={`h-1.5 w-1.5 rounded-full ${status === "published" ? "bg-
|
|
1449
|
+
className={`h-1.5 w-1.5 rounded-full ${status === "published" && !hasUnsavedChanges ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
|
|
1145
1450
|
/>
|
|
1146
|
-
{status}
|
|
1451
|
+
{hasUnsavedChanges ? "Draft" : status}
|
|
1147
1452
|
</span>
|
|
1148
|
-
{
|
|
1453
|
+
{autoSaveStatus === "saving" && (
|
|
1454
|
+
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
1455
|
+
<svg
|
|
1456
|
+
className="animate-spin h-3 w-3"
|
|
1457
|
+
viewBox="0 0 24 24"
|
|
1458
|
+
fill="none"
|
|
1459
|
+
>
|
|
1460
|
+
<circle
|
|
1461
|
+
className="opacity-25"
|
|
1462
|
+
cx="12"
|
|
1463
|
+
cy="12"
|
|
1464
|
+
r="10"
|
|
1465
|
+
stroke="currentColor"
|
|
1466
|
+
strokeWidth="4"
|
|
1467
|
+
/>
|
|
1468
|
+
<path
|
|
1469
|
+
className="opacity-75"
|
|
1470
|
+
fill="currentColor"
|
|
1471
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
1472
|
+
/>
|
|
1473
|
+
</svg>
|
|
1474
|
+
Saving...
|
|
1475
|
+
</span>
|
|
1476
|
+
)}
|
|
1477
|
+
{autoSaveStatus === "saved" && (
|
|
1478
|
+
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
1479
|
+
<svg
|
|
1480
|
+
width="12"
|
|
1481
|
+
height="12"
|
|
1482
|
+
viewBox="0 0 24 24"
|
|
1483
|
+
fill="none"
|
|
1484
|
+
stroke="currentColor"
|
|
1485
|
+
strokeWidth="3"
|
|
1486
|
+
>
|
|
1487
|
+
<path d="M20 6L9 17l-5-5" />
|
|
1488
|
+
</svg>
|
|
1489
|
+
Saved
|
|
1490
|
+
</span>
|
|
1491
|
+
)}
|
|
1492
|
+
{autoSaveStatus === "error" && (
|
|
1493
|
+
<span className="text-[var(--kyro-danger)]">Save failed</span>
|
|
1494
|
+
)}
|
|
1495
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && (
|
|
1149
1496
|
<>
|
|
1150
1497
|
<span className="opacity-30">—</span>
|
|
1151
1498
|
<button
|
|
1152
1499
|
type="button"
|
|
1153
|
-
onClick={() => setFormData(
|
|
1500
|
+
onClick={() => setFormData(lastSavedData)}
|
|
1154
1501
|
className="text-[var(--kyro-primary)] hover:underline"
|
|
1155
1502
|
>
|
|
1156
|
-
Revert
|
|
1503
|
+
Revert changes
|
|
1157
1504
|
</button>
|
|
1158
1505
|
</>
|
|
1159
1506
|
)}
|
|
@@ -1207,8 +1554,11 @@ export function AutoForm({
|
|
|
1207
1554
|
</button>
|
|
1208
1555
|
<button
|
|
1209
1556
|
type="button"
|
|
1210
|
-
|
|
1211
|
-
|
|
1557
|
+
onClick={() => {
|
|
1558
|
+
window.dispatchEvent(new CustomEvent("toggle-sidebar"));
|
|
1559
|
+
}}
|
|
1560
|
+
className={`p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
1561
|
+
title="Toggle Sidebar"
|
|
1212
1562
|
>
|
|
1213
1563
|
<svg
|
|
1214
1564
|
width="20"
|
|
@@ -1219,38 +1569,258 @@ export function AutoForm({
|
|
|
1219
1569
|
strokeWidth="2"
|
|
1220
1570
|
>
|
|
1221
1571
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
1222
|
-
<line x1="
|
|
1572
|
+
<line x1="9" y1="3" x2="9" y2="21" />
|
|
1223
1573
|
</svg>
|
|
1224
1574
|
</button>
|
|
1225
1575
|
|
|
1226
1576
|
<button
|
|
1227
1577
|
id="btn-save"
|
|
1228
1578
|
type="button"
|
|
1229
|
-
onClick={() =>
|
|
1230
|
-
|
|
1231
|
-
|
|
1579
|
+
onClick={async () => {
|
|
1580
|
+
const hiddenInput = document.getElementById(
|
|
1581
|
+
"form-data",
|
|
1582
|
+
) as HTMLInputElement;
|
|
1583
|
+
if (!hiddenInput || !hiddenInput.value) return;
|
|
1584
|
+
|
|
1585
|
+
const btn = document.getElementById(
|
|
1586
|
+
"btn-save",
|
|
1587
|
+
) as HTMLButtonElement;
|
|
1588
|
+
const originalText = btn?.textContent || "";
|
|
1589
|
+
if (btn) {
|
|
1590
|
+
btn.textContent = "Saving...";
|
|
1591
|
+
btn.setAttribute("disabled", "true");
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
try {
|
|
1595
|
+
const data = JSON.parse(hiddenInput.value);
|
|
1596
|
+
const url = isNew
|
|
1597
|
+
? `/api/${collectionSlug}`
|
|
1598
|
+
: `/api/${collectionSlug}/${formData.id}`;
|
|
1599
|
+
const method = isNew ? "POST" : "PATCH";
|
|
1600
|
+
|
|
1601
|
+
const response = await fetch(url, {
|
|
1602
|
+
method,
|
|
1603
|
+
credentials: "include",
|
|
1604
|
+
headers: { "Content-Type": "application/json" },
|
|
1605
|
+
body: JSON.stringify(data),
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
if (response.ok) {
|
|
1609
|
+
const result = await response.json();
|
|
1610
|
+
setLastSavedData(result.data || formData);
|
|
1611
|
+
lastAutoSaveTimeRef.current = Date.now();
|
|
1612
|
+
setAutoSaveStatus("saved");
|
|
1613
|
+
fetchVersions();
|
|
1614
|
+
setTimeout(() => setAutoSaveStatus("idle"), 2000);
|
|
1615
|
+
onActionSuccess?.(
|
|
1616
|
+
isNew ? "Document created successfully" : "Changes saved",
|
|
1617
|
+
);
|
|
1618
|
+
if (isNew) {
|
|
1619
|
+
setTimeout(() => {
|
|
1620
|
+
window.location.href = `/${collectionSlug}`;
|
|
1621
|
+
}, 800);
|
|
1622
|
+
}
|
|
1623
|
+
} else {
|
|
1624
|
+
const error = await response.json();
|
|
1625
|
+
setAlertModal({
|
|
1626
|
+
open: true,
|
|
1627
|
+
title: "Error",
|
|
1628
|
+
message: error.error || "Failed to save",
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
setAlertModal({
|
|
1633
|
+
open: true,
|
|
1634
|
+
title: "Error",
|
|
1635
|
+
message: "Failed to save document",
|
|
1636
|
+
});
|
|
1637
|
+
} finally {
|
|
1638
|
+
if (btn) {
|
|
1639
|
+
btn.textContent = originalText;
|
|
1640
|
+
btn.removeAttribute("disabled");
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}}
|
|
1232
1644
|
className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
|
|
1233
1645
|
>
|
|
1234
|
-
|
|
1646
|
+
{isNew ? "Create" : hasUnsavedChanges ? "Save Draft" : "Saved"}
|
|
1235
1647
|
</button>
|
|
1236
1648
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1649
|
+
{!isNew && status === "draft" && (
|
|
1650
|
+
<button
|
|
1651
|
+
id="btn-publish"
|
|
1652
|
+
type="button"
|
|
1653
|
+
onClick={async () => {
|
|
1654
|
+
const btn = document.getElementById(
|
|
1655
|
+
"btn-publish",
|
|
1656
|
+
) as HTMLButtonElement;
|
|
1657
|
+
const originalText = btn?.textContent || "";
|
|
1658
|
+
if (btn) {
|
|
1659
|
+
btn.textContent = "Publishing...";
|
|
1660
|
+
btn.setAttribute("disabled", "true");
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
try {
|
|
1664
|
+
const response = await fetch(
|
|
1665
|
+
`/api/${collectionSlug}/${formData.id}/publish`,
|
|
1666
|
+
{
|
|
1667
|
+
method: "POST",
|
|
1668
|
+
credentials: "include",
|
|
1669
|
+
},
|
|
1670
|
+
);
|
|
1671
|
+
|
|
1672
|
+
if (response.ok) {
|
|
1673
|
+
onActionSuccess?.("Published successfully");
|
|
1674
|
+
location.reload();
|
|
1675
|
+
} else {
|
|
1676
|
+
const error = await response.json();
|
|
1677
|
+
setAlertModal({
|
|
1678
|
+
open: true,
|
|
1679
|
+
title: "Error",
|
|
1680
|
+
message: error.error || "Failed to publish",
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
setAlertModal({
|
|
1685
|
+
open: true,
|
|
1686
|
+
title: "Error",
|
|
1687
|
+
message: "Failed to publish",
|
|
1688
|
+
});
|
|
1689
|
+
} finally {
|
|
1690
|
+
if (btn) {
|
|
1691
|
+
btn.textContent = originalText;
|
|
1692
|
+
btn.removeAttribute("disabled");
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}}
|
|
1696
|
+
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"
|
|
1248
1697
|
>
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1698
|
+
Publish
|
|
1699
|
+
</button>
|
|
1700
|
+
)}
|
|
1701
|
+
|
|
1702
|
+
<div ref={menuRef} className="relative">
|
|
1703
|
+
<button
|
|
1704
|
+
type="button"
|
|
1705
|
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
1706
|
+
className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
|
|
1707
|
+
>
|
|
1708
|
+
<svg
|
|
1709
|
+
width="20"
|
|
1710
|
+
height="20"
|
|
1711
|
+
viewBox="0 0 24 24"
|
|
1712
|
+
fill="none"
|
|
1713
|
+
stroke="currentColor"
|
|
1714
|
+
strokeWidth="3"
|
|
1715
|
+
>
|
|
1716
|
+
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
|
1717
|
+
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
|
1718
|
+
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
1719
|
+
</svg>
|
|
1720
|
+
</button>
|
|
1721
|
+
{isMenuOpen && (
|
|
1722
|
+
<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">
|
|
1723
|
+
<button
|
|
1724
|
+
type="button"
|
|
1725
|
+
onClick={() => {
|
|
1726
|
+
handleCreateNew();
|
|
1727
|
+
setIsMenuOpen(false);
|
|
1728
|
+
}}
|
|
1729
|
+
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"
|
|
1730
|
+
>
|
|
1731
|
+
<svg
|
|
1732
|
+
width="16"
|
|
1733
|
+
height="16"
|
|
1734
|
+
viewBox="0 0 24 24"
|
|
1735
|
+
fill="none"
|
|
1736
|
+
stroke="currentColor"
|
|
1737
|
+
strokeWidth="2"
|
|
1738
|
+
>
|
|
1739
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
1740
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
1741
|
+
</svg>
|
|
1742
|
+
Create New
|
|
1743
|
+
</button>
|
|
1744
|
+
{!isNew && (
|
|
1745
|
+
<>
|
|
1746
|
+
<button
|
|
1747
|
+
type="button"
|
|
1748
|
+
onClick={() => {
|
|
1749
|
+
handleDuplicate();
|
|
1750
|
+
setIsMenuOpen(false);
|
|
1751
|
+
}}
|
|
1752
|
+
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"
|
|
1753
|
+
>
|
|
1754
|
+
<svg
|
|
1755
|
+
width="16"
|
|
1756
|
+
height="16"
|
|
1757
|
+
viewBox="0 0 24 24"
|
|
1758
|
+
fill="none"
|
|
1759
|
+
stroke="currentColor"
|
|
1760
|
+
strokeWidth="2"
|
|
1761
|
+
>
|
|
1762
|
+
<rect
|
|
1763
|
+
x="9"
|
|
1764
|
+
y="9"
|
|
1765
|
+
width="13"
|
|
1766
|
+
height="13"
|
|
1767
|
+
rx="2"
|
|
1768
|
+
ry="2"
|
|
1769
|
+
></rect>
|
|
1770
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
1771
|
+
</svg>
|
|
1772
|
+
Duplicate
|
|
1773
|
+
</button>
|
|
1774
|
+
{status === "published" && (
|
|
1775
|
+
<button
|
|
1776
|
+
type="button"
|
|
1777
|
+
onClick={() => {
|
|
1778
|
+
handleUnpublish();
|
|
1779
|
+
setIsMenuOpen(false);
|
|
1780
|
+
}}
|
|
1781
|
+
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"
|
|
1782
|
+
>
|
|
1783
|
+
<svg
|
|
1784
|
+
width="16"
|
|
1785
|
+
height="16"
|
|
1786
|
+
viewBox="0 0 24 24"
|
|
1787
|
+
fill="none"
|
|
1788
|
+
stroke="currentColor"
|
|
1789
|
+
strokeWidth="2"
|
|
1790
|
+
>
|
|
1791
|
+
<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>
|
|
1792
|
+
<line x1="1" y1="1" x2="23" y2="23"></line>
|
|
1793
|
+
</svg>
|
|
1794
|
+
Unpublish
|
|
1795
|
+
</button>
|
|
1796
|
+
)}
|
|
1797
|
+
<div className="h-px bg-[var(--kyro-border)]" />
|
|
1798
|
+
<button
|
|
1799
|
+
type="button"
|
|
1800
|
+
onClick={() => {
|
|
1801
|
+
handleDelete();
|
|
1802
|
+
setIsMenuOpen(false);
|
|
1803
|
+
}}
|
|
1804
|
+
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"
|
|
1805
|
+
>
|
|
1806
|
+
<svg
|
|
1807
|
+
width="16"
|
|
1808
|
+
height="16"
|
|
1809
|
+
viewBox="0 0 24 24"
|
|
1810
|
+
fill="none"
|
|
1811
|
+
stroke="currentColor"
|
|
1812
|
+
strokeWidth="2"
|
|
1813
|
+
>
|
|
1814
|
+
<polyline points="3 6 5 6 21 6"></polyline>
|
|
1815
|
+
<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>
|
|
1816
|
+
</svg>
|
|
1817
|
+
Delete
|
|
1818
|
+
</button>
|
|
1819
|
+
</>
|
|
1820
|
+
)}
|
|
1821
|
+
</div>
|
|
1822
|
+
)}
|
|
1823
|
+
</div>
|
|
1254
1824
|
</div>
|
|
1255
1825
|
</div>
|
|
1256
1826
|
</header>
|
|
@@ -1270,9 +1840,20 @@ export function AutoForm({
|
|
|
1270
1840
|
}
|
|
1271
1841
|
|
|
1272
1842
|
// Default split layout
|
|
1843
|
+
const showRightColumn = !sidebarCollapsed && !showPreview;
|
|
1844
|
+
const hasSidebarFields =
|
|
1845
|
+
config.fields.some((f) => f.admin?.position === "sidebar") &&
|
|
1846
|
+
!showPreview;
|
|
1847
|
+
|
|
1273
1848
|
return (
|
|
1274
1849
|
<div
|
|
1275
|
-
className={`w-full mx-auto grid gap-8 pb-32 transition-all duration-700 ${
|
|
1850
|
+
className={`w-full mx-auto grid gap-8 pb-32 transition-all duration-700 ${
|
|
1851
|
+
showPreview
|
|
1852
|
+
? "grid-cols-1 lg:grid-cols-2"
|
|
1853
|
+
: sidebarCollapsed || !hasSidebarFields
|
|
1854
|
+
? "grid-cols-1"
|
|
1855
|
+
: "grid-cols-1 lg:grid-cols-[1fr_380px]"
|
|
1856
|
+
}`}
|
|
1276
1857
|
>
|
|
1277
1858
|
<div className="space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
|
1278
1859
|
{config.tabs ? (
|
|
@@ -1305,12 +1886,12 @@ export function AutoForm({
|
|
|
1305
1886
|
<div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
|
|
1306
1887
|
</div>
|
|
1307
1888
|
</div>
|
|
1308
|
-
) : (
|
|
1889
|
+
) : sidebarCollapsed ? null : (
|
|
1309
1890
|
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
|
1310
1891
|
{config.fields.some((f) => f.admin?.position === "sidebar") && (
|
|
1311
1892
|
<div className="surface-tile p-6 space-y-6">
|
|
1312
1893
|
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
|
1313
|
-
|
|
1894
|
+
Settings
|
|
1314
1895
|
</h3>
|
|
1315
1896
|
{config.fields
|
|
1316
1897
|
.filter((f) => f.admin?.position === "sidebar")
|
|
@@ -1324,62 +1905,205 @@ export function AutoForm({
|
|
|
1324
1905
|
};
|
|
1325
1906
|
|
|
1326
1907
|
const renderVersionView = () => (
|
|
1327
|
-
<div className="w-full
|
|
1328
|
-
<div className="surface-tile p-
|
|
1329
|
-
<
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1908
|
+
<div className="w-full animate-in fade-in slide-in-from-bottom-4 pb-12">
|
|
1909
|
+
<div className="surface-tile p-0 overflow-hidden">
|
|
1910
|
+
<div className="px-6 py-4 border-b border-[var(--kyro-border)] flex items-center justify-between">
|
|
1911
|
+
<div>
|
|
1912
|
+
<h2 className="text-lg font-bold text-[var(--kyro-text-primary)]">
|
|
1913
|
+
Version History
|
|
1914
|
+
</h2>
|
|
1915
|
+
<p className="text-[11px] text-[var(--kyro-text-muted)] mt-0.5">
|
|
1916
|
+
{compareMode
|
|
1917
|
+
? `Select 2 versions · ${compareSelected.length}/2 chosen`
|
|
1918
|
+
: `${versions.length} snapshot${versions.length !== 1 ? "s" : ""} · Auto-saved`}
|
|
1919
|
+
</p>
|
|
1920
|
+
</div>
|
|
1921
|
+
<div className="flex items-center gap-2">
|
|
1922
|
+
{compareMode && compareSelected.length === 2 && (
|
|
1923
|
+
<button
|
|
1924
|
+
type="button"
|
|
1925
|
+
onClick={handleCompareVersions}
|
|
1926
|
+
disabled={loadingDiffs}
|
|
1927
|
+
className="px-3 py-1.5 rounded-lg bg-[var(--kyro-primary)] text-white text-[11px] font-bold uppercase tracking-wider hover:opacity-90 disabled:opacity-50"
|
|
1928
|
+
>
|
|
1929
|
+
{loadingDiffs ? "Comparing..." : "Compare"}
|
|
1930
|
+
</button>
|
|
1931
|
+
)}
|
|
1932
|
+
<button
|
|
1933
|
+
type="button"
|
|
1934
|
+
onClick={() => {
|
|
1935
|
+
setCompareMode(!compareMode);
|
|
1936
|
+
setCompareSelected([]);
|
|
1937
|
+
setCompareDiffs([]);
|
|
1938
|
+
}}
|
|
1939
|
+
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
|
|
1940
|
+
compareMode
|
|
1941
|
+
? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
1942
|
+
: "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
1943
|
+
}`}
|
|
1944
|
+
>
|
|
1945
|
+
{compareMode ? "Done" : "Compare"}
|
|
1946
|
+
</button>
|
|
1947
|
+
</div>
|
|
1948
|
+
</div>
|
|
1949
|
+
|
|
1950
|
+
{compareDiffs.length > 0 && (
|
|
1951
|
+
<div className="border-b border-[var(--kyro-border)]">
|
|
1952
|
+
<div className="px-6 py-3 flex items-center justify-between">
|
|
1953
|
+
<span className="text-[11px] font-bold text-[var(--kyro-text-primary)] uppercase tracking-wider">
|
|
1954
|
+
{compareDiffs.length} change
|
|
1955
|
+
{compareDiffs.length !== 1 ? "s" : ""}
|
|
1956
|
+
</span>
|
|
1957
|
+
<button
|
|
1958
|
+
type="button"
|
|
1959
|
+
onClick={() => setCompareDiffs([])}
|
|
1960
|
+
className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
|
|
1961
|
+
>
|
|
1962
|
+
<svg
|
|
1963
|
+
className="w-3.5 h-3.5"
|
|
1964
|
+
viewBox="0 0 24 24"
|
|
1965
|
+
fill="none"
|
|
1966
|
+
stroke="currentColor"
|
|
1967
|
+
strokeWidth="2.5"
|
|
1968
|
+
>
|
|
1969
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
1970
|
+
</svg>
|
|
1971
|
+
</button>
|
|
1972
|
+
</div>
|
|
1973
|
+
<div className="max-h-[400px] overflow-y-auto">
|
|
1974
|
+
{compareDiffs.map((d, i) => (
|
|
1975
|
+
<div
|
|
1976
|
+
key={i}
|
|
1977
|
+
className="grid grid-cols-4 gap-3 px-6 py-2.5 text-[11px] font-mono border-t border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)]"
|
|
1978
|
+
>
|
|
1979
|
+
<div className="text-[var(--kyro-text-muted)] truncate">
|
|
1980
|
+
{d.field}
|
|
1981
|
+
</div>
|
|
1982
|
+
<div className="text-[var(--kyro-text-muted)] truncate">
|
|
1983
|
+
{typeof d.oldValue === "object"
|
|
1984
|
+
? JSON.stringify(d.oldValue)
|
|
1985
|
+
: String(d.oldValue ?? "null")}
|
|
1986
|
+
</div>
|
|
1987
|
+
<div className="col-span-2 text-[var(--kyro-text-primary)] truncate">
|
|
1988
|
+
{typeof d.newValue === "object"
|
|
1989
|
+
? JSON.stringify(d.newValue)
|
|
1990
|
+
: String(d.newValue ?? "null")}
|
|
1991
|
+
</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
))}
|
|
1994
|
+
</div>
|
|
1995
|
+
</div>
|
|
1996
|
+
)}
|
|
1333
1997
|
|
|
1334
1998
|
{loadingVersions ? (
|
|
1335
|
-
<div className="flex justify-center py-
|
|
1999
|
+
<div className="flex justify-center py-16">
|
|
1336
2000
|
<span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
|
|
1337
2001
|
</div>
|
|
1338
2002
|
) : versions.length === 0 ? (
|
|
1339
|
-
<
|
|
1340
|
-
No
|
|
1341
|
-
</
|
|
2003
|
+
<div className="text-center py-16 text-[var(--kyro-text-muted)] text-sm italic">
|
|
2004
|
+
No versions yet.
|
|
2005
|
+
</div>
|
|
1342
2006
|
) : (
|
|
1343
|
-
<div className="
|
|
1344
|
-
{versions.map((v, i) =>
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
2007
|
+
<div className="divide-y divide-[var(--kyro-border)]">
|
|
2008
|
+
{versions.map((v, i) => {
|
|
2009
|
+
const isSelected = compareSelected.includes(v.id);
|
|
2010
|
+
const isDraftVersion = v.status === "draft";
|
|
2011
|
+
const isAutoSaved = (v.changeDescription || "")
|
|
2012
|
+
.toLowerCase()
|
|
2013
|
+
.includes("auto");
|
|
2014
|
+
|
|
2015
|
+
return (
|
|
2016
|
+
<div
|
|
2017
|
+
key={v.id}
|
|
2018
|
+
onClick={
|
|
2019
|
+
compareMode ? () => toggleCompareSelection(v.id) : undefined
|
|
2020
|
+
}
|
|
2021
|
+
className={`grid grid-cols-12 gap-3 px-6 py-3 items-center transition-all ${
|
|
2022
|
+
compareMode
|
|
2023
|
+
? isSelected
|
|
2024
|
+
? "bg-[var(--kyro-primary)]/5 cursor-pointer"
|
|
2025
|
+
: "hover:bg-[var(--kyro-bg-secondary)] cursor-pointer"
|
|
2026
|
+
: "hover:bg-[var(--kyro-bg-secondary)]"
|
|
2027
|
+
} ${isDraftVersion ? "" : ""}`}
|
|
2028
|
+
>
|
|
2029
|
+
<div className="col-span-1 flex items-center gap-2">
|
|
2030
|
+
{compareMode ? (
|
|
2031
|
+
<div
|
|
2032
|
+
className={`w-4 h-4 rounded-full border ${
|
|
2033
|
+
isSelected
|
|
2034
|
+
? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
|
|
2035
|
+
: "border-[var(--kyro-border)]"
|
|
2036
|
+
}`}
|
|
2037
|
+
>
|
|
2038
|
+
{isSelected && (
|
|
2039
|
+
<svg
|
|
2040
|
+
className="w-full h-full text-white p-0.5"
|
|
2041
|
+
viewBox="0 0 24 24"
|
|
2042
|
+
fill="none"
|
|
2043
|
+
stroke="currentColor"
|
|
2044
|
+
strokeWidth="3"
|
|
2045
|
+
>
|
|
2046
|
+
<path d="M20 6L9 17l-5-5" />
|
|
2047
|
+
</svg>
|
|
2048
|
+
)}
|
|
2049
|
+
</div>
|
|
2050
|
+
) : (
|
|
2051
|
+
<span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5">
|
|
2052
|
+
{versions.length - i}
|
|
1365
2053
|
</span>
|
|
2054
|
+
)}
|
|
2055
|
+
</div>
|
|
2056
|
+
<div className="col-span-4 min-w-0">
|
|
2057
|
+
<div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-2">
|
|
2058
|
+
{v.changeDescription || "Snapshot"}
|
|
2059
|
+
{isAutoSaved && (
|
|
2060
|
+
<span className="text-[9px] px-1.5 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold uppercase tracking-wider">
|
|
2061
|
+
Auto
|
|
2062
|
+
</span>
|
|
2063
|
+
)}
|
|
2064
|
+
</div>
|
|
2065
|
+
<div className="text-[11px] text-[var(--kyro-text-muted)]">
|
|
2066
|
+
{new Date(v.createdAt).toLocaleString("en-US", {
|
|
2067
|
+
month: "short",
|
|
2068
|
+
day: "numeric",
|
|
2069
|
+
hour: "2-digit",
|
|
2070
|
+
minute: "2-digit",
|
|
2071
|
+
})}
|
|
1366
2072
|
</div>
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
2073
|
+
</div>
|
|
2074
|
+
<div className="col-span-3">
|
|
2075
|
+
{v.status && (
|
|
2076
|
+
<span
|
|
2077
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold capitalize tracking-wider ${
|
|
2078
|
+
v.status === "published"
|
|
2079
|
+
? " text-[var(--kyro-success)]"
|
|
2080
|
+
: " text-[var(--kyro-warning)]"
|
|
2081
|
+
}`}
|
|
2082
|
+
>
|
|
2083
|
+
<span
|
|
2084
|
+
className={`w-1.5 h-1.5 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
|
|
2085
|
+
/>
|
|
2086
|
+
{v.status}
|
|
2087
|
+
</span>
|
|
2088
|
+
)}
|
|
2089
|
+
</div>
|
|
2090
|
+
<div className="col-span-2 text-[11px] text-[var(--kyro-text-muted)]">
|
|
2091
|
+
{v.createdBy || "system"}
|
|
2092
|
+
</div>
|
|
2093
|
+
<div className="col-span-2 flex justify-end">
|
|
2094
|
+
{!compareMode && (
|
|
2095
|
+
<button
|
|
2096
|
+
type="button"
|
|
2097
|
+
onClick={() => handleRestoreVersion(v.id)}
|
|
2098
|
+
className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold uppercase tracking-wider text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95"
|
|
2099
|
+
>
|
|
2100
|
+
Restore
|
|
2101
|
+
</button>
|
|
2102
|
+
)}
|
|
1370
2103
|
</div>
|
|
1371
2104
|
</div>
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
type="button"
|
|
1375
|
-
onClick={() => handleRestoreVersion(v.id)}
|
|
1376
|
-
className="px-5 py-2.5 rounded-xl border border-[var(--kyro-border)] text-[var(--kyro-text-primary)] text-xs font-black uppercase tracking-widest hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95 shadow-lg group-hover:shadow-[var(--kyro-primary)]"
|
|
1377
|
-
>
|
|
1378
|
-
Restore
|
|
1379
|
-
</button>
|
|
1380
|
-
</div>
|
|
1381
|
-
</div>
|
|
1382
|
-
))}
|
|
2105
|
+
);
|
|
2106
|
+
})}
|
|
1383
2107
|
</div>
|
|
1384
2108
|
)}
|
|
1385
2109
|
</div>
|
|
@@ -1508,131 +2232,36 @@ export function AutoForm({
|
|
|
1508
2232
|
{view === "version" && renderVersionView()}
|
|
1509
2233
|
{view === "api" && renderApiView()}
|
|
1510
2234
|
</main>
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
value: any;
|
|
1518
|
-
onChange: (value: any) => void;
|
|
1519
|
-
disabled?: boolean;
|
|
1520
|
-
error?: string;
|
|
1521
|
-
documentName?: string;
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
function UploadField({
|
|
1525
|
-
field,
|
|
1526
|
-
value,
|
|
1527
|
-
onChange,
|
|
1528
|
-
disabled,
|
|
1529
|
-
error,
|
|
1530
|
-
documentName,
|
|
1531
|
-
}: UploadFieldProps) {
|
|
1532
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
1533
|
-
const [preview, setPreview] = useState<string | null>(null);
|
|
1534
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
1535
|
-
|
|
1536
|
-
const isImage = (file: File) => file.type.startsWith("image/");
|
|
1537
|
-
|
|
1538
|
-
const handleFile = (file: File) => {
|
|
1539
|
-
if (isImage(file)) {
|
|
1540
|
-
const reader = new FileReader();
|
|
1541
|
-
reader.onloadend = () => {
|
|
1542
|
-
setPreview(reader.result as string);
|
|
1543
|
-
};
|
|
1544
|
-
reader.readAsDataURL(file);
|
|
1545
|
-
}
|
|
1546
|
-
onChange({
|
|
1547
|
-
filename: file.name,
|
|
1548
|
-
size: file.size,
|
|
1549
|
-
type: file.type,
|
|
1550
|
-
url: URL.createObjectURL(file),
|
|
1551
|
-
});
|
|
1552
|
-
};
|
|
1553
|
-
|
|
1554
|
-
const handleDrop = (e: React.DragEvent) => {
|
|
1555
|
-
e.preventDefault();
|
|
1556
|
-
setIsDragging(false);
|
|
1557
|
-
const file = e.dataTransfer.files[0];
|
|
1558
|
-
if (file) handleFile(file);
|
|
1559
|
-
};
|
|
1560
|
-
|
|
1561
|
-
return (
|
|
1562
|
-
<div className="kyro-form-field">
|
|
1563
|
-
<label className="kyro-form-label">
|
|
1564
|
-
{field.label || field.name}
|
|
1565
|
-
{field.required && <span className="kyro-form-label-required">*</span>}
|
|
1566
|
-
</label>
|
|
1567
|
-
<div
|
|
1568
|
-
className={`kyro-form-upload ${isDragging ? "kyro-form-upload-dragging" : ""} ${error ? "kyro-form-upload-error" : ""}`}
|
|
1569
|
-
onDragOver={(e) => {
|
|
1570
|
-
e.preventDefault();
|
|
1571
|
-
setIsDragging(true);
|
|
2235
|
+
<ConfirmModal
|
|
2236
|
+
open={confirmModal.open}
|
|
2237
|
+
onClose={() => setConfirmModal({ ...confirmModal, open: false })}
|
|
2238
|
+
onConfirm={() => {
|
|
2239
|
+
confirmModal.onConfirm();
|
|
2240
|
+
setConfirmModal({ ...confirmModal, open: false });
|
|
1572
2241
|
}}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
2242
|
+
title={confirmModal.title}
|
|
2243
|
+
message={confirmModal.message}
|
|
2244
|
+
variant={confirmModal.danger ? "danger" : "default"}
|
|
2245
|
+
/>
|
|
2246
|
+
<UIModal
|
|
2247
|
+
open={alertModal.open}
|
|
2248
|
+
onClose={() => setAlertModal({ ...alertModal, open: false })}
|
|
2249
|
+
title={alertModal.title}
|
|
2250
|
+
size="sm"
|
|
2251
|
+
footer={
|
|
2252
|
+
<button
|
|
2253
|
+
type="button"
|
|
2254
|
+
onClick={() => setAlertModal({ ...alertModal, open: false })}
|
|
2255
|
+
className="px-4 py-2 rounded-lg font-medium text-sm bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] hover:opacity-90 transition-colors"
|
|
2256
|
+
>
|
|
2257
|
+
OK
|
|
2258
|
+
</button>
|
|
2259
|
+
}
|
|
1576
2260
|
>
|
|
1577
|
-
<
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
onChange={(e) => {
|
|
1582
|
-
const file = e.target.files?.[0];
|
|
1583
|
-
if (file) handleFile(file);
|
|
1584
|
-
}}
|
|
1585
|
-
disabled={disabled}
|
|
1586
|
-
accept={field.mimeTypes?.join(",")}
|
|
1587
|
-
/>
|
|
1588
|
-
{preview || value?.url ? (
|
|
1589
|
-
<div className="kyro-form-upload-preview">
|
|
1590
|
-
<img
|
|
1591
|
-
src={preview || value.url}
|
|
1592
|
-
alt="Preview"
|
|
1593
|
-
className="kyro-form-upload-image"
|
|
1594
|
-
/>
|
|
1595
|
-
<div className="kyro-form-upload-info">
|
|
1596
|
-
<span className="kyro-form-upload-filename">
|
|
1597
|
-
{value?.filename || "Uploaded file"}
|
|
1598
|
-
</span>
|
|
1599
|
-
<button
|
|
1600
|
-
type="button"
|
|
1601
|
-
className="kyro-form-upload-change"
|
|
1602
|
-
onClick={(e) => {
|
|
1603
|
-
e.stopPropagation();
|
|
1604
|
-
inputRef.current?.click();
|
|
1605
|
-
}}
|
|
1606
|
-
>
|
|
1607
|
-
Change
|
|
1608
|
-
</button>
|
|
1609
|
-
</div>
|
|
1610
|
-
</div>
|
|
1611
|
-
) : (
|
|
1612
|
-
<div className="kyro-form-upload-placeholder">
|
|
1613
|
-
<svg
|
|
1614
|
-
width="32"
|
|
1615
|
-
height="32"
|
|
1616
|
-
viewBox="0 0 24 24"
|
|
1617
|
-
fill="none"
|
|
1618
|
-
stroke="currentColor"
|
|
1619
|
-
strokeWidth="1.5"
|
|
1620
|
-
>
|
|
1621
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
1622
|
-
<polyline points="17,8 12,3 7,8" />
|
|
1623
|
-
<line x1="12" y1="3" x2="12" y2="15" />
|
|
1624
|
-
</svg>
|
|
1625
|
-
<span>Drop image here or click to upload</span>
|
|
1626
|
-
<span className="kyro-form-upload-hint">
|
|
1627
|
-
PNG, JPG, GIF up to 10MB
|
|
1628
|
-
</span>
|
|
1629
|
-
</div>
|
|
1630
|
-
)}
|
|
1631
|
-
</div>
|
|
1632
|
-
{field.admin?.description && !error && (
|
|
1633
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
1634
|
-
)}
|
|
1635
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
2261
|
+
<p className="text-[var(--kyro-text-secondary)]">
|
|
2262
|
+
{alertModal.message}
|
|
2263
|
+
</p>
|
|
2264
|
+
</UIModal>
|
|
1636
2265
|
</div>
|
|
1637
2266
|
);
|
|
1638
2267
|
}
|