@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.
- package/dist/index.cjs +24 -24
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +19 -19
- package/package.json +1 -1
- package/src/components/ActionBar.tsx +35 -5
- package/src/components/AutoForm.tsx +54 -20
- package/src/components/DetailView.tsx +41 -0
- package/src/components/FieldRenderer.tsx +11 -0
- package/src/components/MediaGallery.tsx +65 -51
- package/src/components/WebhookManager.tsx +3 -3
- package/src/components/autoform/AutoFormHeader.tsx +1 -2
- package/src/components/fields/AccordionField.tsx +54 -3
- package/src/components/fields/BlocksField.tsx +105 -19
- package/src/components/fields/GroupLayout.tsx +54 -4
- package/src/components/fields/IconField.tsx +79 -0
- package/src/components/fields/TabsLayout.tsx +62 -11
- package/src/components/fields/UploadField.tsx +24 -26
- 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/SplitButton.tsx +5 -5
- package/src/components/ui/icons.tsx +1 -1
- package/src/lib/autoform-store.ts +9 -3
- package/src/lib/deep-equal.ts +73 -0
|
@@ -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-
|
|
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-
|
|
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)]
|
|
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 (
|
|
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 (
|
|
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
|
|
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=
|
|
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
|
|
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
|
-
<
|
|
659
|
-
<
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
<ChevronDown className={`w-
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
missingIds.map(id => apiGet<any>(`/api/media/${id}`))
|
|
119
123
|
);
|
|
120
124
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 = (
|
|
305
|
-
if (!
|
|
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,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
|
);
|