@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import type { IconField as IconFieldType } from "@kyro-cms/core/client";
|
|
3
|
+
import FieldLayout from "./FieldLayout";
|
|
4
|
+
import { IconPickerModal } from "../ui/IconPickerModal";
|
|
5
|
+
import * as LucideIcons from "lucide-react";
|
|
6
|
+
|
|
7
|
+
interface IconFieldComponentProps {
|
|
8
|
+
field: IconFieldType;
|
|
9
|
+
value?: string | null;
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
error?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function IconField({
|
|
16
|
+
field,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
error,
|
|
20
|
+
disabled,
|
|
21
|
+
}: IconFieldComponentProps) {
|
|
22
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
23
|
+
const normalizedValue = value == null ? "" : String(value);
|
|
24
|
+
|
|
25
|
+
// Convert kebab-case value back to PascalCase to render the preview component
|
|
26
|
+
const pascalName = useMemo(() => {
|
|
27
|
+
if (!normalizedValue) return "";
|
|
28
|
+
return normalizedValue
|
|
29
|
+
.split("-")
|
|
30
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
31
|
+
.join("");
|
|
32
|
+
}, [normalizedValue]);
|
|
33
|
+
|
|
34
|
+
const IconComponent = (LucideIcons as any)[pascalName];
|
|
35
|
+
const isReadOnly = typeof field.admin?.readOnly === "function" ? false : Boolean(field.admin?.readOnly);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<FieldLayout field={field} error={error}>
|
|
39
|
+
<div className="flex items-center gap-3">
|
|
40
|
+
<div className="flex-1 relative">
|
|
41
|
+
<input
|
|
42
|
+
id={field.name}
|
|
43
|
+
type="text"
|
|
44
|
+
value={normalizedValue}
|
|
45
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
46
|
+
placeholder={field.admin?.placeholder || "e.g., activity"}
|
|
47
|
+
disabled={disabled || isReadOnly}
|
|
48
|
+
required={field.required}
|
|
49
|
+
className={`kyro-form-input ${disabled || isReadOnly ? "opacity-70 bg-[var(--kyro-bg-secondary)] cursor-not-allowed" : ""}`}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={() => setPickerOpen(true)}
|
|
56
|
+
disabled={disabled || isReadOnly}
|
|
57
|
+
className="flex items-center gap-2 h-10 px-4 shrink-0 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl text-sm font-bold text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary-alpha)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
58
|
+
>
|
|
59
|
+
{IconComponent ? (
|
|
60
|
+
<IconComponent className="w-5 h-5" strokeWidth={2} />
|
|
61
|
+
) : (
|
|
62
|
+
<LucideIcons.Search className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
|
|
63
|
+
)}
|
|
64
|
+
Browse
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{pickerOpen && (
|
|
69
|
+
<IconPickerModal
|
|
70
|
+
open={pickerOpen}
|
|
71
|
+
onClose={() => setPickerOpen(false)}
|
|
72
|
+
onSelect={(iconName) => {
|
|
73
|
+
onChange?.(iconName);
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
</FieldLayout>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -186,12 +186,14 @@ export function UploadField({
|
|
|
186
186
|
formData.append("folder", selectedFolder);
|
|
187
187
|
}
|
|
188
188
|
const result = await apiUpload<any>("/api/media/upload", formData);
|
|
189
|
+
const data = result.data || result.doc || result;
|
|
189
190
|
const newImage = {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
...data,
|
|
192
|
+
id: data.id,
|
|
193
|
+
filename: data.filename,
|
|
194
|
+
originalName: data.originalName ?? file.name,
|
|
195
|
+
url: data.url,
|
|
196
|
+
mimeType: data.mimeType || file.type,
|
|
195
197
|
};
|
|
196
198
|
if (isMultiple) {
|
|
197
199
|
onChange([...currentValue, newImage]);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import React, { type ReactNode } from "react";
|
|
2
2
|
import { useDraggable } from "@dnd-kit/core";
|
|
3
3
|
import { SlidePanel } from "./SlidePanel";
|
|
4
|
+
import { ClipboardPaste } from "lucide-react";
|
|
4
5
|
|
|
5
6
|
interface BlockDrawerProps {
|
|
6
7
|
open: boolean;
|
|
7
8
|
onClose: () => void;
|
|
8
9
|
onSelect: (blockType: string) => void;
|
|
10
|
+
onPasteBlock?: (blockData: any) => void;
|
|
9
11
|
children?: ReactNode;
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -13,8 +15,29 @@ export function BlockDrawer({
|
|
|
13
15
|
open,
|
|
14
16
|
onClose,
|
|
15
17
|
onSelect,
|
|
18
|
+
onPasteBlock,
|
|
16
19
|
children,
|
|
17
20
|
}: BlockDrawerProps) {
|
|
21
|
+
const [pasteError, setPasteError] = React.useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const handlePaste = async () => {
|
|
24
|
+
try {
|
|
25
|
+
setPasteError(null);
|
|
26
|
+
const text = await navigator.clipboard.readText();
|
|
27
|
+
const parsed = JSON.parse(text);
|
|
28
|
+
if (parsed.__kyro_block) {
|
|
29
|
+
if (onPasteBlock) {
|
|
30
|
+
onPasteBlock(parsed);
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
setPasteError("Clipboard does not contain a valid Kyro block.");
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setPasteError("Failed to read block from clipboard.");
|
|
37
|
+
console.error(err);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
18
41
|
if (!open) return null;
|
|
19
42
|
|
|
20
43
|
return (
|
|
@@ -22,6 +45,22 @@ export function BlockDrawer({
|
|
|
22
45
|
<p className="text-sm text-[var(--kyro-text-muted)] mb-4">
|
|
23
46
|
Drag blocks into the editor or click to insert
|
|
24
47
|
</p>
|
|
48
|
+
|
|
49
|
+
{onPasteBlock && (
|
|
50
|
+
<div className="mb-6 pb-6 border-b border-[var(--kyro-border)]">
|
|
51
|
+
<button
|
|
52
|
+
onClick={handlePaste}
|
|
53
|
+
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] text-sm font-semibold transition-all hover:bg-[var(--kyro-primary)]/5"
|
|
54
|
+
>
|
|
55
|
+
<ClipboardPaste className="w-4 h-4" />
|
|
56
|
+
Paste Block from Clipboard
|
|
57
|
+
</button>
|
|
58
|
+
{pasteError && (
|
|
59
|
+
<p className="text-xs text-red-500 mt-2 text-center font-medium">{pasteError}</p>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
25
64
|
{children}
|
|
26
65
|
</SlidePanel>
|
|
27
66
|
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import { Modal } from "./Modal";
|
|
3
|
+
import * as LucideIcons from "lucide-react";
|
|
4
|
+
import { Search } from "./icons";
|
|
5
|
+
|
|
6
|
+
// Extract only valid components from lucide-react
|
|
7
|
+
const availableIcons = Object.keys(LucideIcons).filter((key) => {
|
|
8
|
+
return /^[A-Z]/.test(key) && key !== "LucideProps" && key !== "Icon";
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
interface IconPickerModalProps {
|
|
12
|
+
open: boolean;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
onSelect: (iconName: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function IconPickerModal({ open, onClose, onSelect }: IconPickerModalProps) {
|
|
18
|
+
const [search, setSearch] = useState("");
|
|
19
|
+
|
|
20
|
+
const filteredIcons = useMemo(() => {
|
|
21
|
+
if (!search) return availableIcons;
|
|
22
|
+
const lowerSearch = search.toLowerCase();
|
|
23
|
+
return availableIcons.filter((iconName) => iconName.toLowerCase().includes(lowerSearch));
|
|
24
|
+
}, [search]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Modal
|
|
28
|
+
open={open}
|
|
29
|
+
onClose={onClose}
|
|
30
|
+
title="Select an Icon"
|
|
31
|
+
size="xl"
|
|
32
|
+
>
|
|
33
|
+
<div className="flex flex-col h-[60vh]">
|
|
34
|
+
<div className="relative mb-6">
|
|
35
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
|
|
36
|
+
<input
|
|
37
|
+
type="text"
|
|
38
|
+
placeholder="Search icons..."
|
|
39
|
+
value={search}
|
|
40
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
41
|
+
className="w-full pl-10 pr-4 py-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-primary)] transition-all text-sm font-bold"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar pr-2">
|
|
46
|
+
{filteredIcons.length === 0 ? (
|
|
47
|
+
<div className="flex items-center justify-center h-full text-[var(--kyro-text-secondary)] font-medium">
|
|
48
|
+
No icons found
|
|
49
|
+
</div>
|
|
50
|
+
) : (
|
|
51
|
+
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-3 pb-8">
|
|
52
|
+
{filteredIcons.map((iconName) => {
|
|
53
|
+
const IconComponent = (LucideIcons as any)[iconName];
|
|
54
|
+
// we store standard kebab-case in db
|
|
55
|
+
const kebabName = iconName
|
|
56
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
key={iconName}
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={() => {
|
|
64
|
+
onSelect(kebabName);
|
|
65
|
+
onClose();
|
|
66
|
+
}}
|
|
67
|
+
className="flex flex-col items-center justify-center p-3 gap-2 rounded-md border border-[var(--kyro-border)] hover:bg-[var(--kyro-surface-accent)] hover:border-[var(--kyro-primary)] transition-all group"
|
|
68
|
+
title={iconName}
|
|
69
|
+
>
|
|
70
|
+
<IconComponent className="w-6 h-6 text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-primary)] transition-colors" strokeWidth={1.5} />
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</Modal>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
2
2
|
import ReactCrop, { type Crop } from "react-image-crop";
|
|
3
3
|
import "react-image-crop/dist/ReactCrop.css";
|
|
4
|
-
import { Crop as CropIcon, MousePointerClick, RefreshCcw } from "./icons";
|
|
4
|
+
import { Crop as CropIcon, MousePointerClick, RefreshCcw, Eye } from "./icons";
|
|
5
5
|
|
|
6
6
|
interface RectPercent {
|
|
7
7
|
x: number;
|
|
@@ -145,6 +145,19 @@ export function ImageFocalEditor({
|
|
|
145
145
|
setShowHotspot(false);
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
+
// Build preview URL with crop params
|
|
149
|
+
const previewUrl = useMemo(() => {
|
|
150
|
+
if (!crop || !crop.width || !crop.height) return null;
|
|
151
|
+
const params = new URLSearchParams({ url });
|
|
152
|
+
params.set("cx", String(crop.x));
|
|
153
|
+
params.set("cy", String(crop.y));
|
|
154
|
+
params.set("cw", String(crop.width));
|
|
155
|
+
params.set("ch", String(crop.height));
|
|
156
|
+
return `/api/media/resize?${params.toString()}`;
|
|
157
|
+
}, [url, crop]);
|
|
158
|
+
|
|
159
|
+
const hasCrop = crop?.width && crop.height;
|
|
160
|
+
|
|
148
161
|
return (
|
|
149
162
|
<div className="flex flex-col h-full bg-[var(--kyro-bg)]">
|
|
150
163
|
{/* Toolbar */}
|
|
@@ -211,87 +224,115 @@ export function ImageFocalEditor({
|
|
|
211
224
|
</div>
|
|
212
225
|
</div>
|
|
213
226
|
|
|
214
|
-
{/* Editor Area */}
|
|
215
|
-
<div className="flex-1
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
ref={imgRef}
|
|
225
|
-
src={url}
|
|
226
|
-
alt="Focal Editor"
|
|
227
|
-
className="max-h-[70vh] object-contain pointer-events-none"
|
|
228
|
-
onLoad={onImageLoad}
|
|
229
|
-
/>
|
|
230
|
-
</ReactCrop>
|
|
231
|
-
|
|
232
|
-
{/* Hotspot Overlay */}
|
|
233
|
-
{showHotspot && imgRef.current && (
|
|
234
|
-
<div
|
|
235
|
-
className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center"
|
|
227
|
+
{/* Editor + Preview Area */}
|
|
228
|
+
<div className="flex-1 flex overflow-hidden">
|
|
229
|
+
{/* Editor (left) */}
|
|
230
|
+
<div className="flex-1 w-full flex items-center justify-center p-8 overflow-hidden relative select-none">
|
|
231
|
+
<div className="relative max-w-full max-h-full flex items-center justify-center" ref={containerRef}>
|
|
232
|
+
<ReactCrop
|
|
233
|
+
crop={crop}
|
|
234
|
+
onChange={(c, pc) => setCrop(pc)} // Save percentage crop
|
|
235
|
+
locked={mode === "hotspot"}
|
|
236
|
+
className={mode === "hotspot" ? "opacity-70 transition-opacity" : "transition-opacity"}
|
|
236
237
|
>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
238
|
+
<img
|
|
239
|
+
ref={imgRef}
|
|
240
|
+
src={url}
|
|
241
|
+
alt="Focal Editor"
|
|
242
|
+
className="max-h-[70vh] object-contain pointer-events-none"
|
|
243
|
+
onLoad={onImageLoad}
|
|
244
|
+
/>
|
|
245
|
+
</ReactCrop>
|
|
246
|
+
|
|
247
|
+
{/* Hotspot Overlay */}
|
|
248
|
+
{showHotspot && imgRef.current && (
|
|
249
|
+
<div
|
|
250
|
+
className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center"
|
|
244
251
|
>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
style={{
|
|
252
|
-
left: `${hotspot.x}%`,
|
|
253
|
-
top: `${hotspot.y}%`,
|
|
254
|
-
width: `${hotspot.width}%`,
|
|
255
|
-
height: `${hotspot.height}%`,
|
|
256
|
-
borderRadius: "50%",
|
|
252
|
+
{/* Overlay that matches the image dimensions exactly */}
|
|
253
|
+
<div
|
|
254
|
+
className="relative"
|
|
255
|
+
style={{
|
|
256
|
+
width: imgRef.current.width,
|
|
257
|
+
height: imgRef.current.height
|
|
257
258
|
}}
|
|
258
|
-
onPointerDown={(e) => handlePointerDown(e)}
|
|
259
259
|
>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
260
|
+
<div
|
|
261
|
+
className={`absolute border-2 shadow-2xl transition-colors duration-200 ${
|
|
262
|
+
mode === "hotspot"
|
|
263
|
+
? "border-blue-500 bg-blue-500/20 cursor-move pointer-events-auto shadow-blue-500/50"
|
|
264
|
+
: "border-blue-500/50 bg-blue-500/10 pointer-events-none"
|
|
265
|
+
}`}
|
|
266
|
+
style={{
|
|
267
|
+
left: `${hotspot.x}%`,
|
|
268
|
+
top: `${hotspot.y}%`,
|
|
269
|
+
width: `${hotspot.width}%`,
|
|
270
|
+
height: `${hotspot.height}%`,
|
|
271
|
+
borderRadius: "50%",
|
|
272
|
+
}}
|
|
273
|
+
onPointerDown={(e) => handlePointerDown(e)}
|
|
274
|
+
>
|
|
275
|
+
{/* Center dot */}
|
|
276
|
+
<div className="absolute top-1/2 left-1/2 w-2 h-2 -ml-1 -mt-1 bg-blue-500 rounded-full"></div>
|
|
277
|
+
|
|
278
|
+
{/* Resize handles */}
|
|
279
|
+
{mode === "hotspot" && (
|
|
280
|
+
<>
|
|
281
|
+
<div
|
|
282
|
+
className="absolute top-0 left-1/2 w-4 h-4 -ml-2 -mt-2 bg-blue-500 rounded-full cursor-ns-resize"
|
|
283
|
+
onPointerDown={(e) => handlePointerDown(e, "n")}
|
|
284
|
+
/>
|
|
285
|
+
<div
|
|
286
|
+
className="absolute bottom-0 left-1/2 w-4 h-4 -ml-2 -mb-2 bg-blue-500 rounded-full cursor-ns-resize"
|
|
287
|
+
onPointerDown={(e) => handlePointerDown(e, "s")}
|
|
288
|
+
/>
|
|
289
|
+
<div
|
|
290
|
+
className="absolute top-1/2 left-0 w-4 h-4 -ml-2 -mt-2 bg-blue-500 rounded-full cursor-ew-resize"
|
|
291
|
+
onPointerDown={(e) => handlePointerDown(e, "w")}
|
|
292
|
+
/>
|
|
293
|
+
<div
|
|
294
|
+
className="absolute top-1/2 right-0 w-4 h-4 -mr-2 -mt-2 bg-blue-500 rounded-full cursor-ew-resize"
|
|
295
|
+
onPointerDown={(e) => handlePointerDown(e, "e")}
|
|
296
|
+
/>
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
284
300
|
</div>
|
|
285
301
|
</div>
|
|
286
|
-
|
|
287
|
-
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
288
304
|
</div>
|
|
305
|
+
|
|
306
|
+
{/* Preview (right) */}
|
|
307
|
+
{hasCrop && (
|
|
308
|
+
<div className="w-[280px] border-l border-[var(--kyro-border)] bg-[var(--kyro-surface)] flex flex-col overflow-hidden">
|
|
309
|
+
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--kyro-border)]">
|
|
310
|
+
<Eye className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
|
|
311
|
+
<span className="text-xs font-bold text-[var(--kyro-text-secondary)] tracking-wide uppercase">
|
|
312
|
+
Preview
|
|
313
|
+
</span>
|
|
314
|
+
</div>
|
|
315
|
+
<div className="flex-1 flex items-center justify-center p-4 overflow-hidden">
|
|
316
|
+
{previewUrl && (
|
|
317
|
+
<img
|
|
318
|
+
src={previewUrl}
|
|
319
|
+
alt="Crop preview"
|
|
320
|
+
className="max-w-full max-h-full object-contain rounded-lg shadow-lg border border-[var(--kyro-border)]"
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
<div className="px-4 py-3 border-t border-[var(--kyro-border)] text-[10px] text-[var(--kyro-text-secondary)] font-mono">
|
|
325
|
+
<div>x: {crop?.x?.toFixed(1)}% y: {crop?.y?.toFixed(1)}%</div>
|
|
326
|
+
<div>w: {crop?.width?.toFixed(1)}% h: {crop?.height?.toFixed(1)}%</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
289
330
|
</div>
|
|
290
331
|
|
|
291
332
|
{/* Help Text */}
|
|
292
333
|
<div className="p-4 text-center bg-[var(--kyro-surface-accent)] border-t border-[var(--kyro-border)] text-sm text-[var(--kyro-text-secondary)]">
|
|
293
334
|
{mode === "crop"
|
|
294
|
-
? "Drag to define the crop area.
|
|
335
|
+
? "Drag to define the crop area. The preview panel shows the result in real-time."
|
|
295
336
|
: "Drag the circle to define the focal hotspot. This area will always remain visible when the image is resized."}
|
|
296
337
|
</div>
|
|
297
338
|
</div>
|
|
@@ -18,7 +18,7 @@ interface ModalProps {
|
|
|
18
18
|
title: string;
|
|
19
19
|
children: ReactNode;
|
|
20
20
|
footer?: ReactNode;
|
|
21
|
-
size?: "sm" | "md" | "lg" | "full";
|
|
21
|
+
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
|
22
22
|
variant?: "default" | "danger" | "lightbox";
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -28,7 +28,7 @@ export function Modal({
|
|
|
28
28
|
title,
|
|
29
29
|
children,
|
|
30
30
|
footer,
|
|
31
|
-
size = "
|
|
31
|
+
size = "lg",
|
|
32
32
|
variant = "default",
|
|
33
33
|
}: ModalProps) {
|
|
34
34
|
useEffect(() => {
|
|
@@ -53,6 +53,8 @@ export function Modal({
|
|
|
53
53
|
sm: "max-w-sm",
|
|
54
54
|
md: "max-w-md",
|
|
55
55
|
lg: "max-w-lg",
|
|
56
|
+
xl: "max-w-xl",
|
|
57
|
+
"2xl": "max-w-2xl",
|
|
56
58
|
full: "w-full h-full max-w-none rounded-none border-0",
|
|
57
59
|
};
|
|
58
60
|
|
|
@@ -110,4 +110,4 @@ export { Grid3X3 as IconGrid3X3 } from "lucide-react";
|
|
|
110
110
|
// Direct re-exports for files that still use original lucide-react names
|
|
111
111
|
export { Activity as Activity, AlignLeft as AlignLeft, Archive as Archive, ArrowDown as ArrowDown, ArrowRight as ArrowRight, ArrowUpRight as ArrowUpRight, Blocks as Blocks, Box as Box, Calendar as Calendar, Check as Check, ChevronDown as ChevronDown, ChevronLeft as ChevronLeft, ChevronRight as ChevronRight, ChevronUp as ChevronUp, Clock as Clock, Code as Code, Columns3 as Columns3, Copy as Copy, Crop as Crop, Download as Download, ExternalLink as ExternalLink, Eye as Eye, EyeOff as EyeOff, File as File, File as FileIcon, FileText as FileText, Globe as Globe, Film as Film, Filter as Filter, Folder as Folder, FolderInput as FolderInput, FolderPlus as FolderPlus, GripVertical as GripVertical, Heading1 as Heading1, Image as Image, Info as Info, Key as Key, LayoutDashboard as LayoutDashboard, Link as Link, Link2 as Link2, List as List, ListOrdered as ListOrdered, Lock as Lock, Mail as Mail, Maximize2 as Maximize2, Menu as Menu, Minus as Minus, Monitor as Monitor, MousePointerClick as MousePointerClick, Music as Music, Palette as Palette, Pause as Pause, Play as Play, Plus as Plus, RefreshCcw as RefreshCcw, RefreshCw as RefreshCw, Save as Save, Search as Search, Send as Send, Settings as Settings, Shield as Shield, Sparkles as Sparkles, Star as Star, Tag as Tag, Terminal as Terminal, ToggleLeft as ToggleLeft, ToggleRight as ToggleRight, Trash2 as Trash2, TrendingUp as TrendingUp, Type as Type, User as User, UserPlus as UserPlus, Users as Users, Video as Video, Webhook as Webhook, X as X, Zap as Zap, CircleHelp as HelpCircle } from "lucide-react";
|
|
112
112
|
export { CircleCheck as CheckCircle2, Grid3X3 as Grid, House as Home, LayoutPanelTop as Layout, LoaderCircle as Loader2, LockOpen as Unlock, CirclePlay as PlayCircle, TriangleAlert as AlertTriangle, CodeXml as Code2, CloudDownload as DownloadCloud, EllipsisVertical as MoreVertical, ShieldCheck as ShieldCheck, ShieldAlert as ShieldAlert, Pencil as Edit2, Moon as Moon, Sun as Sun, LogOut as LogOut, Database as Database, Hexagon as Hexagon, Network as Network, Book as Book, Bold as Bold, Italic as Italic, Underline as Underline, Strikethrough as Strikethrough, Undo as Undo, Redo as Redo, Dot as Dot, Grid3X3, Laptop as Laptop, Smartphone as Smartphone } from "lucide-react";
|
|
113
|
-
export { Server as Server, XCircle as XCircle, Clipboard as Clipboard, Upload as Upload } from "lucide-react";
|
|
113
|
+
export { Server as Server, XCircle as XCircle, Clipboard as Clipboard, ClipboardPaste as ClipboardPaste, ClipboardCopy as ClipboardCopy, Upload as Upload } from "lucide-react";
|