@kyro-cms/admin 0.12.1 → 0.12.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.1",
3
+ "version": "0.12.4",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -128,4 +128,4 @@
128
128
  "type": "git",
129
129
  "url": "https://github.com/danielDozie/kyro-cms"
130
130
  }
131
- }
131
+ }
@@ -568,7 +568,7 @@ export function AutoForm({
568
568
  const result = await response.json();
569
569
  const savedData = result.data || data;
570
570
  setFormData({ ...formData, ...savedData });
571
- setLastSavedData({ ...formData, ...savedData });
571
+ markSaved();
572
572
  lastAutoSaveTimeRef.current = Date.now();
573
573
  setAutoSaveStatus("success");
574
574
  setLocalSaveStatus("saved");
@@ -631,7 +631,7 @@ export function AutoForm({
631
631
  const result = await response.json();
632
632
  const savedData = result.data || data;
633
633
  setFormData({ ...formData, ...savedData });
634
- setLastSavedData({ ...formData, ...savedData });
634
+ markSaved();
635
635
  dataToPublish = { ...formData, ...savedData };
636
636
  }
637
637
 
@@ -640,8 +640,17 @@ export function AutoForm({
640
640
  const response = await saveDocument(data, false);
641
641
 
642
642
  if (response?.ok) {
643
+ const result = await response.json().catch(() => ({}));
644
+ const savedData = result.data || data;
645
+ setFormData({ ...formData, ...savedData });
646
+ markSaved();
643
647
  setLocalSaveStatus("saved");
644
648
  onActionSuccess?.("Published successfully");
649
+
650
+ setTimeout(() => {
651
+ setLocalSaveStatus("idle");
652
+ }, 2000);
653
+
645
654
  if (isNewDoc && !globalSlug && dataToPublish.id) {
646
655
  setTimeout(() => {
647
656
  window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
@@ -10,6 +10,7 @@ import { ActionBar, type DocumentStatus, type SaveStatus } from "./ActionBar";
10
10
  import { Spinner } from "./ui/Spinner";
11
11
  import { Shimmer } from "./ui/Shimmer";
12
12
  import { useUIStore, toast } from "../lib/stores";
13
+ import { useAutoFormStore } from "../lib/autoform-store";
13
14
  import { PageHeader } from "./ui/PageHeader";
14
15
  import { Badge } from "./ui/Badge";
15
16
  import { SplitButton } from "./ui/SplitButton";
@@ -53,6 +54,10 @@ export function DetailView({
53
54
  const [updatedAt, setUpdatedAt] = useState<string | null>(null);
54
55
  const [publishedAt, setPublishedAt] = useState<string | null>(null);
55
56
  const [justSaved, setJustSaved] = useState(false);
57
+ const showPreview = useAutoFormStore((state) => state.showPreview);
58
+ const setShowPreview = useAutoFormStore((state) => state.setShowPreview);
59
+ const previewUrl = useAutoFormStore((state) => state.previewUrl);
60
+ const setPreviewUrl = useAutoFormStore((state) => state.setPreviewUrl);
56
61
 
57
62
  const fields = global?.fields || collection?.fields || [];
58
63
  const label = global?.label || collection?.label || "Document";
@@ -162,7 +167,12 @@ export function DetailView({
162
167
  setTimeout(() => {
163
168
  setSaveStatus("idle");
164
169
  }, 2000);
165
- } catch {
170
+
171
+ if (showPreview) {
172
+ refreshPreviewUrl(savedData);
173
+ }
174
+
175
+ } catch (e: any) {
166
176
  setSaveStatus("error");
167
177
  if (!isAutosave) {
168
178
  onError("Failed to save changes");
@@ -284,7 +294,37 @@ export function DetailView({
284
294
  }
285
295
  });
286
296
  };
297
+ const refreshPreviewUrl = async (docData: any = data) => {
298
+ try {
299
+ const endpoint = mode === "global" ? `/api/globals/${slug}/preview-url` : `/api/${slug}/preview-url`;
300
+ console.log("[Kyro Preview] Calling endpoint:", endpoint, "with data keys:", Object.keys(docData || {}), "documentId:", documentId);
301
+ const res = await apiPost(endpoint, { ...docData, id: documentId }, { autoToast: false });
302
+ console.log("[Kyro Preview] Response:", JSON.stringify(res));
303
+ if (res && (res as any).url) {
304
+ console.log("[Kyro Preview] Setting previewUrl:", (res as any).url);
305
+ setPreviewUrl((res as any).url);
306
+ } else {
307
+ console.warn("[Kyro Preview] No url in response:", res);
308
+ }
309
+ } catch (e: any) {
310
+ console.error("[Kyro Preview] Error:", e.message, e);
311
+ toast.error(e.message || "Failed to generate preview URL");
312
+ }
313
+ };
314
+
315
+ useEffect(() => {
316
+ if (showPreview) {
317
+ refreshPreviewUrl(data);
318
+ }
319
+ }, [showPreview]);
287
320
 
321
+ const togglePreview = () => {
322
+ const nextState = !showPreview;
323
+ setShowPreview(nextState);
324
+ if (nextState) {
325
+ refreshPreviewUrl(data);
326
+ }
327
+ };
288
328
  if (loading) {
289
329
  return (
290
330
  <div className="kyro-detail">
@@ -344,9 +384,7 @@ export function DetailView({
344
384
  onViewHistory={() => {
345
385
  window.dispatchEvent(new CustomEvent('kyro:show-version-history'));
346
386
  }}
347
- onPreview={() =>
348
- window.open(`/preview/${slug}/${documentId}`, "_blank")
349
- }
387
+ onPreview={togglePreview}
350
388
  onDelete={handleDeleteTrigger}
351
389
  onBack={onBack}
352
390
  onToggleSidebar={() => window.dispatchEvent(new CustomEvent("toggle-sidebar"))}
@@ -356,12 +394,14 @@ export function DetailView({
356
394
 
357
395
  <div
358
396
  className={
359
- isSingleLayout
360
- ? "w-full pt-4 md:pt-8"
361
- : "w-full mx-auto grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-4 md:gap-8 pt-4 md:pt-0"
397
+ showPreview
398
+ ? "w-full mx-auto grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 pt-4 md:pt-0 h-[calc(100vh-140px)]"
399
+ : isSingleLayout
400
+ ? "w-full pt-4 md:pt-8"
401
+ : "w-full mx-auto grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-4 md:gap-8 pt-4 md:pt-0"
362
402
  }
363
403
  >
364
- <div className="space-y-4 md:space-y-8 min-w-0">
404
+ <div className={`space-y-4 md:space-y-8 min-w-0 ${showPreview ? "overflow-y-auto pr-2 pb-20" : ""}`}>
365
405
  <div className="surface-tile p-4 md:p-8">
366
406
  <div className="flex items-center justify-between mb-8 px-1">
367
407
  <h2 className="text-[10px] font-bold tracking-[0.2em] opacity-40">
@@ -409,7 +449,37 @@ export function DetailView({
409
449
  </div>
410
450
  </div>
411
451
 
412
- {!isSingleLayout && (
452
+ {showPreview && (
453
+ <div className="surface-tile flex flex-col overflow-hidden h-full border border-[var(--kyro-border)] rounded-xl animate-in fade-in slide-in-from-right-4 duration-500 shadow-xl bg-white dark:bg-black">
454
+ <div className="bg-[var(--kyro-surface-accent)] border-b border-[var(--kyro-border)] p-2 flex items-center justify-between shrink-0">
455
+ <div className="flex items-center gap-2 pl-2">
456
+ <div className="w-3 h-3 rounded-full bg-red-400/80"></div>
457
+ <div className="w-3 h-3 rounded-full bg-amber-400/80"></div>
458
+ <div className="w-3 h-3 rounded-full bg-green-400/80"></div>
459
+ </div>
460
+ <div className="text-[10px] font-mono opacity-50 truncate max-w-[60%] bg-[var(--kyro-bg)] px-3 py-1 rounded">
461
+ {previewUrl || "Generating preview URL..."}
462
+ </div>
463
+ <div className="w-16"></div>
464
+ </div>
465
+ <div className="flex-1 bg-white relative">
466
+ {!previewUrl ? (
467
+ <div className="absolute inset-0 flex items-center justify-center">
468
+ <Spinner className="w-6 h-6 text-[var(--kyro-primary)]" />
469
+ </div>
470
+ ) : (
471
+ <iframe
472
+ src={previewUrl}
473
+ className="w-full h-full border-0 bg-white"
474
+ title="Preview"
475
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
476
+ />
477
+ )}
478
+ </div>
479
+ </div>
480
+ )}
481
+
482
+ {!isSingleLayout && !showPreview && (
413
483
  <div className="space-y-4 md:space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
414
484
  <div className="surface-tile p-4 md:p-8">
415
485
  <h3 className="text-[10px] font-bold tracking-[0.2em] opacity-40 mb-4 md:mb-6">
@@ -483,13 +553,15 @@ export function DetailView({
483
553
  >
484
554
  {isDuplicating ? "Duplicating..." : "Duplicate Document"}
485
555
  </button>
486
- <button
487
- type="button"
488
- onClick={() => window.open(`/preview/${slug}/${documentId}`, "_blank")}
489
- className="kyro-btn kyro-btn-sm kyro-btn-ghost w-full justify-start"
490
- >
491
- View Public Page
492
- </button>
556
+ {previewUrl && (
557
+ <button
558
+ type="button"
559
+ onClick={() => window.open(previewUrl, "_blank")}
560
+ className="kyro-btn kyro-btn-sm kyro-btn-ghost w-full justify-start"
561
+ >
562
+ Open Preview in New Tab
563
+ </button>
564
+ )}
493
565
  <button
494
566
  type="button"
495
567
  onClick={handleDeleteTrigger}
@@ -17,6 +17,8 @@ import {
17
17
  ExternalLink,
18
18
  Zap,
19
19
  Shield,
20
+ Activity,
21
+ XCircle,
20
22
  } from "./ui/icons";
21
23
  import { useUIStore, toast } from "../lib/stores";
22
24
  import { Modal, ModalContent, ModalActions } from "./ui/Modal";
@@ -52,14 +54,25 @@ interface WebhookItem {
52
54
  action?: string;
53
55
  config?: Record<string, string>;
54
56
  lastTriggered?: string;
57
+ lastError?: string;
58
+ createdAt: string;
59
+ }
60
+
61
+ interface WebhookDelivery {
62
+ id: string;
63
+ webhookId: string;
64
+ event: string;
65
+ status: "pending" | "success" | "failed" | "retrying";
66
+ responseStatus?: number;
67
+ responseBody?: string;
68
+ error?: string;
69
+ duration?: number;
55
70
  createdAt: string;
56
71
  }
57
72
 
58
73
  const ACTION_ICONS: Record<string, string> = {
59
74
  generic: "🔗",
60
- "github-dispatch": "⚙️",
61
- "dokploy-deploy": "🚀",
62
- "coolify-deploy": "🎯",
75
+ "github-push": "⚙️",
63
76
  };
64
77
 
65
78
  export function WebhookManager() {
@@ -97,6 +110,11 @@ export function WebhookManager() {
97
110
  });
98
111
  const [createError, setCreateError] = useState("");
99
112
 
113
+ const [showHistoryModal, setShowHistoryModal] = useState(false);
114
+ const [selectedWebhookId, setSelectedWebhookId] = useState<string | null>(null);
115
+ const [deliveryHistory, setDeliveryHistory] = useState<WebhookDelivery[]>([]);
116
+ const [loadingHistory, setLoadingHistory] = useState(false);
117
+
100
118
  useEffect(() => {
101
119
  loadActions();
102
120
  }, []);
@@ -168,9 +186,10 @@ export function WebhookManager() {
168
186
  await create(formData);
169
187
  resetCreateModal();
170
188
  toast.success(`Webhook created: ${formData.name}`);
171
- } catch (e) {
172
- setCreateError("Failed to create webhook");
173
- toast.error("Failed to create webhook");
189
+ } catch (e: any) {
190
+ const errorMsg = e.message || "Failed to create webhook";
191
+ setCreateError(errorMsg);
192
+ toast.error(errorMsg);
174
193
  }
175
194
  };
176
195
 
@@ -204,6 +223,21 @@ export function WebhookManager() {
204
223
  }
205
224
  };
206
225
 
226
+ const handleViewHistory = async (id: string) => {
227
+ setSelectedWebhookId(id);
228
+ setShowHistoryModal(true);
229
+ setLoadingHistory(true);
230
+ try {
231
+ const data = await apiGet<{ docs: WebhookDelivery[] }>(`/api/webhooks/${id}/history`);
232
+ setDeliveryHistory(data.docs || []);
233
+ } catch (e) {
234
+ console.error("Failed to load history:", e);
235
+ toast.error("Failed to load delivery history");
236
+ } finally {
237
+ setLoadingHistory(false);
238
+ }
239
+ };
240
+
207
241
  const eventOptions = [
208
242
  {
209
243
  label: "Create",
@@ -359,11 +393,29 @@ export function WebhookManager() {
359
393
  ? `Last triggered ${new Date(webhook.lastTriggered).toLocaleDateString()}`
360
394
  : "Never triggered"}
361
395
  </div>
396
+ {webhook.lastError && (
397
+ <div className="text-[10px] text-red-500/80 flex items-center gap-1.5 mt-1">
398
+ <XCircle className="w-3 h-3" />
399
+ <span className="truncate max-w-[150px]" title={webhook.lastError}>
400
+ {webhook.lastError}
401
+ </span>
402
+ </div>
403
+ )}
362
404
  </div>
363
405
  </div>
364
406
  </div>
365
407
 
366
408
  <div className="flex items-center gap-1.5 lg:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
409
+ <button
410
+ type="button"
411
+ onClick={() => handleViewHistory(webhook.id)}
412
+ className="p-2 rounded-lg border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] transition-colors flex items-center gap-1.5"
413
+ title="View delivery history"
414
+ >
415
+ <Activity className="w-3.5 h-3.5" />
416
+ <span className="text-[11px] font-medium hidden sm:inline">History</span>
417
+ </button>
418
+
367
419
  <button
368
420
  type="button"
369
421
  onClick={() => handleTest(webhook.id)}
@@ -418,7 +470,7 @@ export function WebhookManager() {
418
470
  type="button"
419
471
  key={key}
420
472
  onClick={() => handleActionSelect(key)}
421
- className="flex flex-col items-start p-4 rounded-xl border border-[var(--kyro-border)] bg-[var(--kyro-surface)] hover:border-[var(--kyro-primary)]/40 transition-all text-left group"
473
+ className="flex flex-col items-start p-4 rounded-md border border-[var(--kyro-border)] bg-[var(--kyro-surface)] hover:border-[var(--kyro-primary)]/40 transition-all text-left group"
422
474
  >
423
475
  <div className="text-lg mb-2">{ACTION_ICONS[key] || "🔗"}</div>
424
476
  <h3 className="text-sm font-medium mb-0.5 group-hover:text-[var(--kyro-primary)] transition-colors">
@@ -680,6 +732,81 @@ export function WebhookManager() {
680
732
  </button>
681
733
  </ModalActions>
682
734
  </Modal>
735
+
736
+ {/* History Modal */}
737
+ <Modal
738
+ open={showHistoryModal}
739
+ onClose={() => setShowHistoryModal(false)}
740
+ title="Delivery History"
741
+ >
742
+ <ModalContent>
743
+ <div className="space-y-4">
744
+ {loadingHistory ? (
745
+ <div className="flex items-center justify-center p-12 opacity-50">
746
+ <RefreshCw className="w-5 h-5 animate-spin text-[var(--kyro-primary)]" />
747
+ </div>
748
+ ) : deliveryHistory.length === 0 ? (
749
+ <div className="text-center p-8 text-[var(--kyro-text-secondary)] opacity-60 text-sm">
750
+ No delivery history available for this webhook.
751
+ </div>
752
+ ) : (
753
+ <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
754
+ {deliveryHistory.map((delivery) => (
755
+ <div key={delivery.id} className="p-4 rounded-md border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-sm">
756
+ <div className="flex items-center justify-between mb-2">
757
+ <div className="flex items-center gap-2">
758
+ {delivery.status === "success" ? (
759
+ <CheckCircle2 className="w-4 h-4 text-green-500" />
760
+ ) : (
761
+ <XCircle className="w-4 h-4 text-red-500" />
762
+ )}
763
+ <span className="font-medium">{delivery.event}</span>
764
+ </div>
765
+ <span className="text-[10px] text-[var(--kyro-text-secondary)] opacity-50">
766
+ {new Date(delivery.createdAt).toLocaleString()}
767
+ </span>
768
+ </div>
769
+
770
+ <div className="grid grid-cols-2 gap-4 text-xs text-[var(--kyro-text-secondary)] mb-3">
771
+ <div>
772
+ <span className="opacity-50">Status: </span>
773
+ <span className={`font-medium ${delivery.status === "success" ? "text-green-500" : "text-red-500"}`}>
774
+ {delivery.responseStatus || delivery.status}
775
+ </span>
776
+ </div>
777
+ <div>
778
+ <span className="opacity-50">Duration: </span>
779
+ {delivery.duration ? `${delivery.duration}ms` : "-"}
780
+ </div>
781
+ </div>
782
+
783
+ {delivery.error && (
784
+ <div className="text-xs text-red-500 bg-red-500/5 p-2 rounded border border-red-500/10 font-mono break-all mb-2">
785
+ {delivery.error}
786
+ </div>
787
+ )}
788
+
789
+ {delivery.responseBody && (
790
+ <div className="text-[10px] font-mono bg-[var(--kyro-bg)] p-2 rounded border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] opacity-70 max-h-24 overflow-y-auto break-all">
791
+ {delivery.responseBody}
792
+ </div>
793
+ )}
794
+ </div>
795
+ ))}
796
+ </div>
797
+ )}
798
+ </div>
799
+ </ModalContent>
800
+ <ModalActions>
801
+ <button
802
+ type="button"
803
+ onClick={() => setShowHistoryModal(false)}
804
+ className="w-full py-2.5 rounded-lg text-sm font-medium bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] hover:bg-[var(--kyro-surface)] transition-colors"
805
+ >
806
+ Close
807
+ </button>
808
+ </ModalActions>
809
+ </Modal>
683
810
  </div>
684
811
  );
685
812
  }
@@ -15,7 +15,7 @@ export function AutoFormEditView({
15
15
  collectionSlug,
16
16
  renderField,
17
17
  }: AutoFormEditViewProps) {
18
- const { showPreview, sidebarCollapsed, formData } = useAutoFormStore();
18
+ const { showPreview, sidebarCollapsed, formData, previewUrl } = useAutoFormStore();
19
19
 
20
20
  if (layout === "single") {
21
21
  return (
@@ -65,7 +65,7 @@ export function AutoFormEditView({
65
65
  </span>
66
66
  </div>
67
67
  <iframe
68
- src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
68
+ src={previewUrl || ""}
69
69
  className="w-full h-full border-none"
70
70
  title="Live Preview"
71
71
  />
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { ChevronDown, ChevronUp, Plus, X } from "../ui/icons";
1
+ import React, { useState, useCallback } from "react";
2
+ import { ChevronDown, ChevronUp, Plus, X, Copy, ClipboardPaste, Check } from "../ui/icons";
3
3
 
4
4
  interface AccordionItem {
5
5
  title: string;
@@ -17,7 +17,56 @@ export const AccordionField: React.FC<AccordionFieldProps> = ({
17
17
  onChange,
18
18
  compact = false,
19
19
  }) => {
20
- const [openIndex, setOpenIndex] = React.useState<number | null>(0);
20
+ const [openIndex, setOpenIndex] = useState<number | null>(0);
21
+ const [copied, setCopied] = useState(false);
22
+
23
+ const handleCopy = useCallback(async (e: React.MouseEvent) => {
24
+ e.preventDefault();
25
+ e.stopPropagation();
26
+ try {
27
+ const payload = JSON.stringify({ __kyro_accordion: true, items });
28
+ await navigator.clipboard.writeText(payload);
29
+ setCopied(true);
30
+ setTimeout(() => setCopied(false), 2000);
31
+ } catch (err) {
32
+ console.error("Failed to copy", err);
33
+ }
34
+ }, [items]);
35
+
36
+ const handlePaste = useCallback(async (e: React.MouseEvent) => {
37
+ e.preventDefault();
38
+ e.stopPropagation();
39
+ try {
40
+ const text = await navigator.clipboard.readText();
41
+ const parsed = JSON.parse(text);
42
+ if (parsed && parsed.__kyro_accordion && Array.isArray(parsed.items)) {
43
+ onChange([...items, ...parsed.items]);
44
+ }
45
+ } catch (err) {
46
+ console.error("Failed to paste", err);
47
+ }
48
+ }, [items, onChange]);
49
+
50
+ const headerControls = (
51
+ <div className="flex justify-end gap-1 mb-2">
52
+ <button
53
+ type="button"
54
+ onClick={handleCopy}
55
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
56
+ title="Copy Items"
57
+ >
58
+ {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
59
+ </button>
60
+ <button
61
+ type="button"
62
+ onClick={handlePaste}
63
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
64
+ title="Paste Items"
65
+ >
66
+ <ClipboardPaste className="w-3.5 h-3.5" />
67
+ </button>
68
+ </div>
69
+ );
21
70
 
22
71
  const handleTitleChange = (index: number, value: string) => {
23
72
  const newItems = [...items];
@@ -52,6 +101,7 @@ export const AccordionField: React.FC<AccordionFieldProps> = ({
52
101
  if (compact) {
53
102
  return (
54
103
  <div className="space-y-2">
104
+ {headerControls}
55
105
  {items.length === 0 ? (
56
106
  <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
57
107
  No items. Click "Add Item" to create one.
@@ -134,6 +184,7 @@ export const AccordionField: React.FC<AccordionFieldProps> = ({
134
184
 
135
185
  return (
136
186
  <div className="space-y-2">
187
+ {headerControls}
137
188
  {items.length === 0 ? (
138
189
  <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
139
190
  No items. Click "Add Item" to create one.
@@ -14,6 +14,8 @@ import {
14
14
  import { Check, ClipboardCopy } from "lucide-react";
15
15
  import { GenericBlock } from "../blocks/GenericBlock";
16
16
  import { BlockEditModal } from "../blocks/BlockEditModal";
17
+ import { deepEqual } from "../../lib/deep-equal";
18
+ import { toast } from "../../lib/stores";
17
19
  import {
18
20
 
19
21
  DndContext,
@@ -499,7 +501,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
499
501
  const lastValueArray = lastValueRef.current || [];
500
502
 
501
503
  // Deep compare to catch external data changes (e.g. discard draft / auto-save restore)
502
- if (JSON.stringify(valueArray) !== JSON.stringify(lastValueArray)) {
504
+ if (!deepEqual(valueArray, lastValueArray)) {
503
505
  const valueArrayCopy = [...valueArray];
504
506
  prevBlocksLengthRef.current = valueArrayCopy.length;
505
507
  prevBlockIdsRef.current = new Set(valueArrayCopy.map((b: Record<string, unknown>) => b.id));
@@ -521,7 +523,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
521
523
  if (!lastValue) return; // Wait until initialized
522
524
 
523
525
  // Deep compare blocks vs lastValue to detect content edits, not just ID changes
524
- if (JSON.stringify(blocks) !== JSON.stringify(lastValue)) {
526
+ if (!deepEqual(blocks, lastValue)) {
525
527
  lastValueRef.current = [...blocks]; // Update ref BEFORE firing onChange to prevent loops
526
528
  onChangeRef.current(blocks);
527
529
  }
@@ -569,6 +571,33 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
569
571
  setIsDrawerOpen(false);
570
572
  }, [field.blocks, blocks, store]);
571
573
 
574
+ const handleContainerPaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
575
+ const activeEl = document.activeElement;
576
+ if (
577
+ activeEl &&
578
+ (activeEl.tagName === "INPUT" ||
579
+ activeEl.tagName === "TEXTAREA" ||
580
+ activeEl.getAttribute("contenteditable") === "true")
581
+ ) {
582
+ return;
583
+ }
584
+
585
+ const text = e.clipboardData?.getData("text");
586
+ if (!text) return;
587
+
588
+ try {
589
+ const parsed = JSON.parse(text);
590
+ if (parsed && parsed.__kyro_block) {
591
+ e.preventDefault();
592
+ e.stopPropagation();
593
+ handlePasteBlock(parsed);
594
+ toast.success(`Block pasted: ${parsed.type}`);
595
+ }
596
+ } catch {
597
+ // Ignore
598
+ }
599
+ }, [handlePasteBlock]);
600
+
572
601
  const duplicateBlock = useCallback(
573
602
  (blockId: string) => {
574
603
  const blockIndex = blocks.findIndex((b) => b.id === blockId);
@@ -689,7 +718,12 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
689
718
 
690
719
  return (
691
720
  <BlocksContext.Provider value={storeRef.current}>
692
- <div className="kyro-blocks-field">
721
+ <div
722
+ className="kyro-blocks-field"
723
+ onPaste={handleContainerPaste}
724
+ tabIndex={0}
725
+ style={{ outline: "none" }}
726
+ >
693
727
  <DndContext
694
728
  sensors={sensors}
695
729
  collisionDetection={closestCenter}
@@ -1,4 +1,6 @@
1
- import React from "react";
1
+ import React, { useState, useCallback } from "react";
2
+ import type { Field } from "@kyro-cms/core/client";
3
+ import { Copy, ClipboardPaste, Check } from "../ui/icons";
2
4
  import type { Field } from "@kyro-cms/core/client";
3
5
 
4
6
  interface GroupLayoutProps {
@@ -19,12 +21,60 @@ export function GroupLayout({
19
21
  renderField,
20
22
  }: GroupLayoutProps) {
21
23
  const groupData = value || {};
24
+ const [copied, setCopied] = useState(false);
25
+
26
+ const handleCopy = useCallback(async (e: React.MouseEvent) => {
27
+ e.preventDefault();
28
+ e.stopPropagation();
29
+ try {
30
+ const payload = JSON.stringify({ __kyro_container: true, value: groupData });
31
+ await navigator.clipboard.writeText(payload);
32
+ setCopied(true);
33
+ setTimeout(() => setCopied(false), 2000);
34
+ } catch (err) {
35
+ console.error("Failed to copy", err);
36
+ }
37
+ }, [groupData]);
38
+
39
+ const handlePaste = useCallback(async (e: React.MouseEvent) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ try {
43
+ const text = await navigator.clipboard.readText();
44
+ const parsed = JSON.parse(text);
45
+ if (parsed && (parsed.__kyro_group || parsed.__kyro_container) && parsed.value) {
46
+ onChange({ ...groupData, ...parsed.value });
47
+ }
48
+ } catch (err) {
49
+ console.error("Failed to paste", err);
50
+ }
51
+ }, [groupData, onChange]);
22
52
 
23
53
  return (
24
54
  <div className="kyro-form-group border border-[var(--kyro-border)] rounded-[var(--kyro-radius-lg)] p-6 bg-[var(--kyro-surface-accent)]/30">
25
- <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
26
- {field.label || field.name}
27
- </h3>
55
+ <div className="flex items-center justify-between mb-6 border-b border-[var(--kyro-border)] pb-2">
56
+ <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)]">
57
+ {field.label || field.name}
58
+ </h3>
59
+ <div className="flex items-center gap-1">
60
+ <button
61
+ type="button"
62
+ onClick={handleCopy}
63
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
64
+ title="Copy Group Data"
65
+ >
66
+ {copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
67
+ </button>
68
+ <button
69
+ type="button"
70
+ onClick={handlePaste}
71
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
72
+ title="Paste Group Data"
73
+ >
74
+ <ClipboardPaste className="w-4 h-4" />
75
+ </button>
76
+ </div>
77
+ </div>
28
78
  <div className={field.admin?.inline ? "flex items-start gap-4" : "space-y-6"}>
29
79
  {(field as Field & { fields?: Field[] }).fields.map((f: Field) =>
30
80
  renderField(f, groupData, onChange),