@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.
Files changed (71) hide show
  1. package/package.json +5 -3
  2. package/src/components/Admin.tsx +1 -1
  3. package/src/components/AutoForm.tsx +966 -337
  4. package/src/components/CreateView.tsx +1 -1
  5. package/src/components/DetailView.tsx +1 -1
  6. package/src/components/EnhancedListView.tsx +156 -52
  7. package/src/components/ListView.tsx +1 -1
  8. package/src/components/Modal.tsx +65 -8
  9. package/src/components/Sidebar.astro +2 -2
  10. package/src/components/ThemeProvider.tsx +8 -2
  11. package/src/components/blocks/AccordionBlock.tsx +20 -52
  12. package/src/components/blocks/ArrayBlock.tsx +40 -31
  13. package/src/components/blocks/BlockEditModal.tsx +170 -581
  14. package/src/components/blocks/ButtonBlock.tsx +27 -128
  15. package/src/components/blocks/CodeBlock.tsx +88 -40
  16. package/src/components/blocks/ColumnsBlock.tsx +27 -85
  17. package/src/components/blocks/FileBlock.tsx +38 -39
  18. package/src/components/blocks/HeadingBlock.tsx +9 -31
  19. package/src/components/blocks/HeroBlock.tsx +42 -100
  20. package/src/components/blocks/ImageBlock.tsx +6 -7
  21. package/src/components/blocks/LinkBlock.tsx +27 -33
  22. package/src/components/blocks/ListBlock.tsx +47 -26
  23. package/src/components/blocks/RelationshipBlock.tsx +26 -233
  24. package/src/components/blocks/RichTextBlock.tsx +66 -0
  25. package/src/components/blocks/VStackBlock.tsx +23 -37
  26. package/src/components/blocks/VideoBlock.tsx +52 -32
  27. package/src/components/fields/AccordionField.tsx +213 -0
  28. package/src/components/fields/ArrayField.tsx +241 -0
  29. package/src/components/fields/BlocksField.tsx +5 -5
  30. package/src/components/fields/ButtonField.tsx +53 -0
  31. package/src/components/fields/CheckboxField.tsx +7 -3
  32. package/src/components/fields/ChildrenField.tsx +48 -0
  33. package/src/components/fields/CodeField.tsx +154 -94
  34. package/src/components/fields/ColumnsField.tsx +137 -0
  35. package/src/components/fields/DateField.tsx +9 -24
  36. package/src/components/fields/EditorClient.tsx +426 -160
  37. package/src/components/fields/HeadingField.tsx +31 -0
  38. package/src/components/fields/HeroField.tsx +101 -0
  39. package/src/components/fields/JSONField.tsx +7 -27
  40. package/src/components/fields/LinkField.tsx +81 -0
  41. package/src/components/fields/ListField.tsx +74 -0
  42. package/src/components/fields/MarkdownField.tsx +4 -26
  43. package/src/components/fields/NumberField.tsx +9 -27
  44. package/src/components/fields/PortableTextField.tsx +61 -49
  45. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  46. package/src/components/fields/RelationshipField.tsx +59 -13
  47. package/src/components/fields/SelectField.tsx +6 -4
  48. package/src/components/fields/TextField.tsx +9 -24
  49. package/src/components/fields/UploadField.tsx +613 -0
  50. package/src/components/fields/VideoField.tsx +73 -0
  51. package/src/components/fields/extensions/blockComponents.tsx +11 -1
  52. package/src/components/fields/extensions/blocksStore.ts +1 -1
  53. package/src/components/fields/index.ts +12 -1
  54. package/src/components/layout/Layout.tsx +1 -1
  55. package/src/lib/api.ts +163 -0
  56. package/src/lib/config.ts +1 -1
  57. package/src/lib/dataStore.ts +87 -30
  58. package/src/lib/date-utils.ts +69 -0
  59. package/src/lib/db/version-adapter.ts +248 -0
  60. package/src/lib/i18n.tsx +353 -0
  61. package/src/lib/slugify.ts +15 -0
  62. package/src/lib/validation.ts +250 -0
  63. package/src/pages/api/[collection]/[id]/publish.ts +12 -4
  64. package/src/pages/api/[collection]/[id]/versions.ts +39 -9
  65. package/src/pages/api/[collection]/[id].ts +13 -1
  66. package/src/pages/api/[collection]/index.ts +5 -6
  67. package/src/styles/main.css +12 -2
  68. package/src/components/blocks/BlockEditModal.MARKER +0 -12
  69. package/src/components/fields/FileField.tsx +0 -390
  70. package/src/components/fields/HybridContentField.tsx +0 -109
  71. 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 { ImageField } from "./fields/ImageField";
9
- import { PortableTextField, HybridContentField } from "./fields";
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
- // Lightweight slugify helper for on-the-fly slug generation from a title/name
23
- function slugifyText(text: string): string {
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
- // Track unsaved changes
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(initialData);
202
+ JSON.stringify(formData) !== JSON.stringify(lastSavedData);
182
203
  setHasUnsavedChanges(isDifferent);
183
- }, [formData, initialData]);
204
+ }, [formData, lastSavedData]);
184
205
 
