@kyro-cms/admin 0.11.6 → 0.12.1

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 (32) hide show
  1. package/dist/index.cjs +25 -25
  2. package/dist/index.d.cts +4 -2
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +20 -20
  5. package/package.json +1 -1
  6. package/src/components/ActionBar.tsx +35 -5
  7. package/src/components/AutoForm.tsx +97 -40
  8. package/src/components/DetailView.tsx +41 -0
  9. package/src/components/FieldRenderer.tsx +11 -0
  10. package/src/components/GraphQLPlayground.tsx +259 -12
  11. package/src/components/MediaGallery.tsx +65 -51
  12. package/src/components/WebhookManager.tsx +338 -188
  13. package/src/components/autoform/AutoFormHeader.tsx +19 -9
  14. package/src/components/autoform/ErrorBoundary.tsx +93 -0
  15. package/src/components/blocks/HeadingSubheadingBlock.tsx +2 -2
  16. package/src/components/blocks/HeroBlock.tsx +2 -2
  17. package/src/components/fields/BlocksField.tsx +69 -17
  18. package/src/components/fields/HeadingSubheadingField.tsx +2 -2
  19. package/src/components/fields/HeroField.tsx +4 -4
  20. package/src/components/fields/IconField.tsx +79 -0
  21. package/src/components/fields/RelationshipField.tsx +2 -2
  22. package/src/components/fields/UploadField.tsx +7 -5
  23. package/src/components/fields/extensions/blocksStore.ts +1 -1
  24. package/src/components/fields/index.ts +1 -0
  25. package/src/components/ui/BlockDrawer.tsx +39 -0
  26. package/src/components/ui/IconPickerModal.tsx +80 -0
  27. package/src/components/ui/ImageFocalEditor.tsx +112 -71
  28. package/src/components/ui/Modal.tsx +4 -2
  29. package/src/components/ui/icons.tsx +1 -1
  30. package/src/hooks/useAutoFormState.ts +51 -1
  31. package/src/integration.ts +4 -4
  32. package/src/pages/settings/[slug].astro +1 -1
@@ -20,7 +20,9 @@ interface AutoFormHeaderProps {
20
20
  handleUnpublish: () => void;
21
21
  handleDelete: () => void;
22
22
  handlePublish: () => void;
23
+ handleSaveDraft: () => void;
23
24
  handleSchedulePublish: (scheduledFor: string) => void;
25
+ handleConflictOverride?: () => void;
24
26
  }
25
27
 
