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