@kyro-cms/admin 0.12.0 → 0.12.3

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.
@@ -11,8 +11,11 @@ import {
11
11
  getBlockLabel,
12
12
  blockTheme,
13
13
  } from "./extensions/blockComponents";
14
+ import { Check, ClipboardCopy } from "lucide-react";
14
15
  import { GenericBlock } from "../blocks/GenericBlock";
15
16
  import { BlockEditModal } from "../blocks/BlockEditModal";
17
+ import { deepEqual } from "../../lib/deep-equal";
18
+ import { toast } from "../../lib/stores";
16
19
  import {
17
20
 
18
21
  DndContext,
@@ -94,6 +97,20 @@ const SortableBlockComponent = ({
94
97
  const [editingName, setEditingName] = useState(false);
95
98
  const [nameDraft, setNameDraft] = useState((block.name as string) || "");
96
99
  const nameInputRef = useRef<HTMLInputElement>(null);
100
+ const [copied, setCopied] = useState(false);
101
+
102
+ const copyToClipboard = useCallback(async (e: React.MouseEvent) => {
103
+ e.stopPropagation();
104
+ try {
105
+ const { id, ...blockProps } = block;
106
+ const payload = JSON.stringify({ __kyro_block: true, type: block.type, blockProps });
107
+ await navigator.clipboard.writeText(payload);
108
+ setCopied(true);
109
+ setTimeout(() => setCopied(false), 2000);
110
+ } catch (err) {
111
+ console.error("Failed to copy block", err);
112
+ }
113
+ }, [block]);
97
114
 
98
115
  useEffect(() => {
99
116
  if (editingName && nameInputRef.current) {
@@ -123,16 +140,16 @@ const SortableBlockComponent = ({
123
140
 
124
141
  if (compact) {
125
142
  return (
126
- <div ref={setNodeRef} style={style} className="relative group">
143
+ <div ref={setNodeRef} style={style} className="relative group w-full">
127
144
  <div
128
145
  onClick={() => setEditingBlockId(block.id as string)}
129
- className={`flex items-center gap-1 pl-5 pr-1.5 py-1 bg-[var(--kyro-bg-secondary)] rounded-md border transition-colors cursor-pointer text-xs whitespace-nowrap ${isEditing
146
+ className={`flex items-center gap-2 pl-7 pr-2 py-2 w-full bg-[var(--kyro-bg-secondary)] rounded-md border transition-colors cursor-pointer text-sm ${isEditing
130
147
  ? `${(blockTheme[block.type as string] || blockTheme.default).border} bg-[var(--kyro-primary)]/5`
131
148
  : "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]/50 hover:bg-[var(--kyro-primary)]/5"
132
149
  }`}
133
150
  >
134
151
  <div
135
- className="absolute left-0.5 top-1/2 -translate-y-1/2 p-0.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--kyro-surface-accent)] rounded touch-none"
152
+ className="absolute left-1.5 top-1/2 -translate-y-1/2 p-0.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--kyro-surface-accent)] rounded touch-none"
136
153
  {...attributes}
137
154
  {...listeners}
138
155
  onClick={(e) => e.stopPropagation()}
@@ -158,7 +175,7 @@ const SortableBlockComponent = ({
158
175
  />
159
176
  ) : (
160
177
  <span
161
- className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px] transition-colors"
178
+ className="font-medium text-[var(--kyro-text-secondary)] flex-1 min-w-0 truncate transition-colors text-left"
162
179
  >
163
180
  {itemLabel}
164
181
  </span>
@@ -205,10 +222,18 @@ const SortableBlockComponent = ({
205
222
  onDuplicate(block.id as string);
206
223
  }}
207
224
  className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
208
- title="Duplicate"
225
+ title="Duplicate in place"
209
226
  >
210
227
  <Copy className="w-3 h-3" />
211
228
  </button>
229
+ <button
230
+ type="button"
231
+ onClick={copyToClipboard}
232
+ className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
233
+ title="Copy Block to Clipboard"
234
+ >
235
+ {copied ? <Check className="w-3 h-3 text-green-500" /> : <ClipboardCopy className="w-3 h-3" />}
236
+ </button>
212
237
  <button
213
238
  type="button"
214
239
  onClick={(e) => {
@@ -344,6 +369,14 @@ const SortableBlockComponent = ({
344
369
  >
345
370
  <Copy className="w-3.5 h-3.5" />
346
371
  </button>
372
+ <button
373
+ type="button"
374
+ onClick={copyToClipboard}
375
+ className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
376
+ title="Copy Block to Clipboard"
377
+ >
378
+ {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <ClipboardCopy className="w-3.5 h-3.5" />}
379
+ </button>
347
380
  <button
348
381
  type="button"
349
382
  onClick={(e) => {
@@ -468,7 +501,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
468
501
  const lastValueArray = lastValueRef.current || [];
469
502
 
470
503
  // Deep compare to catch external data changes (e.g. discard draft / auto-save restore)
471
- if (JSON.stringify(valueArray) !== JSON.stringify(lastValueArray)) {
504
+ if (!deepEqual(valueArray, lastValueArray)) {
472
505
  const valueArrayCopy = [...valueArray];
473
506
  prevBlocksLengthRef.current = valueArrayCopy.length;
474
507
  prevBlockIdsRef.current = new Set(valueArrayCopy.map((b: Record<string, unknown>) => b.id));
@@ -490,7 +523,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
490
523
  if (!lastValue) return; // Wait until initialized
491
524
 
492
525
  // Deep compare blocks vs lastValue to detect content edits, not just ID changes
493
- if (JSON.stringify(blocks) !== JSON.stringify(lastValue)) {
526
+ if (!deepEqual(blocks, lastValue)) {
494
527
  lastValueRef.current = [...blocks]; // Update ref BEFORE firing onChange to prevent loops
495
528
  onChangeRef.current(blocks);
496
529
  }
@@ -518,6 +551,53 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
518
551
  [store],
519
552
  );
520
553
 
554
+ const handlePasteBlock = useCallback((parsedData: any) => {
555
+ const allowedBlocks = (field.blocks as Array<{ slug: string }>) || [];
556
+ const isAllowed = allowedBlocks.some((b) => b.slug === parsedData.type);
557
+
558
+ if (!isAllowed) {
559
+ alert(`The block type "${parsedData.type}" is not allowed in this collection.`);
560
+ return;
561
+ }
562
+
563
+ const newId = Math.random().toString(36).substring(2, 11);
564
+ const newBlock = {
565
+ ...(parsedData.blockProps || {}),
566
+ id: newId,
567
+ type: parsedData.type,
568
+ };
569
+
570
+ store.getState().setBlocks([...blocks, newBlock]);
571
+ setIsDrawerOpen(false);
572
+ }, [field.blocks, blocks, store]);
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
+
521
601
  const duplicateBlock = useCallback(
522
602
  (blockId: string) => {
523
603
  const blockIndex = blocks.findIndex((b) => b.id === blockId);
@@ -638,32 +718,37 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
638
718
 
639
719
  return (
640
720
  <BlocksContext.Provider value={storeRef.current}>
641
- <div className="kyro-blocks-field">
721
+ <div
722
+ className="kyro-blocks-field"
723
+ onPaste={handleContainerPaste}
724
+ tabIndex={0}
725
+ style={{ outline: "none" }}
726
+ >
642
727
  <DndContext
643
728
  sensors={sensors}
644
729
  collisionDetection={closestCenter}
645
730
  onDragStart={handleDragStart}
646
731
  onDragEnd={handleDragEnd}
647
732
  >
648
- <div className="flex items-center justify-between mb-2">
649
- <label className="kyro-form-label">{field.label || field.name}</label>
733
+ <div className={pickerMode === "dropdown" ? "mb-4" : "flex items-center justify-between mb-2"}>
734
+ <label className={`kyro-form-label ${pickerMode === "dropdown" ? "block mb-2" : ""}`}>{field.label || field.name}</label>
650
735
  {pickerMode === "dropdown" ? (
651
- <div ref={dropdownRef} className="relative">
736
+ <div ref={dropdownRef} className="relative w-full">
652
737
  <button
653
738
  type="button"
654
739
  onClick={() => setIsDropdownOpen(!isDropdownOpen)}
655
740
  disabled={disabled}
656
- className="flex items-center gap-1.5 px-3 py-2 text-sm text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)]/30 rounded-md transition-colors disabled:opacity-50 font-semibold"
741
+ className="flex w-full items-center justify-between px-3 py-2 text-sm text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] bg-[var(--kyro-surface)] rounded-md transition-colors disabled:opacity-50"
657
742
  >
658
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
659
- <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
660
- </svg>
661
- Add Element
662
- <ChevronDown className={`w-3.5 h-3.5 transition-transform ${isDropdownOpen ? "rotate-180" : ""}`} />
743
+ <div className="flex items-center gap-2">
744
+ <Plus className="w-4 h-4 text-[var(--kyro-primary)]" />
745
+ <span className="font-semibold">Select an element to add...</span>
746
+ </div>
747
+ <ChevronDown className={`w-4 h-4 transition-transform ${isDropdownOpen ? "rotate-180 text-[var(--kyro-primary)]" : "opacity-50"}`} />
663
748
  </button>
664
749
 
665
750
  {isDropdownOpen && (
666
- <div className="absolute right-0 top-full mt-1 w-56 bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 py-2 max-h-80 overflow-y-auto">
751
+ <div className="absolute right-0 top-full mt-1 w-full bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 py-2 max-h-80 overflow-y-auto">
667
752
  {dynamicCategories.map((category) => (
668
753
  <div key={category.title}>
669
754
  {dynamicCategories.length > 1 && (
@@ -715,6 +800,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
715
800
  open={isDrawerOpen}
716
801
  onClose={() => setIsDrawerOpen(false)}
717
802
  onSelect={handleAddBlock}
803
+ onPasteBlock={handlePasteBlock}
718
804
  >
719
805
  <div className="space-y-4">
720
806
  {dynamicCategories.map((category) => (
@@ -755,7 +841,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
755
841
  items={blocks.map((b) => b.id)}
756
842
  strategy={verticalListSortingStrategy}
757
843
  >
758
- <div className={pickerMode === "dropdown" ? "flex flex-wrap gap-1.5" : "space-y-4"}>
844
+ <div className={pickerMode === "dropdown" ? "flex flex-col gap-2 mt-3" : "space-y-4"}>
759
845
  {blocks.map((block, index) => {
760
846
  const blockSchema = (field.blocks as any[])?.find(
761
847
  (b) => b.slug === block.type
@@ -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),
@@ -0,0 +1,79 @@
1
+ import React, { useState, useMemo } from "react";
2
+ import type { IconField as IconFieldType } from "@kyro-cms/core/client";
3
+ import FieldLayout from "./FieldLayout";
4
+ import { IconPickerModal } from "../ui/IconPickerModal";
5
+ import * as LucideIcons from "lucide-react";
6
+
7
+ interface IconFieldComponentProps {
8
+ field: IconFieldType;
9
+ value?: string | null;
10
+ onChange?: (value: string) => void;
11
+ error?: string;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ export default function IconField({
16
+ field,
17
+ value,
18
+ onChange,
19
+ error,
20
+ disabled,
21
+ }: IconFieldComponentProps) {
22
+ const [pickerOpen, setPickerOpen] = useState(false);
23
+ const normalizedValue = value == null ? "" : String(value);
24
+
25
+ // Convert kebab-case value back to PascalCase to render the preview component
26
+ const pascalName = useMemo(() => {
27
+ if (!normalizedValue) return "";
28
+ return normalizedValue
29
+ .split("-")
30
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
31
+ .join("");
32
+ }, [normalizedValue]);
33
+
34
+ const IconComponent = (LucideIcons as any)[pascalName];
35
+ const isReadOnly = typeof field.admin?.readOnly === "function" ? false : Boolean(field.admin?.readOnly);
36
+
37
+ return (
38
+ <FieldLayout field={field} error={error}>
39
+ <div className="flex items-center gap-3">
40
+ <div className="flex-1 relative">
41
+ <input
42
+ id={field.name}
43
+ type="text"
44
+ value={normalizedValue}
45
+ onChange={(e) => onChange?.(e.target.value)}
46
+ placeholder={field.admin?.placeholder || "e.g., activity"}
47
+ disabled={disabled || isReadOnly}
48
+ required={field.required}
49
+ className={`kyro-form-input ${disabled || isReadOnly ? "opacity-70 bg-[var(--kyro-bg-secondary)] cursor-not-allowed" : ""}`}
50
+ />
51
+ </div>
52
+
53
+ <button
54
+ type="button"
55
+ onClick={() => setPickerOpen(true)}
56
+ disabled={disabled || isReadOnly}
57
+ className="flex items-center gap-2 h-10 px-4 shrink-0 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl text-sm font-bold text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary-alpha)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
58
+ >
59
+ {IconComponent ? (
60
+ <IconComponent className="w-5 h-5" strokeWidth={2} />
61
+ ) : (
62
+ <LucideIcons.Search className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
63
+ )}
64
+ Browse
65
+ </button>
66
+ </div>
67
+
68
+ {pickerOpen && (
69
+ <IconPickerModal
70
+ open={pickerOpen}
71
+ onClose={() => setPickerOpen(false)}
72
+ onSelect={(iconName) => {
73
+ onChange?.(iconName);
74
+ }}
75
+ />
76
+ )}
77
+ </FieldLayout>
78
+ );
79
+ }
@@ -1,6 +1,7 @@
1
- import React, { useState } from "react";
1
+ import React, { useState, useCallback } from "react";
2
2
  import type { Field } from "@kyro-cms/core/client";
3
3
  import { SeoPreview } from "../ui/SeoPreview";
4
+ import { Copy, ClipboardPaste, Check } from "../ui/icons";
4
5
 
5
6
  interface TabsLayoutProps {
6
7
  field: Field;
@@ -29,22 +30,72 @@ export function TabsLayout({
29
30
  ? (formData[field.name] as Record<string, unknown>) || {}
30
31
  : formData;
31
32
 
33
+ const [copied, setCopied] = useState(false);
34
+
35
+ const handleCopy = useCallback(async (e: React.MouseEvent) => {
36
+ e.preventDefault();
37
+ e.stopPropagation();
38
+ try {
39
+ const payload = JSON.stringify({ __kyro_container: true, value: tabData });
40
+ await navigator.clipboard.writeText(payload);
41
+ setCopied(true);
42
+ setTimeout(() => setCopied(false), 2000);
43
+ } catch (err) {
44
+ console.error("Failed to copy", err);
45
+ }
46
+ }, [tabData]);
47
+
48
+ const handlePaste = useCallback(async (e: React.MouseEvent) => {
49
+ e.preventDefault();
50
+ e.stopPropagation();
51
+ try {
52
+ const text = await navigator.clipboard.readText();
53
+ const parsed = JSON.parse(text);
54
+ if (parsed && (parsed.__kyro_group || parsed.__kyro_container) && parsed.value) {
55
+ onTabDataChange({ ...tabData, ...parsed.value });
56
+ }
57
+ } catch (err) {
58
+ console.error("Failed to paste", err);
59
+ }
60
+ }, [tabData, onTabDataChange]);
61
+
32
62
  return (
33
63
  <div className="space-y-8">
34
- <div className="flex items-center gap-2 border-b border-[var(--kyro-border)] mb-6 overflow-x-auto hide-scrollbar">
35
- {fieldTabs.map((tab: { label: string }, index: number) => (
64
+ <div className="flex items-center justify-between border-b border-[var(--kyro-border)] mb-6">
65
+ <div className="flex items-center gap-2 overflow-x-auto hide-scrollbar">
66
+ {fieldTabs.map((tab: { label: string }, index: number) => (
67
+ <button
68
+ key={index}
69
+ type="button"
70
+ className={`px-6 py-3 text-sm tracking-widest font-medium transition-all border-b-2 -mb-[1px] whitespace-nowrap ${
71
+ activeTab === index
72
+ ? "border-[var(--kyro-primary)] text-[var(--kyro-primary)]"
73
+ : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] opacity-60 hover:opacity-100"
74
+ }`}
75
+ onClick={() => setActiveTab(index)}
76
+ >
77
+ {tab.label}
78
+ </button>
79
+ ))}
80
+ </div>
81
+ <div className="flex items-center gap-1 pr-2">
36
82
  <button
37
- key={index}
38
83
  type="button"
39
- className={`px-6 py-3 text-sm tracking-widest font-medium transition-all border-b-2 -mb-[1px] whitespace-nowrap ${activeTab === index
40
- ? "border-[var(--kyro-primary)] text-[var(--kyro-primary)]"
41
- : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] opacity-60 hover:opacity-100"
42
- }`}
43
- onClick={() => setActiveTab(index)}
84
+ onClick={handleCopy}
85
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
86
+ title="Copy Tab Data"
44
87
  >
45
- {tab.label}
88
+ {copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
46
89
  </button>
47
- ))}
90
+ <button
91
+ type="button"
92
+ onClick={handlePaste}
93
+ className="p-1.5 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors rounded hover:bg-[var(--kyro-surface)]"
94
+ title="Paste Tab Data"
95
+ >
96
+ <ClipboardPaste className="w-4 h-4" />
97
+ </button>
98
+ </div>
48
99
  </div>
49
100
  <div className="space-y-6">
50
101
  {currentTab?.fields.map((f: Field) =>
@@ -96,6 +96,8 @@ export function UploadField({
96
96
  const currentValue = Array.isArray(value) ? value : value ? [value] : [];
97
97
  const canAddMore = currentValue.length < maxCount;
98
98
 
99
+ const [fetchedDetails, setFetchedDetails] = useState<Record<string, any>>({});
100
+
99
101
  useEffect(() => {
100
102
  const fetchMissingDetails = async () => {
101
103
  const idsToFetch = currentValue
@@ -111,37 +113,29 @@ export function UploadField({
111
113
  .map(item => item.id as string);
112
114
 
113
115
  const allIds = [...idsToFetch, ...objectIdsToFetch];
114
- if (allIds.length === 0) return;
116
+ const missingIds = allIds.filter(id => !fetchedDetails[id]);
117
+
118
+ if (missingIds.length === 0) return;
115
119
 
116
120
  try {
117
121
  const fetchedItems = await Promise.all(
118
- allIds.map(id => apiGet<any>(`/api/media/${id}`))
122
+ missingIds.map(id => apiGet<any>(`/api/media/${id}`))
119
123
  );
120
124
 
121
- const newItems = [...currentValue];
122
- fetchedItems.forEach(fetchedItem => {
123
- const id = fetchedItem.id as string;
124
- const strIndex = newItems.findIndex(item => item === id);
125
- if (strIndex !== -1) {
126
- newItems[strIndex] = fetchedItem;
127
- return;
128
- }
129
- const objIndex = newItems.findIndex(
130
- item => typeof item === 'object' && item !== null && (item as any).id === id
131
- );
132
- if (objIndex !== -1) {
133
- newItems[objIndex] = fetchedItem;
134
- }
125
+ setFetchedDetails(prev => {
126
+ const next = { ...prev };
127
+ fetchedItems.forEach(item => {
128
+ if (item && item.id) next[item.id] = item;
129
+ });
130
+ return next;
135
131
  });
136
-
137
- onChange(isMultiple ? newItems : newItems[0]);
138
132
  } catch (err) {
139
133
  console.error("Failed to fetch media details:", err);
140
134
  }
141
135
  };
142
136
 
143
137
  fetchMissingDetails();
144
- }, [value]);
138
+ }, [value, fetchedDetails]);
145
139
 
146
140
  useEffect(() => {
147
141
  if (showPicker) {
@@ -186,12 +180,14 @@ export function UploadField({
186
180
  formData.append("folder", selectedFolder);
187
181
  }
188
182
  const result = await apiUpload<any>("/api/media/upload", formData);
183
+ const data = result.data || result.doc || result;
189
184
  const newImage = {
190
- id: result.id,
191
- filename: result.filename,
192
- originalName: (result as any).originalName ?? file.name,
193
- url: result.url,
194
- mimeType: file.type,
185
+ ...data,
186
+ id: data.id,
187
+ filename: data.filename,
188
+ originalName: data.originalName ?? file.name,
189
+ url: data.url,
190
+ mimeType: data.mimeType || file.type,
195
191
  };
196
192
  if (isMultiple) {
197
193
  onChange([...currentValue, newImage]);
@@ -301,8 +297,10 @@ export function UploadField({
301
297
  );
302
298
  }
303
299
 
304
- const renderImagePreview = (img: any, index?: number) => {
305
- if (!img) return null;
300
+ const renderImagePreview = (rawImg: any, index?: number) => {
301
+ if (!rawImg) return null;
302
+ const id = typeof rawImg === 'string' ? rawImg : rawImg.id;
303
+ const img = fetchedDetails[id] || (typeof rawImg === 'object' ? rawImg : { id });
306
304
  const fileType = getFileType(img.mimeType, img.filename || img.url);
307
305
  const isImage = fileType === "image";
308
306
 
@@ -1,4 +1,5 @@
1
1
  export { default as RichTextField } from "./RichTextField";
2
+ export { default as IconField } from "./IconField";
2
3
  export { CodeField } from "./CodeField";
3
4
  export { JSONField } from "./JSONField";
4
5
  export { MarkdownField } from "./MarkdownField";
@@ -1,11 +1,13 @@
1
1
  import React, { type ReactNode } from "react";
2
2
  import { useDraggable } from "@dnd-kit/core";
3
3
  import { SlidePanel } from "./SlidePanel";
4
+ import { ClipboardPaste } from "lucide-react";
4
5
 
5
6
  interface BlockDrawerProps {
6
7
  open: boolean;
7
8
  onClose: () => void;
8
9
  onSelect: (blockType: string) => void;
10
+ onPasteBlock?: (blockData: any) => void;
9
11
  children?: ReactNode;
10
12
  }
11
13
 
@@ -13,8 +15,29 @@ export function BlockDrawer({
13
15
  open,
14
16
  onClose,
15
17
  onSelect,
18
+ onPasteBlock,
16
19
  children,
17
20
  }: BlockDrawerProps) {
21
+ const [pasteError, setPasteError] = React.useState<string | null>(null);
22
+
23
+ const handlePaste = async () => {
24
+ try {
25
+ setPasteError(null);
26
+ const text = await navigator.clipboard.readText();
27
+ const parsed = JSON.parse(text);
28
+ if (parsed.__kyro_block) {
29
+ if (onPasteBlock) {
30
+ onPasteBlock(parsed);
31
+ }
32
+ } else {
33
+ setPasteError("Clipboard does not contain a valid Kyro block.");
34
+ }
35
+ } catch (err) {
36
+ setPasteError("Failed to read block from clipboard.");
37
+ console.error(err);
38
+ }
39
+ };
40
+
18
41
  if (!open) return null;
19
42
 
20
43
  return (
@@ -22,6 +45,22 @@ export function BlockDrawer({
22
45
  <p className="text-sm text-[var(--kyro-text-muted)] mb-4">
23
46
  Drag blocks into the editor or click to insert
24
47
  </p>
48
+
49
+ {onPasteBlock && (
50
+ <div className="mb-6 pb-6 border-b border-[var(--kyro-border)]">
51
+ <button
52
+ onClick={handlePaste}
53
+ className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] text-sm font-semibold transition-all hover:bg-[var(--kyro-primary)]/5"
54
+ >
55
+ <ClipboardPaste className="w-4 h-4" />
56
+ Paste Block from Clipboard
57
+ </button>
58
+ {pasteError && (
59
+ <p className="text-xs text-red-500 mt-2 text-center font-medium">{pasteError}</p>
60
+ )}
61
+ </div>
62
+ )}
63
+
25
64
  {children}
26
65
  </SlidePanel>
27
66
  );