26
28
  export function AutoFormHeader({
@@ -35,7 +37,9 @@ export function AutoFormHeader({
35
37
  handleUnpublish,
36
38
  handleDelete,
37
39
  handlePublish,
40
+ handleSaveDraft,
38
41
  handleSchedulePublish,
42
+ handleConflictOverride,
39
43
  }: AutoFormHeaderProps) {
40
44
  const [now, setNow] = useState(Date.now());
41
45
  const [showSchedulePicker, setShowSchedulePicker] = useState(false);
@@ -76,7 +80,7 @@ export function AutoFormHeader({
76
80
  }, [showSchedulePicker]);
77
81
 
78
82
  const docTitle = String(
79
- (formData.mainTabs as { title?: string })?.title ||
83
+ (formData.tabs as { title?: string })?.title ||
80
84
  (typeof formData.title === "object" ? "" : formData.title) ||
81
85
  (typeof formData.name === "object" ? "" : formData.name) ||
82
86
  "Untitled",
@@ -165,13 +169,7 @@ export function AutoFormHeader({
165
169
  <span className="opacity-30">—</span>
166
170
  <button
167
171
  type="button"
168
- onClick={async () => {
169
- // To keep changes during conflict, we'd fire the prop or a store action.
170
- // For now, reload handles the other side. Since this is in header, we'll
171
- // trigger a window reload or a saveDocument via context if needed.
172
- // It's cleaner to handle this conflict directly if passed as a prop,
173
- // but let's just trigger a reload for simplicity if they want the server version.
174
- }}
172
+ onClick={() => handleConflictOverride?.()}
175
173
  className="text-[var(--kyro-primary)] hover:underline"
176
174
  >
177
175
  Keep my changes
@@ -207,6 +205,18 @@ export function AutoFormHeader({
207
205
  }
208
206
  direction="down"
209
207
  >
208
+ <DropdownItem
209
+ onClick={handleSaveDraft}
210
+ icon={
211
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
212
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
213
+ <polyline points="17 21 17 13 7 13 7 21" />
214
+ <polyline points="7 3 7 8 15 8" />
215
+ </svg>
216
+ }
217
+ >
218
+ Save Draft
219
+ </DropdownItem>
210
220
  {!globalSlug && (
211
221
  <DropdownItem
212
222
  onClick={handleCreateNew}
@@ -316,7 +326,7 @@ export function AutoFormHeader({
316
326
  return (
317
327
  <>
318
328
  {/* MOBILE HEADER */}
319
- <header className="md:hidden border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] backdrop-blur-md rounded-lg">
329
+ <header className="md:hidden border-b border-[var(--kyro-border)] z-50 bg-[var(--kyro-surface)] backdrop-blur-md rounded-lg">
320
330
  <div className="flex items-center gap-2 px-3 py-2.5">
321
331
  <a
322
332
  href={`${ADMIN_BASE}/${collectionSlug}`}
@@ -0,0 +1,93 @@
1
+ import React, { Component, type ReactNode } from "react";
2
+ import { AlertTriangle, RefreshCw, Copy } from "../ui/icons";
3
+ import { useAutoFormStore } from "../../lib/autoform-store";
4
+ import { toast } from "../../lib/stores";
5
+
6
+ interface Props {
7
+ children: ReactNode;
8
+ fallbackTitle?: string;
9
+ }
10
+
11
+ interface State {
12
+ hasError: boolean;
13
+ error: Error | null;
14
+ errorInfo: string | null;
15
+ }
16
+
17
+ export class ErrorBoundary extends Component<Props, State> {
18
+ constructor(props: Props) {
19
+ super(props);
20
+ this.state = { hasError: false, error: null, errorInfo: null };
21
+ }
22
+
23
+ static getDerivedStateFromError(error: Error): Partial<State> {
24
+ return { hasError: true, error };
25
+ }
26
+
27
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
28
+ console.error("[ErrorBoundary] Caught:", error, errorInfo);
29
+ this.setState({ errorInfo: errorInfo.componentStack || null });
30
+ }
31
+
32
+ handleCopyToClipboard = () => {
33
+ const { error, errorInfo } = this.state;
34
+ const formData = useAutoFormStore.getState().formData;
35
+ const payload = {
36
+ error: error?.message,
37
+ stack: error?.stack,
38
+ componentStack: errorInfo,
39
+ formData,
40
+ timestamp: new Date().toISOString(),
41
+ };
42
+ navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
43
+ toast.success("Crash details copied to clipboard");
44
+ });
45
+ };
46
+
47
+ handleReload = () => {
48
+ window.location.reload();
49
+ };
50
+
51
+ render() {
52
+ if (this.state.hasError) {
53
+ return (
54
+ <div className="flex flex-col items-center justify-center gap-4 p-16">
55
+ <div className="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center">
56
+ <AlertTriangle className="w-8 h-8 text-red-500" />
57
+ </div>
58
+ <h3 className="text-lg font-bold text-[var(--kyro-text-primary)]">
59
+ {this.props.fallbackTitle || "Something went wrong"}
60
+ </h3>
61
+ <p className="text-sm text-[var(--kyro-text-secondary)] text-center max-w-md">
62
+ The form encountered an unexpected error. Your unsaved changes may still be recoverable.
63
+ </p>
64
+ {this.state.error && (
65
+ <p className="text-xs font-mono text-red-500/70 bg-red-500/5 px-4 py-2 rounded-lg max-w-full overflow-auto">
66
+ {this.state.error.message}
67
+ </p>
68
+ )}
69
+ <div className="flex items-center gap-3 mt-2">
70
+ <button
71
+ type="button"
72
+ onClick={this.handleReload}
73
+ className="flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl border border-[var(--kyro-border)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
74
+ >
75
+ <RefreshCw className="w-4 h-4" />
76
+ Reload Page
77
+ </button>
78
+ <button
79
+ type="button"
80
+ onClick={this.handleCopyToClipboard}
81
+ className="flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl bg-[var(--kyro-primary)]/10 text-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)]/20 transition-colors"
82
+ >
83
+ <Copy className="w-4 h-4" />
84
+ Copy Details
85
+ </button>
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ return this.props.children;
92
+ }
93
+ }
@@ -22,8 +22,8 @@ export const HeadingSubheadingBlock: React.FC<{ block: Record<string, unknown>;
22
22
  return (
23
23
  <BlockWrapper id={block.id} type="heading-subheading" label="Heading + Subheading">
24
24
  <HeadingSubheadingField
25
- heading={data.heading || ""}
26
- subheading={data.subheading || ""}
25
+ heading={data.title || ""}
26
+ subheading={data.subtitle || ""}
27
27
  onChange={handleChange}
28
28
  compact
29
29
  />
@@ -60,8 +60,8 @@ export const HeroBlock: React.FC<{ block: Record<string, unknown>; index: number
60
60
 
61
61
  <div className="space-y-3">
62
62
  <HeroField
63
- heading={data.heading || ""}
64
- subheading={data.subheading || ""}
63
+ heading={data.title || ""}
64
+ subheading={data.subtitle || ""}
65
65
  ctaText={data.ctaText || ""}
66
66
  ctaUrl={data.ctaUrl || ""}
67
67
  onChange={handleChange}
@@ -11,6 +11,7 @@ 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";
16
17
  import {
@@ -58,7 +59,7 @@ function getBlockPreviewSnippet(
58
59
  }
59
60
  }
60
61
  }
61
- return data.heading || data.title || data.text || data.name || data.label || data.sectionTitle || "";
62
+ return data.title || data.text || data.name || data.label || "";
62
63
  }
63
64
 
64
65
  // Sortable block wrapper for drag-and-drop
@@ -94,6 +95,20 @@ const SortableBlockComponent = ({
94
95
  const [editingName, setEditingName] = useState(false);
95
96
  const [nameDraft, setNameDraft] = useState((block.name as string) || "");
96
97
  const nameInputRef = useRef<HTMLInputElement>(null);
98
+ const [copied, setCopied] = useState(false);
99
+
100
+ const copyToClipboard = useCallback(async (e: React.MouseEvent) => {
101
+ e.stopPropagation();
102
+ try {
103
+ const { id, ...blockProps } = block;
104
+ const payload = JSON.stringify({ __kyro_block: true, type: block.type, blockProps });
105
+ await navigator.clipboard.writeText(payload);
106
+ setCopied(true);
107
+ setTimeout(() => setCopied(false), 2000);
108
+ } catch (err) {
109
+ console.error("Failed to copy block", err);
110
+ }
111
+ }, [block]);
97
112
 
98
113
  useEffect(() => {
99
114
  if (editingName && nameInputRef.current) {
@@ -123,16 +138,16 @@ const SortableBlockComponent = ({
123
138
 
124
139
  if (compact) {
125
140
  return (
126
- <div ref={setNodeRef} style={style} className="relative group">
141
+ <div ref={setNodeRef} style={style} className="relative group w-full">
127
142
  <div
128
143
  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
144
+ 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
145
  ? `${(blockTheme[block.type as string] || blockTheme.default).border} bg-[var(--kyro-primary)]/5`
131
146
  : "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]/50 hover:bg-[var(--kyro-primary)]/5"
132
147
  }`}
133
148
  >
134
149
  <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"
150
+ 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
151
  {...attributes}
137
152
  {...listeners}
138
153
  onClick={(e) => e.stopPropagation()}
@@ -158,7 +173,7 @@ const SortableBlockComponent = ({
158
173
  />
159
174
  ) : (
160
175
  <span
161
- className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px] transition-colors"
176
+ className="font-medium text-[var(--kyro-text-secondary)] flex-1 min-w-0 truncate transition-colors text-left"
162
177
  >
163
178
  {itemLabel}
164
179
  </span>
@@ -205,10 +220,18 @@ const SortableBlockComponent = ({
205
220
  onDuplicate(block.id as string);
206
221
  }}
207
222
  className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
208
- title="Duplicate"
223
+ title="Duplicate in place"
209
224
  >
210
225
  <Copy className="w-3 h-3" />
211
226
  </button>
227
+ <button
228
+ type="button"
229
+ onClick={copyToClipboard}
230
+ className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
231
+ title="Copy Block to Clipboard"
232
+ >
233
+ {copied ? <Check className="w-3 h-3 text-green-500" /> : <ClipboardCopy className="w-3 h-3" />}
234
+ </button>
212
235
  <button
213
236
  type="button"
214
237
  onClick={(e) => {
@@ -344,6 +367,14 @@ const SortableBlockComponent = ({
344
367
  >
345
368
  <Copy className="w-3.5 h-3.5" />
346
369
  </button>
370
+ <button
371
+ type="button"
372
+ onClick={copyToClipboard}
373
+ className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
374
+ title="Copy Block to Clipboard"
375
+ >
376
+ {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <ClipboardCopy className="w-3.5 h-3.5" />}
377
+ </button>
347
378
  <button
348
379
  type="button"
349
380
  onClick={(e) => {
@@ -518,6 +549,26 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
518
549
  [store],
519
550
  );
520
551
 
552
+ const handlePasteBlock = useCallback((parsedData: any) => {
553
+ const allowedBlocks = (field.blocks as Array<{ slug: string }>) || [];
554
+ const isAllowed = allowedBlocks.some((b) => b.slug === parsedData.type);
555
+
556
+ if (!isAllowed) {
557
+ alert(`The block type "${parsedData.type}" is not allowed in this collection.`);
558
+ return;
559
+ }
560
+
561
+ const newId = Math.random().toString(36).substring(2, 11);
562
+ const newBlock = {
563
+ ...(parsedData.blockProps || {}),
564
+ id: newId,
565
+ type: parsedData.type,
566
+ };
567
+
568
+ store.getState().setBlocks([...blocks, newBlock]);
569
+ setIsDrawerOpen(false);
570
+ }, [field.blocks, blocks, store]);
571
+
521
572
  const duplicateBlock = useCallback(
522
573
  (blockId: string) => {
523
574
  const blockIndex = blocks.findIndex((b) => b.id === blockId);
@@ -645,25 +696,25 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
645
696
  onDragStart={handleDragStart}
646
697
  onDragEnd={handleDragEnd}
647
698
  >
648
- <div className="flex items-center justify-between mb-2">
649
- <label className="kyro-form-label">{field.label || field.name}</label>
699
+ <div className={pickerMode === "dropdown" ? "mb-4" : "flex items-center justify-between mb-2"}>
700
+ <label className={`kyro-form-label ${pickerMode === "dropdown" ? "block mb-2" : ""}`}>{field.label || field.name}</label>
650
701
  {pickerMode === "dropdown" ? (
651
- <div ref={dropdownRef} className="relative">
702
+ <div ref={dropdownRef} className="relative w-full">
652
703
  <button
653
704
  type="button"
654
705
  onClick={() => setIsDropdownOpen(!isDropdownOpen)}
655
706
  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"
707
+ 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
708
  >
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" : ""}`} />
709
+ <div className="flex items-center gap-2">
710
+ <Plus className="w-4 h-4 text-[var(--kyro-primary)]" />
711
+ <span className="font-semibold">Select an element to add...</span>
712
+ </div>
713
+ <ChevronDown className={`w-4 h-4 transition-transform ${isDropdownOpen ? "rotate-180 text-[var(--kyro-primary)]" : "opacity-50"}`} />
663
714
  </button>
664
715
 
665
716
  {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">
717
+ <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
718
  {dynamicCategories.map((category) => (
668
719
  <div key={category.title}>
669
720
  {dynamicCategories.length > 1 && (
@@ -715,6 +766,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
715
766
  open={isDrawerOpen}
716
767
  onClose={() => setIsDrawerOpen(false)}
717
768
  onSelect={handleAddBlock}
769
+ onPasteBlock={handlePasteBlock}
718
770
  >
719
771
  <div className="space-y-4">
720
772
  {dynamicCategories.map((category) => (
@@ -755,7 +807,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
755
807
  items={blocks.map((b) => b.id)}
756
808
  strategy={verticalListSortingStrategy}
757
809
  >
758
- <div className={pickerMode === "dropdown" ? "flex flex-wrap gap-1.5" : "space-y-4"}>
810
+ <div className={pickerMode === "dropdown" ? "flex flex-col gap-2 mt-3" : "space-y-4"}>
759
811
  {blocks.map((block, index) => {
760
812
  const blockSchema = (field.blocks as any[])?.find(
761
813
  (b) => b.slug === block.type
@@ -26,13 +26,13 @@ export const HeadingSubheadingField: React.FC<HeadingSubheadingFieldProps> = ({
26
26
  <input
27
27
  type="text"
28
28
  value={heading}
29
- onChange={(e) => onChange("heading", e.target.value)}
29
+ onChange={(e) => onChange("title", e.target.value)}
30
30
  className={`${inputClass} font-bold text-base`}
31
31
  placeholder="Heading..."
32
32
  />
33
33
  <textarea
34
34
  value={subheading}
35
- onChange={(e) => onChange("subheading", e.target.value)}
35
+ onChange={(e) => onChange("subtitle", e.target.value)}
36
36
  className={textareaClass}
37
37
  placeholder="Subheading..."
38
38
  />
@@ -31,13 +31,13 @@ export const HeroField: React.FC<HeroFieldProps> = ({
31
31
  <input
32
32
  type="text"
33
33
  value={heading}
34
- onChange={(e) => onChange("heading", e.target.value)}
34
+ onChange={(e) => onChange("title", e.target.value)}
35
35
  className={`${inputClass} font-bold text-base`}
36
36
  placeholder="Hero heading..."
37
37
  />
38
38
  <textarea
39
39
  value={subheading}
40
- onChange={(e) => onChange("subheading", e.target.value)}
40
+ onChange={(e) => onChange("subtitle", e.target.value)}
41
41
  className={textareaClass}
42
42
  placeholder="Hero subheading..."
43
43
  />
@@ -67,13 +67,13 @@ export const HeroField: React.FC<HeroFieldProps> = ({
67
67
  <input
68
68
  type="text"
69
69
  value={heading}
70
- onChange={(e) => onChange("heading", e.target.value)}
70
+ onChange={(e) => onChange("title", e.target.value)}
71
71
  className={`${inputClass} font-bold text-base`}
72
72
  placeholder="Hero heading..."
73
73
  />
74
74
  <textarea
75
75
  value={subheading}
76
- onChange={(e) => onChange("subheading", e.target.value)}
76
+ onChange={(e) => onChange("subtitle", e.target.value)}
77
77
  className={textareaClass}
78
78
  placeholder="Hero subheading..."
79
79
  />
@@ -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
+ }
@@ -43,11 +43,11 @@ interface ResolvedDoc {
43
43
  }
44
44
 
45
45
  function getLabel(opt: Record<string, unknown>): string {
46
- const mainTabs = opt?.mainTabs as Record<string, unknown> | undefined;
46
+ const tabs = opt?.tabs as Record<string, unknown> | undefined;
47
47
 
48
48
  return (
49
49
  (opt?.title as string) ||
50
- (mainTabs?.title as string) ||
50
+ (tabs?.title as string) ||
51
51
  (opt?.name as string) ||
52
52
  (opt?.label as string) ||
53
53
  (opt?.email as string) ||
@@ -186,12 +186,14 @@ export function UploadField({
186
186
  formData.append("folder", selectedFolder);
187
187
  }
188
188
  const result = await apiUpload<any>("/api/media/upload", formData);
189
+ const data = result.data || result.doc || result;
189
190
  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,
191
+ ...data,
192
+ id: data.id,
193
+ filename: data.filename,
194
+ originalName: data.originalName ?? file.name,
195
+ url: data.url,
196
+ mimeType: data.mimeType || file.type,
195
197
  };
196
198
  if (isMultiple) {
197
199
  onChange([...currentValue, newImage]);
@@ -157,7 +157,7 @@ export function createNewBlock(type: string): BlockData {
157
157
  function getDefaultData(type: string): Record<string, unknown> {
158
158
  const defaults: Record<string, unknown> = {
159
159
  heading: { level: 1, text: "" },
160
- "heading-subheading": { heading: "", subheading: "" },
160
+ "heading-subheading": { title: "", subtitle: "" },
161
161
  hero: { isMultiScreen: false },
162
162
  card: { title: "", description: "", icon: "", link: "", linkText: "", isMultiCard: false },
163
163
  paragraph: { text: "" },
@@ -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
  );