@kyro-cms/admin 0.12.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -128,4 +128,4 @@
128
128
  "type": "git",
129
129
  "url": "https://github.com/danielDozie/kyro-cms"
130
130
  }
131
- }
131
+ }
@@ -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
- } catch {
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
- isSingleLayout
360
- ? "w-full pt-4 md:pt-8"
361
- : "w-full mx-auto grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-4 md:gap-8 pt-4 md:pt-0"
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="space-y-4 md:space-y-8 min-w-0">
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
- {!isSingleLayout && (
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
- <button
487
- type="button"
488
- onClick={() => window.open(`/preview/${slug}/${documentId}`, "_blank")}
489
- className="kyro-btn kyro-btn-sm kyro-btn-ghost w-full justify-start"
490
- >
491
- View Public Page
492
- </button>
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-dispatch": "⚙️",
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
- setCreateError("Failed to create webhook");
173
- toast.error("Failed to create webhook");
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-xl border border-[var(--kyro-border)] bg-[var(--kyro-surface)] hover:border-[var(--kyro-primary)]/40 transition-all text-left group"
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={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
68
+ src={previewUrl || ""}
69
69
  className="w-full h-full border-none"
70
70
  title="Live Preview"
71
71
  />
@@ -96,13 +96,12 @@ function ActionsSlot({ actions }: { actions: NonNullable<PageHeaderProps["action
96
96
  type="button"
97
97
  onClick={act.onClick}
98
98
  disabled={act.disabled}
99
- className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm transition-all shadow-lg shadow-[var(--kyro-primary)]/10 ${
100
- act.variant === "outline"
101
- ? "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
102
- : act.variant === "ghost"
99
+ className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm transition-all ${act.variant === "outline"
100
+ ? "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
101
+ : act.variant === "ghost"
103
102
  ? "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] shadow-none"
104
103
  : "kyro-btn-primary hover:opacity-90"
105
- } ${act.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${act.className || ""}`}
104
+ } ${act.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${act.className || ""}`}
106
105
  >
107
106
  {act.icon && <act.icon className="w-4 h-4" />}
108
107
  {act.label}
@@ -120,7 +119,7 @@ function SingleAction({ action }: { action: NonNullable<PageHeaderProps["action"
120
119
  type="button"
121
120
  onClick={action.onClick}
122
121
  disabled={action.disabled}
123
- className={`kyro-btn kyro-btn-primary flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm hover:opacity-90 transition-all shadow-lg shadow-[var(--kyro-primary)]/10 w-full lg:w-auto justify-center ${action.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${action.className || ""}`}
122
+ className={`kyro-btn kyro-btn-primary flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm hover:opacity-90 transition-all w-full lg:w-auto justify-center ${action.disabled ? "opacity-50 cursor-wait pointer-events-none" : ""} ${action.className || ""}`}
124
123
  >
125
124
  {action.icon && <action.icon className="w-4 h-4" />}
126
125
  {action.label}
@@ -87,6 +87,7 @@ interface AutoFormStore {
87
87
  versions: Version[];
88
88
  loadingVersions: boolean;
89
89
  showPreview: boolean;
90
+ previewUrl: string | null;
90
91
  isMenuOpen: boolean;
91
92
  hasUnsavedChanges: boolean;
92
93
  loadingFields: Record<string, boolean>;
@@ -118,6 +119,7 @@ interface AutoFormStore {
118
119
  setVersions: (versions: Version[]) => void;
119
120
  setLoadingVersions: (loading: boolean) => void;
120
121
  setShowPreview: (show: boolean | ((prev: boolean) => boolean)) => void;
122
+ setPreviewUrl: (url: string | null) => void;
121
123
  setIsMenuOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
122
124
  setHasUnsavedChanges: (hasChanges: boolean) => void;
123
125
  setLoadingFields: (fields: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void;
@@ -187,6 +189,7 @@ export const useAutoFormStore = create<AutoFormStore>()(
187
189
  versions: [],
188
190
  loadingVersions: false,
189
191
  showPreview: false,
192
+ previewUrl: null,
190
193
  isMenuOpen: false,
191
194
  hasUnsavedChanges: false,
192
195
  loadingFields: {},
@@ -275,6 +278,7 @@ export const useAutoFormStore = create<AutoFormStore>()(
275
278
  showPreview:
276
279
  typeof show === "function" ? show(state.showPreview) : show,
277
280
  })),
281
+ setPreviewUrl: (previewUrl) => set({ previewUrl }),
278
282
  setIsMenuOpen: (open) =>
279
283
  set((state) => ({
280
284
  isMenuOpen: typeof open === "function" ? open(state.isMenuOpen) : open,