@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.0",
3
+ "version": "0.12.3",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useRef, useEffect } from "react";
2
- import { IconPlus, IconSend, IconClock, IconArchive, IconUndo, IconCopy, IconEye, IconTrash2, IconMoreVertical } from "./ui/icons";
2
+ import { IconPlus, IconSend, IconClock, IconArchive, IconUndo, IconCopy, IconEye, IconTrash2, IconMoreVertical, ClipboardPaste, ClipboardCopy } from "./ui/icons";
3
3
  import { DropdownItem, DropdownSeparator } from "./ui/Dropdown";
4
4
  import { SplitButton } from "./ui/SplitButton";
5
5
  import { Spinner } from "./ui/Spinner";
@@ -23,6 +23,8 @@ export interface ActionBarProps {
23
23
  onToggleSidebar?: () => void;
24
24
  publishedAt?: string | null;
25
25
  updatedAt?: string | null;
26
+ onCopyData?: () => void;
27
+ onPasteData?: () => void;
26
28
  }
27
29
 
28
30
  const STATUS_DOT: Record<string, string> = {
@@ -54,6 +56,8 @@ export function ActionBar({
54
56
  onToggleSidebar,
55
57
  publishedAt,
56
58
  updatedAt,
59
+ onCopyData,
60
+ onPasteData,
57
61
  }: ActionBarProps) {
58
62
  const view = useAutoFormStore((s) => s.view) || "edit";
59
63
  const setView = useAutoFormStore((s) => s.setView);
@@ -130,6 +134,16 @@ export function ActionBar({
130
134
  <IconCopy className="w-3.5 h-3.5" />
131
135
  </button>
132
136
  )}
137
+ {onCopyData && (
138
+ <button type="button" onClick={onCopyData} className={`${iconBtnClass} max-md:hidden`} title="Copy Data">
139
+ <ClipboardCopy className="w-3.5 h-3.5" />
140
+ </button>
141
+ )}
142
+ {onPasteData && (
143
+ <button type="button" onClick={onPasteData} className={`${iconBtnClass} max-md:hidden`} title="Paste Data">
144
+ <ClipboardPaste className="w-3.5 h-3.5" />
145
+ </button>
146
+ )}
133
147
  {onDelete && (
134
148
  <button type="button" onClick={onDelete} className={`${iconBtnClass} hover:bg-[var(--kyro-danger-bg)] hover:text-[var(--kyro-danger)] max-md:hidden`} title="Delete">
135
149
  <IconTrash2 className="w-3.5 h-3.5" />
@@ -183,6 +197,16 @@ export function ActionBar({
183
197
  <IconCopy className="w-3.5 h-3.5" /> Duplicate
184
198
  </button>
185
199
  )}
200
+ {onCopyData && (
201
+ <button type="button" onClick={() => { onCopyData(); setMoreOpen(false); }} className="w-full flex items-center gap-2 px-3 py-2 text-xs text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors">
202
+ <ClipboardCopy className="w-3.5 h-3.5" /> Copy Data
203
+ </button>
204
+ )}
205
+ {onPasteData && (
206
+ <button type="button" onClick={() => { onPasteData(); setMoreOpen(false); }} className="w-full flex items-center gap-2 px-3 py-2 text-xs text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors">
207
+ <ClipboardPaste className="w-3.5 h-3.5" /> Paste Data
208
+ </button>
209
+ )}
186
210
  {onPreview && (
187
211
  <button type="button" onClick={() => { onPreview(); setMoreOpen(false); }} className="w-full flex items-center gap-2 px-3 py-2 text-xs text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors">
188
212
  <IconEye className="w-3.5 h-3.5" /> Preview
@@ -225,15 +249,21 @@ export function ActionBar({
225
249
  onPublish={onSave}
226
250
  >
227
251
  {onDuplicate && (
228
- <DropdownItem icon={<IconCopy className="w-4 h-4" />}>Duplicate</DropdownItem>
252
+ <DropdownItem icon={<IconCopy className="w-4 h-4" />} onClick={onDuplicate}>Duplicate</DropdownItem>
253
+ )}
254
+ {onCopyData && (
255
+ <DropdownItem icon={<ClipboardCopy className="w-4 h-4" />} onClick={onCopyData}>Copy Data</DropdownItem>
256
+ )}
257
+ {onPasteData && (
258
+ <DropdownItem icon={<ClipboardPaste className="w-4 h-4" />} onClick={onPasteData}>Paste Data</DropdownItem>
229
259
  )}
230
260
  {onViewHistory && (
231
- <DropdownItem icon={<IconClock className="w-4 h-4" />}>View History</DropdownItem>
261
+ <DropdownItem icon={<IconClock className="w-4 h-4" />} onClick={onViewHistory}>View History</DropdownItem>
232
262
  )}
233
263
  {onPreview && (
234
- <DropdownItem icon={<IconEye className="w-4 h-4" />}>Preview</DropdownItem>
264
+ <DropdownItem icon={<IconEye className="w-4 h-4" />} onClick={onPreview}>Preview</DropdownItem>
235
265
  )}
236
- {(onDuplicate || onViewHistory || onPreview) && <DropdownSeparator />}
266
+ {(onDuplicate || onCopyData || onPasteData || onViewHistory || onPreview) && <DropdownSeparator />}
237
267
  {onDelete && (
238
268
  <DropdownItem onClick={onDelete} danger icon={<IconTrash2 className="w-4 h-4" />}>Delete</DropdownItem>
239
269
  )}
@@ -62,8 +62,49 @@ interface AutoFormProps {
62
62
  documentStatus?: string;
63
63
  }
64
64
 
65
- const EMPTY_OBJECT = {};
65
+ function getNestedValue(obj: any, path: string): any {
66
+ if (!obj || typeof path !== "string") return undefined;
67
+ return path.split('.').reduce((acc, part) => (acc && acc[part] !== undefined ? acc[part] : undefined), obj);
68
+ }
69
+
70
+ function evaluateDeclarativeCondition(cond: any, currentData: any, formData: any): boolean {
71
+ if (!cond) return true;
72
+
73
+ if (Array.isArray(cond.and)) {
74
+ return cond.and.every((c: any) => evaluateDeclarativeCondition(c, currentData, formData));
75
+ }
76
+ if (Array.isArray(cond.or)) {
77
+ return cond.or.some((c: any) => evaluateDeclarativeCondition(c, currentData, formData));
78
+ }
79
+
80
+ if (cond.field) {
81
+ const targetField = cond.field;
82
+ let val = getNestedValue(currentData, targetField);
83
+ if (val === undefined) {
84
+ val = getNestedValue(formData, targetField);
85
+ }
86
+
87
+ if ("equals" in cond) {
88
+ return val === cond.equals;
89
+ }
90
+ if ("notEquals" in cond) {
91
+ return val !== cond.notEquals;
92
+ }
93
+ if ("in" in cond && Array.isArray(cond.in)) {
94
+ return cond.in.includes(val);
95
+ }
96
+ if ("greaterThan" in cond) {
97
+ return typeof val === "number" && val > cond.greaterThan;
98
+ }
99
+
100
+ // If field exists but no operator is provided, just check truthiness
101
+ return Boolean(val);
102
+ }
103
+
104
+ return true;
105
+ }
66
106
 
107
+ const EMPTY_OBJECT = {};
67
108
  export function AutoForm({
68
109
  config: propConfig,
69
110
  data: initialData = EMPTY_OBJECT,
@@ -527,7 +568,7 @@ export function AutoForm({
527
568
  const result = await response.json();
528
569
  const savedData = result.data || data;
529
570
  setFormData({ ...formData, ...savedData });
530
- setLastSavedData({ ...formData, ...savedData });
571
+ markSaved();
531
572
  lastAutoSaveTimeRef.current = Date.now();
532
573
  setAutoSaveStatus("success");
533
574
  setLocalSaveStatus("saved");
@@ -590,7 +631,7 @@ export function AutoForm({
590
631
  const result = await response.json();
591
632
  const savedData = result.data || data;
592
633
  setFormData({ ...formData, ...savedData });
593
- setLastSavedData({ ...formData, ...savedData });
634
+ markSaved();
594
635
  dataToPublish = { ...formData, ...savedData };
595
636
  }
596
637
 
@@ -599,8 +640,17 @@ export function AutoForm({
599
640
  const response = await saveDocument(data, false);
600
641
 
601
642
  if (response?.ok) {
643
+ const result = await response.json().catch(() => ({}));
644
+ const savedData = result.data || data;
645
+ setFormData({ ...formData, ...savedData });
646
+ markSaved();
602
647
  setLocalSaveStatus("saved");
603
648
  onActionSuccess?.("Published successfully");
649
+
650
+ setTimeout(() => {
651
+ setLocalSaveStatus("idle");
652
+ }, 2000);
653
+
604
654
  if (isNewDoc && !globalSlug && dataToPublish.id) {
605
655
  setTimeout(() => {
606
656
  window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
@@ -706,23 +756,7 @@ export function AutoForm({
706
756
  }
707
757
  } else if (typeof field.admin.condition === "object") {
708
758
  try {
709
- const cond = field.admin.condition as any;
710
- const targetField = cond.field;
711
-
712
- // Get target field value, prioritizing sibling context (currentData) then root context (formData)
713
- const val = (currentData && currentData[targetField] !== undefined)
714
- ? currentData[targetField]
715
- : (formData && formData[targetField] !== undefined ? formData[targetField] : undefined);
716
-
717
- let shouldShow = true;
718
- if ("equals" in cond) {
719
- shouldShow = val === cond.equals;
720
- } else if ("notEquals" in cond) {
721
- shouldShow = val !== cond.notEquals;
722
- } else if ("in" in cond && Array.isArray(cond.in)) {
723
- shouldShow = cond.in.includes(val);
724
- }
725
-
759
+ const shouldShow = evaluateDeclarativeCondition(field.admin.condition, currentData, formData);
726
760
  if (!shouldShow) {
727
761
  return null;
728
762
  }
@@ -229,6 +229,45 @@ export function DetailView({
229
229
  }
230
230
  };
231
231
 
232
+ const handleCopyData = async () => {
233
+ try {
234
+ const copyPayload = { ...data };
235
+ // Strip system fields
236
+ delete copyPayload.id;
237
+ delete copyPayload.createdAt;
238
+ delete copyPayload.updatedAt;
239
+ delete (copyPayload as any).status;
240
+
241
+ await navigator.clipboard.writeText(JSON.stringify(copyPayload));
242
+ toast.success("Document data copied to clipboard");
243
+ } catch (e) {
244
+ toast.error("Failed to copy document data");
245
+ }
246
+ };
247
+
248
+ const handlePasteData = async () => {
249
+ try {
250
+ const text = await navigator.clipboard.readText();
251
+ const pastedData = JSON.parse(text);
252
+ if (typeof pastedData !== 'object' || pastedData === null) {
253
+ throw new Error("Invalid format");
254
+ }
255
+
256
+ setData((prev) => ({
257
+ ...prev,
258
+ ...pastedData,
259
+ // Preserve system fields from overwrite
260
+ id: prev.id,
261
+ createdAt: prev.createdAt,
262
+ updatedAt: prev.updatedAt,
263
+ status: prev.status,
264
+ }));
265
+ toast.success("Document data pasted");
266
+ } catch (e) {
267
+ toast.error("Clipboard does not contain valid document JSON");
268
+ }
269
+ };
270
+
232
271
  const handleDeleteTrigger = () => {
233
272
  confirm({
234
273
  title: `Delete ${label}?`,
@@ -300,6 +339,8 @@ export function DetailView({
300
339
  onPublish={handlePublish}
301
340
  onUnpublish={status === "published" ? handleUnpublish : undefined}
302
341
  onDuplicate={handleDuplicate}
342
+ onCopyData={handleCopyData}
343
+ onPasteData={handlePasteData}
303
344
  onViewHistory={() => {
304
345
  window.dispatchEvent(new CustomEvent('kyro:show-version-history'));
305
346
  }}
@@ -8,6 +8,7 @@ import SelectField from "./fields/SelectField";
8
8
  import DateField from "./fields/DateField";
9
9
  import { MarkdownField } from "./fields/MarkdownField";
10
10
  import TextField from "./fields/TextField";
11
+ import IconField from "./fields/IconField";
11
12
  import { BlocksField } from "./fields/BlocksField";
12
13
  import { RichTextField } from "./fields";
13
14
  import { ListField } from "./fields/ListField";
@@ -62,6 +63,16 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
62
63
  disabled={disabled}
63
64
  />
64
65
  );
66
+ case "icon":
67
+ return (
68
+ <IconField
69
+ field={field as any}
70
+ value={value}
71
+ onChange={onChangeKeystroke}
72
+ error={error}
73
+ disabled={disabled}
74
+ />
75
+ );
65
76
  case "textarea":
66
77
  return (
67
78
  <TextField
@@ -70,6 +70,23 @@ function getAbsoluteUrl(relativeUrl: unknown): string {
70
70
  return `${window.location.origin}${sanitized}`;
71
71
  }
72
72
 
73
+ function getCroppedUrl(item: MediaItem, width?: number): string | null {
74
+ if (!item.metadata?.crop) return null;
75
+ const { x, y, width: cw, height: ch } = item.metadata.crop;
76
+ if (!cw || !ch) return null;
77
+ const base = getAbsoluteUrl(item.url);
78
+ if (!base) return null;
79
+ const params = new URLSearchParams({ url: item.url });
80
+ params.set("cx", String(x));
81
+ params.set("cy", String(y));
82
+ params.set("cw", String(cw));
83
+ params.set("ch", String(ch));
84
+ if (width) params.set("w", String(width));
85
+ const resizePath = `/api/media/resize?${params.toString()}`;
86
+ if (typeof window === "undefined") return resizePath;
87
+ return `${window.location.origin}${resizePath}`;
88
+ }
89
+
73
90
  type FilterType =
74
91
  | "all"
75
92
  | "image"
@@ -383,12 +400,12 @@ export function MediaGallery({
383
400
  crop: cropData,
384
401
  hotspot: hotspotData
385
402
  };
386
-
403
+
387
404
  const result = await apiPatch(`/api/media/${panelItem.id}`, { metadata });
388
405
  setItems(prev => prev.map(item => item.id === panelItem.id ? result.doc : item));
389
406
  setPanelItem(result.doc);
390
407
  setShowCrop(false);
391
- toast.success("Focal metadata saved");
408
+ toast.success("Crop & hotspot saved");
392
409
  } catch (err) {
393
410
  console.error("Save failed:", err);
394
411
  toast.error("Failed to save focal metadata");
@@ -417,71 +434,68 @@ export function MediaGallery({
417
434
  })}
418
435
  >
419
436
  {/* Top Bar */}
420
- <div className={`flex flex-col lg:flex-row lg:items-center justify-between gap-6 border-b border-[var(--kyro-border)] backdrop-blur-md sticky top-0 ${pickerMode ? "p-2" : "p-6 rounded-xl surface-tile"}`}>
437
+ <div className={`flex flex-col ${pickerMode ? "gap-2 p-2" : "gap-4 md:gap-6 p-4 md:p-6 rounded-md md:rounded-t-xl surface-tile"} border-b border-[var(--kyro-border)] backdrop-blur-md sticky top-0 z-10`}>
421
438
  {!pickerMode && (
422
- <div className="flex items-center gap-4">
439
+ <div className="flex items-center justify-between w-full">
423
440
  <div>
424
- <h2 className="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
441
+ <h2 className="text-lg md:text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
425
442
  Media Library
426
443
  </h2>
427
- <div className="flex items-center gap-3 mt-1">
444
+ <div className="flex items-center gap-2 mt-0.5">
428
445
  <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50">
429
- {total} Items · {formatFileSize(stats.totalSize)}
446
+ {total} Items <span className="hidden sm:inline">· {formatFileSize(stats.totalSize)}</span>
430
447
  </span>
431
448
  </div>
432
449
  </div>
433
- </div>
434
- )}
435
-
436
- <div className={`flex items-center gap-3 flex-wrap lg:flex-nowrap ${pickerMode ? "w-full" : ""}`}>
437
- <div className="relative group flex-1 min-w-[200px]">
438
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
439
- <input
440
- type="text"
441
- placeholder="Search assets..."
442
- value={search}
443
- onChange={(e) => setSearch(e.target.value)}
444
- className="w-full pl-11 pr-4 py-2.5 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all text-xs font-bold"
445
- />
446
- </div>
447
-
448
- {!pickerMode && (
449
- <>
450
- <div className="flex items-center gap-2">
451
- <div className="flex bg-[var(--kyro-surface-accent)] p-1 rounded-xl border border-[var(--kyro-border)]">
452
- <button
453
- onClick={() => setView("grid")}
454
- className={`p-2 rounded-lg transition-all ${view === "grid" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
455
- >
456
- <Grid className="w-4 h-4" />
457
- </button>
458
- <button
459
- onClick={() => setView("list")}
460
- className={`p-2 rounded-lg transition-all ${view === "list" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
461
- >
462
- <FileIcon className="w-4 h-4" />
463
- </button>
464
- </div>
465
450
 
451
+ <div className="flex items-center gap-2">
452
+ <div className="flex bg-[var(--kyro-surface-accent)] p-1 rounded-xl border border-[var(--kyro-border)] hidden sm:flex">
466
453
  <button
467
- onClick={() => setShowMobileFilters(true)}
468
- className="md:hidden p-2 rounded-xl bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
454
+ onClick={() => setView("grid")}
455
+ className={`p-1.5 md:p-2 rounded-lg transition-all ${view === "grid" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
469
456
  >
470
- <Filter className="w-4 h-4" />
457
+ <Grid className="w-4 h-4" />
458
+ </button>
459
+ <button
460
+ onClick={() => setView("list")}
461
+ className={`p-1.5 md:p-2 rounded-lg transition-all ${view === "list" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
462
+ >
463
+ <FileIcon className="w-4 h-4" />
471
464
  </button>
472
465
  </div>
473
466
 
467
+ <button
468
+ onClick={() => setShowMobileFilters(true)}
469
+ className="md:hidden p-2 rounded-xl bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
470
+ >
471
+ <Filter className="w-4 h-4" />
472
+ </button>
473
+
474
474
  {canUpload && (
475
475
  <button
476
476
  onClick={() => fileInputRef.current?.click()}
477
- className="flex items-center gap-2 px-6 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs shadow-lg active:scale-95 transition-all"
477
+ className="flex items-center justify-center gap-2 px-3 md:px-6 py-2 md:py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs shadow-lg active:scale-95 transition-all"
478
478
  >
479
- <Maximize2 className="w-4 h-4" />
480
- Upload
479
+ <span className="md:hidden text-lg leading-none">+</span>
480
+ <Maximize2 className="w-4 h-4 hidden md:block" />
481
+ <span className="hidden md:inline">Upload</span>
481
482
  </button>
482
483
  )}
483
- </>
484
- )}
484
+ </div>
485
+ </div>
486
+ )}
487
+
488
+ <div className={`flex items-center w-full`}>
489
+ <div className="relative group flex-1">
490
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
491
+ <input
492
+ type="text"
493
+ placeholder="Search assets..."
494
+ value={search}
495
+ onChange={(e) => setSearch(e.target.value)}
496
+ className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all text-xs font-bold"
497
+ />
498
+ </div>
485
499
  </div>
486
500
  </div>
487
501
 
@@ -610,7 +624,7 @@ export function MediaGallery({
610
624
  >
611
625
  {item.type === "image" ? (
612
626
  <img
613
- src={item.url}
627
+ src={getCroppedUrl(item, 400) || item.url}
614
628
  alt={item.title}
615
629
  className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
616
630
  loading="lazy"
@@ -707,7 +721,7 @@ export function MediaGallery({
707
721
  <div className="w-12 h-12 rounded-xl bg-[var(--kyro-bg)] overflow-hidden border border-[var(--kyro-border)] flex-shrink-0 flex items-center justify-center">
708
722
  {item.type === "image" ? (
709
723
  <img
710
- src={item.url}
724
+ src={getCroppedUrl(item, 96) || item.url}
711
725
  alt=""
712
726
  className="w-full h-full object-cover"
713
727
  />
@@ -962,7 +976,7 @@ export function MediaGallery({
962
976
  <div className="aspect-video w-full rounded-2xl bg-[var(--kyro-bg)] border border-[var(--kyro-border)] overflow-hidden relative group">
963
977
  {panelItem.type === "image" ? (
964
978
  <img
965
- src={getAbsoluteUrl(panelItem.url)}
979
+ src={getCroppedUrl(panelItem) || getAbsoluteUrl(panelItem.url)}
966
980
  alt=""
967
981
  className="w-full h-full object-contain p-4"
968
982
  />
@@ -1152,7 +1166,7 @@ export function MediaGallery({
1152
1166
  <div className="flex-1 w-full flex items-center justify-center p-12">
1153
1167
  {panelItem.type === "image" ? (
1154
1168
  <img
1155
- src={getAbsoluteUrl(panelItem.url)}
1169
+ src={getCroppedUrl(panelItem) || getAbsoluteUrl(panelItem.url)}
1156
1170
  alt=""
1157
1171
  className="max-h-full max-w-full object-contain shadow-2xl rounded-lg animate-in zoom-in-95 duration-500"
1158
1172
  />
@@ -634,7 +634,7 @@ export function WebhookManager() {
634
634
  <ModalContent>
635
635
  <div className="space-y-6">
636
636
  <div className="grid grid-cols-1 gap-4">
637
- <div className="p-4 rounded-xl bg-[var(--kyro-surface)] border border-[var(--kyro-border)]">
637
+ <div className="p-4 rounded-md bg-[var(--kyro-surface)] border border-[var(--kyro-border)]">
638
638
  <h4 className="text-sm font-medium mb-1.5 flex items-center gap-2">
639
639
  <ExternalLink className="w-3.5 h-3.5 text-[var(--kyro-primary)]" />
640
640
  Request format
@@ -644,7 +644,7 @@ export function WebhookManager() {
644
644
  to your endpoint with a JSON payload containing document metadata and operation details.
645
645
  </p>
646
646
  </div>
647
- <div className="p-4 rounded-xl bg-[var(--kyro-surface)] border border-[var(--kyro-border)]">
647
+ <div className="p-4 rounded-md bg-[var(--kyro-surface)] border border-[var(--kyro-border)]">
648
648
  <h4 className="text-sm font-medium mb-1.5 flex items-center gap-2">
649
649
  <Shield className="w-3.5 h-3.5 text-[var(--kyro-primary)]" />
650
650
  Signature verification
@@ -658,7 +658,7 @@ export function WebhookManager() {
658
658
 
659
659
  <div className="space-y-2">
660
660
  <h4 className="text-xs font-medium text-[var(--kyro-text-secondary)] opacity-50">Example payload</h4>
661
- <div className="bg-[var(--kyro-bg)] rounded-xl border border-[var(--kyro-border)] p-4 font-mono text-xs overflow-hidden">
661
+ <div className="bg-[var(--kyro-bg)] rounded-md border border-[var(--kyro-border)] p-4 font-mono text-xs overflow-hidden">
662
662
  <div className="space-y-0.5 text-[var(--kyro-text-secondary)]">
663
663
  <div>{"{"}</div>
664
664
  <div className="pl-3"><span className="text-[var(--kyro-primary)]">"event"</span>: <span className="text-green-500">"collection.create"</span>,</div>
@@ -216,7 +216,6 @@ export function AutoFormHeader({
216
216
  }
217
217
  >
218
218
  Save Draft
219
- <span className="ml-auto text-[9px] opacity-40 font-mono">⌘S</span>
220
219
  </DropdownItem>
221
220
  {!globalSlug && (
222
221
  <DropdownItem
@@ -327,7 +326,7 @@ export function AutoFormHeader({
327
326
  return (
328
327
  <>
329
328
  {/* MOBILE HEADER */}
330
- <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">
331
330
  <div className="flex items-center gap-2 px-3 py-2.5">
332
331
  <a
333
332
  href={`${ADMIN_BASE}/${collectionSlug}`}
@@ -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] = React.useState<number | null>(0);
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.