185
- // Auto-generate slug from title if locked
206
+ // Auto-generate slug from configured source field if locked
186
207
  useEffect(() => {
187
- if (isSlugLocked && formData.title) {
188
- const newSlug = slugifyText(formData.title);
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
- }, [formData.title, isSlugLocked]);
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
- : "Copy to clipboard"
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(slugifyText(formData.title || ""))
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 flex items-center justify-between">
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
- // TEMP: replaced with textarea to isolate hydration crash
720
- return (
721
- <div key={field.name} className="kyro-form-field">
722
- <label className="kyro-form-label">
723
- {field.label || field.name}
724
- </label>
725
- <textarea
726
- className="kyro-form-input kyro-form-textarea"
727
- rows={10}
728
- value={
729
- typeof value === "string"
730
- ? value
731
- : value
732
- ? JSON.stringify(value, null, 2)
733
- : ""
734
- }
735
- onChange={(e) => onFieldChange(e.target.value)}
736
- disabled={disabled}
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
- <div className="kyro-form-array">
769
- {items.map((item: any, index: number) => (
770
- <div key={index} className="kyro-form-array-item">
771
- <div className="flex justify-between mb-2">
772
- <span className="text-xs font-bold opacity-50">
773
- Item {index + 1}
774
- </span>
775
- <button
776
- type="button"
777
- className="text-red-500"
778
- onClick={() =>
779
- onFieldChange(items.filter((_, i) => i !== index))
780
- }
781
- >
782
- Remove
783
- </button>
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
- {(field as any).fields.map((f: Field) =>
786
- renderField(f, item, (newItem) => {
787
- const newItems = [...items];
788
- newItems[index] = newItem;
789
- onFieldChange(newItems);
790
- }),
791
- )}
792
- </div>
793
- ))}
794
- <button
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
- <div key={field.name} className="kyro-form-field">
810
- <label className="kyro-form-label">
811
- {field.label || field.name}
812
- </label>
813
- <textarea
814
- className="kyro-form-input kyro-form-textarea"
815
- rows={10}
816
- value={
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 "upload":
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
- <ImageField
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-green-500" : "bg-amber-500"}`}
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
- {hasUnsavedChanges && (
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(initialData)}
1500
+ onClick={() => setFormData(lastSavedData)}
1154
1501
  className="text-[var(--kyro-primary)] hover:underline"
1155
1502
  >
1156
- Revert to published
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
- className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
1211
- title="Desktop View"
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="15" y1="3" x2="15" y2="21" />
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
- (document.getElementById("btn-save") as any)?.click()
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
- Publish Changes
1646
+ {isNew ? "Create" : hasUnsavedChanges ? "Save Draft" : "Saved"}
1235
1647
  </button>
1236
1648
 
1237
- <button
1238
- type="button"
1239
- className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
1240
- >
1241
- <svg
1242
- width="20"
1243
- height="20"
1244
- viewBox="0 0 24 24"
1245
- fill="none"
1246
- stroke="currentColor"
1247
- strokeWidth="3"
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
- <circle cx="12" cy="12" r="1.5" fill="currentColor" />
1250
- <circle cx="12" cy="5" r="1.5" fill="currentColor" />
1251
- <circle cx="12" cy="19" r="1.5" fill="currentColor" />
1252
- </svg>
1253
- </button>
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 ${showPreview ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 lg:grid-cols-[1fr_380px]"}`}
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
- Sidebar Settings
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 space-y-6 animate-in fade-in slide-in-from-bottom-4 px-8 pb-12">
1328
- <div className="surface-tile p-8">
1329
- <h2 className="text-2xl font-black mb-2">Version History</h2>
1330
- <p className="text-[11px] text-[var(--kyro-text-secondary)] opacity-50 mb-8 uppercase tracking-widest font-bold">
1331
- Snapshots are created automatically every time you save changes.
1332
- </p>
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-20">
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
- <p className="opacity-50 font-medium py-12 text-center italic">
1340
- No previous versions found for this document.
1341
- </p>
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="space-y-4">
1344
- {versions.map((v, i) => (
1345
- <div
1346
- key={v.id}
1347
- className="p-6 rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] flex items-center justify-between group hover:border-[var(--kyro-primary)] transition-all duration-300"
1348
- >
1349
- <div className="flex items-center gap-6">
1350
- <div className="w-12 h-12 rounded-2xl bg-[var(--kyro-bg-secondary)] flex items-center justify-center border border-[var(--kyro-border)]">
1351
- <span className="text-[10px] font-black opacity-40">
1352
- v{versions.length - i}
1353
- </span>
1354
- </div>
1355
- <div>
1356
- <div className="flex items-center gap-3 mb-1">
1357
- <span className="text-sm font-black text-[var(--kyro-text-primary)]">
1358
- {new Date(v.createdAt).toLocaleString("en-US", {
1359
- month: "short",
1360
- day: "numeric",
1361
- year: "numeric",
1362
- hour: "2-digit",
1363
- minute: "2-digit",
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
- <p className="text-[11px] text-[var(--kyro-text-secondary)] font-medium italic opacity-60">
1368
- System captured snapshot
1369
- </p>
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
- <div className="flex items-center gap-3">
1373
- <button
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
- </div>
1512
- );
1513
- }
1514
-
1515
- interface UploadFieldProps {
1516
- field: any;
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
- onDragLeave={() => setIsDragging(false)}
1574
- onDrop={handleDrop}
1575
- onClick={() => inputRef.current?.click()}
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
- <input
1578
- ref={inputRef}
1579
- type="file"
1580
- className="kyro-form-upload-input"
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
  }