@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.
- package/dist/index.cjs +25 -25
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +20 -20
- package/package.json +1 -1
- package/src/components/ActionBar.tsx +35 -5
- package/src/components/AutoForm.tsx +97 -40
- package/src/components/DetailView.tsx +41 -0
- package/src/components/FieldRenderer.tsx +11 -0
- package/src/components/GraphQLPlayground.tsx +259 -12
- package/src/components/MediaGallery.tsx +65 -51
- package/src/components/WebhookManager.tsx +338 -188
- package/src/components/autoform/AutoFormHeader.tsx +19 -9
- package/src/components/autoform/ErrorBoundary.tsx +93 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +2 -2
- package/src/components/blocks/HeroBlock.tsx +2 -2
- package/src/components/fields/BlocksField.tsx +69 -17
- package/src/components/fields/HeadingSubheadingField.tsx +2 -2
- package/src/components/fields/HeroField.tsx +4 -4
- package/src/components/fields/IconField.tsx +79 -0
- package/src/components/fields/RelationshipField.tsx +2 -2
- package/src/components/fields/UploadField.tsx +7 -5
- package/src/components/fields/extensions/blocksStore.ts +1 -1
- package/src/components/fields/index.ts +1 -0
- package/src/components/ui/BlockDrawer.tsx +39 -0
- package/src/components/ui/IconPickerModal.tsx +80 -0
- package/src/components/ui/ImageFocalEditor.tsx +112 -71
- package/src/components/ui/Modal.tsx +4 -2
- package/src/components/ui/icons.tsx +1 -1
- package/src/hooks/useAutoFormState.ts +51 -1
- package/src/integration.ts +4 -4
- 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.
|
|
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={
|
|
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.
|
|
26
|
-
subheading={data.
|
|
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.
|
|
64
|
-
subheading={data.
|
|
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.
|
|
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-
|
|
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-
|
|
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)]
|
|
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=
|
|
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
|
|
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
|
-
<
|
|
659
|
-
<
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
<ChevronDown className={`w-
|
|
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-
|
|
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-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
46
|
+
const tabs = opt?.tabs as Record<string, unknown> | undefined;
|
|
47
47
|
|
|
48
48
|
return (
|
|
49
49
|
(opt?.title as string) ||
|
|
50
|
-
(
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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": {
|
|
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,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
|
);
|