@kyro-cms/admin 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +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 +43 -18
- 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/BlocksField.tsx +68 -16
- package/src/components/fields/IconField.tsx +79 -0
- package/src/components/fields/UploadField.tsx +7 -5
- package/src/components/fields/index.ts +1 -0
- package/src/components/ui/BlockDrawer.tsx +39 -0
- package/src/components/ui/IconPickerModal.tsx +80 -0
- package/src/components/ui/ImageFocalEditor.tsx +112 -71
- package/src/components/ui/Modal.tsx +4 -2
- package/src/components/ui/icons.tsx +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -706,23 +747,7 @@ export function AutoForm({
|
|
|
706
747
|
}
|
|
707
748
|
} else if (typeof field.admin.condition === "object") {
|
|
708
749
|
try {
|
|
709
|
-
const
|
|
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
|
-
|
|
750
|
+
const shouldShow = evaluateDeclarativeCondition(field.admin.condition, currentData, formData);
|
|
726
751
|
if (!shouldShow) {
|
|
727
752
|
return null;
|
|
728
753
|
}
|
|
@@ -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("
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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={() =>
|
|
468
|
-
className=
|
|
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
|
-
<
|
|
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
|
-
<
|
|
480
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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}`}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getBlockLabel,
|
|
12
12
|
blockTheme,
|
|
13
13
|
} from "./extensions/blockComponents";
|
|
14
|
+
import { Check, ClipboardCopy } from "lucide-react";
|
|
14
15
|
import { GenericBlock } from "../blocks/GenericBlock";
|
|
15
16
|
import { BlockEditModal } from "../blocks/BlockEditModal";
|
|
16
17
|
import {
|
|
@@ -94,6 +95,20 @@ const SortableBlockComponent = ({
|
|
|
94
95
|
const [editingName, setEditingName] = useState(false);
|
|
95
96
|
const [nameDraft, setNameDraft] = useState((block.name as string) || "");
|
|
96
97
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
98
|
+
const [copied, setCopied] = useState(false);
|
|
99
|
+
|
|
100
|
+
const copyToClipboard = useCallback(async (e: React.MouseEvent) => {
|
|
101
|
+
e.stopPropagation();
|
|
102
|
+
try {
|
|
103
|
+
const { id, ...blockProps } = block;
|
|
104
|
+
const payload = JSON.stringify({ __kyro_block: true, type: block.type, blockProps });
|
|
105
|
+
await navigator.clipboard.writeText(payload);
|
|
106
|
+
setCopied(true);
|
|
107
|
+
setTimeout(() => setCopied(false), 2000);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error("Failed to copy block", err);
|
|
110
|
+
}
|
|
111
|
+
}, [block]);
|
|
97
112
|
|
|
98
113
|
useEffect(() => {
|
|
99
114
|
if (editingName && nameInputRef.current) {
|
|
@@ -123,16 +138,16 @@ const SortableBlockComponent = ({
|
|
|
123
138
|
|
|
124
139
|
if (compact) {
|
|
125
140
|
return (
|
|
126
|
-
<div ref={setNodeRef} style={style} className="relative group">
|
|
141
|
+
<div ref={setNodeRef} style={style} className="relative group w-full">
|
|
127
142
|
<div
|
|
128
143
|
onClick={() => setEditingBlockId(block.id as string)}
|
|
129
|
-
className={`flex items-center gap-
|
|
144
|
+
className={`flex items-center gap-2 pl-7 pr-2 py-2 w-full bg-[var(--kyro-bg-secondary)] rounded-md border transition-colors cursor-pointer text-sm ${isEditing
|
|
130
145
|
? `${(blockTheme[block.type as string] || blockTheme.default).border} bg-[var(--kyro-primary)]/5`
|
|
131
146
|
: "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]/50 hover:bg-[var(--kyro-primary)]/5"
|
|
132
147
|
}`}
|
|
133
148
|
>
|
|
134
149
|
<div
|
|
135
|
-
className="absolute left-
|
|
150
|
+
className="absolute left-1.5 top-1/2 -translate-y-1/2 p-0.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--kyro-surface-accent)] rounded touch-none"
|
|
136
151
|
{...attributes}
|
|
137
152
|
{...listeners}
|
|
138
153
|
onClick={(e) => e.stopPropagation()}
|
|
@@ -158,7 +173,7 @@ const SortableBlockComponent = ({
|
|
|
158
173
|
/>
|
|
159
174
|
) : (
|
|
160
175
|
<span
|
|
161
|
-
className="font-medium text-[var(--kyro-text-secondary)]
|
|
176
|
+
className="font-medium text-[var(--kyro-text-secondary)] flex-1 min-w-0 truncate transition-colors text-left"
|
|
162
177
|
>
|
|
163
178
|
{itemLabel}
|
|
164
179
|
</span>
|
|
@@ -205,10 +220,18 @@ const SortableBlockComponent = ({
|
|
|
205
220
|
onDuplicate(block.id as string);
|
|
206
221
|
}}
|
|
207
222
|
className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
|
|
208
|
-
title="Duplicate"
|
|
223
|
+
title="Duplicate in place"
|
|
209
224
|
>
|
|
210
225
|
<Copy className="w-3 h-3" />
|
|
211
226
|
</button>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={copyToClipboard}
|
|
230
|
+
className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
|
|
231
|
+
title="Copy Block to Clipboard"
|
|
232
|
+
>
|
|
233
|
+
{copied ? <Check className="w-3 h-3 text-green-500" /> : <ClipboardCopy className="w-3 h-3" />}
|
|
234
|
+
</button>
|
|
212
235
|
<button
|
|
213
236
|
type="button"
|
|
214
237
|
onClick={(e) => {
|
|
@@ -344,6 +367,14 @@ const SortableBlockComponent = ({
|
|
|
344
367
|
>
|
|
345
368
|
<Copy className="w-3.5 h-3.5" />
|
|
346
369
|
</button>
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
onClick={copyToClipboard}
|
|
373
|
+
className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
|
|
374
|
+
title="Copy Block to Clipboard"
|
|
375
|
+
>
|
|
376
|
+
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <ClipboardCopy className="w-3.5 h-3.5" />}
|
|
377
|
+
</button>
|
|
347
378
|
<button
|
|
348
379
|
type="button"
|
|
349
380
|
onClick={(e) => {
|
|
@@ -518,6 +549,26 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
518
549
|
[store],
|
|
519
550
|
);
|
|
520
551
|
|
|
552
|
+
const handlePasteBlock = useCallback((parsedData: any) => {
|
|
553
|
+
const allowedBlocks = (field.blocks as Array<{ slug: string }>) || [];
|
|
554
|
+
const isAllowed = allowedBlocks.some((b) => b.slug === parsedData.type);
|
|
555
|
+
|
|
556
|
+
if (!isAllowed) {
|
|
557
|
+
alert(`The block type "${parsedData.type}" is not allowed in this collection.`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const newId = Math.random().toString(36).substring(2, 11);
|
|
562
|
+
const newBlock = {
|
|
563
|
+
...(parsedData.blockProps || {}),
|
|
564
|
+
id: newId,
|
|
565
|
+
type: parsedData.type,
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
store.getState().setBlocks([...blocks, newBlock]);
|
|
569
|
+
setIsDrawerOpen(false);
|
|
570
|
+
}, [field.blocks, blocks, store]);
|
|
571
|
+
|
|
521
572
|
const duplicateBlock = useCallback(
|
|
522
573
|
(blockId: string) => {
|
|
523
574
|
const blockIndex = blocks.findIndex((b) => b.id === blockId);
|
|
@@ -645,25 +696,25 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
645
696
|
onDragStart={handleDragStart}
|
|
646
697
|
onDragEnd={handleDragEnd}
|
|
647
698
|
>
|
|
648
|
-
<div className="flex items-center justify-between mb-2">
|
|
649
|
-
<label className=
|
|
699
|
+
<div className={pickerMode === "dropdown" ? "mb-4" : "flex items-center justify-between mb-2"}>
|
|
700
|
+
<label className={`kyro-form-label ${pickerMode === "dropdown" ? "block mb-2" : ""}`}>{field.label || field.name}</label>
|
|
650
701
|
{pickerMode === "dropdown" ? (
|
|
651
|
-
<div ref={dropdownRef} className="relative">
|
|
702
|
+
<div ref={dropdownRef} className="relative w-full">
|
|
652
703
|
<button
|
|
653
704
|
type="button"
|
|
654
705
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
655
706
|
disabled={disabled}
|
|
656
|
-
className="flex items-center
|
|
707
|
+
className="flex w-full items-center justify-between px-3 py-2 text-sm text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] bg-[var(--kyro-surface)] rounded-md transition-colors disabled:opacity-50"
|
|
657
708
|
>
|
|
658
|
-
<
|
|
659
|
-
<
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
<ChevronDown className={`w-
|
|
709
|
+
<div className="flex items-center gap-2">
|
|
710
|
+
<Plus className="w-4 h-4 text-[var(--kyro-primary)]" />
|
|
711
|
+
<span className="font-semibold">Select an element to add...</span>
|
|
712
|
+
</div>
|
|
713
|
+
<ChevronDown className={`w-4 h-4 transition-transform ${isDropdownOpen ? "rotate-180 text-[var(--kyro-primary)]" : "opacity-50"}`} />
|
|
663
714
|
</button>
|
|
664
715
|
|
|
665
716
|
{isDropdownOpen && (
|
|
666
|
-
<div className="absolute right-0 top-full mt-1 w-
|
|
717
|
+
<div className="absolute right-0 top-full mt-1 w-full bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 py-2 max-h-80 overflow-y-auto">
|
|
667
718
|
{dynamicCategories.map((category) => (
|
|
668
719
|
<div key={category.title}>
|
|
669
720
|
{dynamicCategories.length > 1 && (
|
|
@@ -715,6 +766,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
715
766
|
open={isDrawerOpen}
|
|
716
767
|
onClose={() => setIsDrawerOpen(false)}
|
|
717
768
|
onSelect={handleAddBlock}
|
|
769
|
+
onPasteBlock={handlePasteBlock}
|
|
718
770
|
>
|
|
719
771
|
<div className="space-y-4">
|
|
720
772
|
{dynamicCategories.map((category) => (
|
|
@@ -755,7 +807,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
755
807
|
items={blocks.map((b) => b.id)}
|
|
756
808
|
strategy={verticalListSortingStrategy}
|
|
757
809
|
>
|
|
758
|
-
<div className={pickerMode === "dropdown" ? "flex flex-
|
|
810
|
+
<div className={pickerMode === "dropdown" ? "flex flex-col gap-2 mt-3" : "space-y-4"}>
|
|
759
811
|
{blocks.map((block, index) => {
|
|
760
812
|
const blockSchema = (field.blocks as any[])?.find(
|
|
761
813
|
(b) => b.slug === block.type
|