@jhits/plugin-images 0.0.4 → 0.0.6
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 +27 -36
- package/src/api/resolve/route.ts +130 -11
- package/src/api/upload/index.ts +0 -4
- package/src/assets/noimagefound.jpg +0 -0
- package/src/components/BackgroundImage.tsx +25 -44
- package/src/components/GlobalImageEditor/config.ts +21 -0
- package/src/components/GlobalImageEditor/eventHandlers.ts +267 -0
- package/src/components/GlobalImageEditor/imageDetection.ts +160 -0
- package/src/components/GlobalImageEditor/imageSetup.ts +306 -0
- package/src/components/GlobalImageEditor/saveLogic.ts +133 -0
- package/src/components/GlobalImageEditor/stylingDetection.ts +122 -0
- package/src/components/GlobalImageEditor/transformParsing.ts +83 -0
- package/src/components/GlobalImageEditor/types.ts +39 -0
- package/src/components/GlobalImageEditor.tsx +186 -637
- package/src/components/Image.tsx +269 -103
- package/src/components/ImageBrowserModal.tsx +837 -0
- package/src/components/ImageEditor.tsx +323 -0
- package/src/components/ImageEffectsPanel.tsx +116 -0
- package/src/components/ImagePicker.tsx +208 -484
- package/src/components/index.ts +3 -0
- package/src/hooks/useImagePicker.ts +344 -0
- package/src/types/index.ts +24 -0
- package/src/utils/transforms.ts +54 -0
|
@@ -1,541 +1,265 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image Picker Component
|
|
3
|
-
* Allows uploading, searching, and selecting images with brightness/blur controls
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
'use client';
|
|
7
2
|
|
|
8
|
-
import React, { useState, useEffect, useRef } from 'react';
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
3
|
+
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { Search, Image as ImageIcon, Settings } from 'lucide-react';
|
|
6
|
+
import type { ImagePickerProps } from '../types';
|
|
7
|
+
import type { ImageMetadata } from '../types';
|
|
8
|
+
import { ImageEditor, type ImageEditorHandle } from './ImageEditor';
|
|
9
|
+
import { ImageBrowserModal } from './ImageBrowserModal';
|
|
10
|
+
import { useImagePicker } from '../hooks/useImagePicker';
|
|
11
|
+
import { getImageTransform, getImageFilter } from '../utils/transforms';
|
|
12
|
+
import { getFallbackImageUrl } from '../utils/fallback';
|
|
11
13
|
|
|
12
14
|
export function ImagePicker({
|
|
13
|
-
value,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
showEffects = true,
|
|
17
|
-
brightness = 100,
|
|
18
|
-
blur = 0,
|
|
19
|
-
onBrightnessChange,
|
|
20
|
-
onBlurChange,
|
|
15
|
+
value, onChange, brightness = 100, blur = 0, scale = 1.0, positionX = 0, positionY = 0,
|
|
16
|
+
aspectRatio = '16/9', borderRadius = 'rounded-xl', onBrightnessChange, onBlurChange,
|
|
17
|
+
onScaleChange, onPositionXChange, onPositionYChange, onEditorSave, autoOpenEditor = false,
|
|
21
18
|
}: ImagePickerProps) {
|
|
22
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
23
|
-
const [images, setImages] = useState<ImageMetadata[]>([]);
|
|
24
|
-
const [loading, setLoading] = useState(false);
|
|
25
|
-
const [uploading, setUploading] = useState(false);
|
|
26
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
27
|
-
const [selectedImage, setSelectedImage] = useState<ImageMetadata | null>(null);
|
|
28
|
-
const [page, setPage] = useState(1);
|
|
29
|
-
const [hasMore, setHasMore] = useState(true);
|
|
30
|
-
const [externalUrl, setExternalUrl] = useState('');
|
|
31
|
-
const [showUrlInput, setShowUrlInput] = useState(false);
|
|
32
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
33
|
-
const modalRef = useRef<HTMLDivElement>(null);
|
|
34
19
|
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const data = await response.json();
|
|
46
|
-
|
|
47
|
-
if (reset) {
|
|
48
|
-
setImages(data.images);
|
|
49
|
-
setPage(2);
|
|
50
|
-
} else {
|
|
51
|
-
setImages(prev => [...prev, ...data.images]);
|
|
52
|
-
setPage(prev => prev + 1);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
setHasMore(data.images.length === 20);
|
|
56
|
-
} catch (error) {
|
|
57
|
-
console.error('Failed to load images:', error);
|
|
58
|
-
} finally {
|
|
59
|
-
setLoading(false);
|
|
60
|
-
}
|
|
61
|
-
};
|
|
20
|
+
const [transforms, setTransforms] = useState({ scale: scale >= 0.1 ? scale : 1.0, positionX, positionY });
|
|
21
|
+
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
|
22
|
+
const [isBrowserOpen, setIsBrowserOpen] = useState(false);
|
|
23
|
+
const [previewBaseScale, setPreviewBaseScale] = useState<number | null>(null);
|
|
24
|
+
const [previewImageError, setPreviewImageError] = useState(false);
|
|
25
|
+
const [mounted, setMounted] = useState(false);
|
|
26
|
+
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
|
27
|
+
const previewImageRef = useRef<HTMLImageElement>(null);
|
|
28
|
+
const previewContainerRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const editorRef = useRef<ImageEditorHandle | null>(null);
|
|
62
30
|
|
|
63
|
-
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (isOpen) {
|
|
66
|
-
loadImages(true);
|
|
67
|
-
}
|
|
68
|
-
}, [isOpen, searchQuery]);
|
|
31
|
+
const { selectedImage, setSelectedImage } = useImagePicker({ value, images: [] });
|
|
69
32
|
|
|
70
|
-
//
|
|
33
|
+
// Reset preview error when selected image changes
|
|
71
34
|
useEffect(() => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let found = images.find(img => img.id === value);
|
|
75
|
-
|
|
76
|
-
// If not found by ID, try to find by URL
|
|
77
|
-
if (!found) {
|
|
78
|
-
found = images.find(img => img.url === value);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (found) {
|
|
82
|
-
setSelectedImage(found);
|
|
83
|
-
} else if (!selectedImage) {
|
|
84
|
-
// Create a temporary image object from the value if not found in list
|
|
85
|
-
// This handles cases where the image was set externally or is an ID
|
|
86
|
-
const isUrl = value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/');
|
|
87
|
-
const urlParts = isUrl ? value.split('/') : [];
|
|
88
|
-
const filename = isUrl ? urlParts[urlParts.length - 1] : value;
|
|
89
|
-
|
|
90
|
-
setSelectedImage({
|
|
91
|
-
id: isUrl ? filename : value, // Use value as ID if it's not a URL
|
|
92
|
-
filename,
|
|
93
|
-
url: isUrl ? value : `/api/uploads/${value}`, // Construct URL if value is an ID
|
|
94
|
-
size: 0,
|
|
95
|
-
mimeType: 'image/jpeg',
|
|
96
|
-
uploadedAt: new Date().toISOString(),
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
setSelectedImage(null);
|
|
101
|
-
}
|
|
102
|
-
}, [value, images]);
|
|
103
|
-
|
|
104
|
-
// Handle file upload
|
|
105
|
-
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
106
|
-
const file = e.target.files?.[0];
|
|
107
|
-
if (!file) return;
|
|
35
|
+
setPreviewImageError(false);
|
|
36
|
+
}, [selectedImage?.id, selectedImage?.url]);
|
|
108
37
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
formData.append('file', file);
|
|
38
|
+
// Handle SSR & portal target - ensure we only render portal on client
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setMounted(true);
|
|
113
41
|
|
|
114
|
-
|
|
115
|
-
method: 'POST',
|
|
116
|
-
body: formData,
|
|
117
|
-
});
|
|
42
|
+
if (typeof document === 'undefined') return;
|
|
118
43
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
onChange(data.image);
|
|
127
|
-
} else {
|
|
128
|
-
alert(data.error || 'Failed to upload image');
|
|
129
|
-
}
|
|
130
|
-
} catch (error) {
|
|
131
|
-
console.error('Upload error:', error);
|
|
132
|
-
alert('Failed to upload image');
|
|
133
|
-
} finally {
|
|
134
|
-
setUploading(false);
|
|
135
|
-
if (fileInputRef.current) {
|
|
136
|
-
fileInputRef.current.value = '';
|
|
137
|
-
}
|
|
44
|
+
// If we're inside the GlobalImageEditor overlay, portal into that instead of document.body
|
|
45
|
+
// This ensures the browser/editor modals share the same stacking context
|
|
46
|
+
const editorContainer = previewContainerRef.current?.closest('[data-image-editor="true"]') as HTMLElement | null;
|
|
47
|
+
if (editorContainer) {
|
|
48
|
+
setPortalTarget(editorContainer);
|
|
49
|
+
} else {
|
|
50
|
+
setPortalTarget(document.body);
|
|
138
51
|
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
// Handle image selection
|
|
142
|
-
const handleSelectImage = (image: ImageMetadata) => {
|
|
143
|
-
setSelectedImage(image);
|
|
144
|
-
onChange(image);
|
|
145
|
-
// Reset effects when selecting a new image (optional - could preserve them)
|
|
146
|
-
// onBrightnessChange?.(100);
|
|
147
|
-
// onBlurChange?.(0);
|
|
148
|
-
};
|
|
52
|
+
}, []);
|
|
149
53
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg'];
|
|
162
|
-
return imageExtensions.some(ext => pathname.endsWith(ext)) ||
|
|
163
|
-
urlObj.hostname.includes('unsplash.com') ||
|
|
164
|
-
urlObj.hostname.includes('pixabay.com') ||
|
|
165
|
-
urlObj.hostname.includes('pexels.com') ||
|
|
166
|
-
url.includes('image') ||
|
|
167
|
-
url.includes('img');
|
|
168
|
-
} catch {
|
|
169
|
-
return false;
|
|
54
|
+
// Auto-open editor if requested (e.g., when opening from edit button)
|
|
55
|
+
// Only auto-open once when the prop first becomes true, not on every render
|
|
56
|
+
const hasAutoOpenedRef = useRef(false);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (autoOpenEditor && selectedImage && !isEditorOpen && !hasAutoOpenedRef.current) {
|
|
59
|
+
// Small delay to ensure ImagePicker is fully rendered
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
setIsEditorOpen(true);
|
|
62
|
+
hasAutoOpenedRef.current = true;
|
|
63
|
+
}, 100);
|
|
64
|
+
return () => clearTimeout(timer);
|
|
170
65
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const handleUseExternalUrl = () => {
|
|
175
|
-
if (!externalUrl.trim()) {
|
|
176
|
-
alert('Please enter an image URL');
|
|
177
|
-
return;
|
|
66
|
+
// Reset the flag when autoOpenEditor becomes false
|
|
67
|
+
if (!autoOpenEditor) {
|
|
68
|
+
hasAutoOpenedRef.current = false;
|
|
178
69
|
}
|
|
70
|
+
}, [autoOpenEditor, selectedImage, isEditorOpen]);
|
|
179
71
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
}
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!isEditorOpen) setTransforms({ scale: scale >= 0.1 ? scale : 1.0, positionX, positionY });
|
|
74
|
+
}, [scale, positionX, positionY, isEditorOpen]);
|
|
185
75
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
filename,
|
|
193
|
-
url: externalUrl,
|
|
194
|
-
size: 0,
|
|
195
|
-
mimeType: 'image/jpeg',
|
|
196
|
-
uploadedAt: new Date().toISOString(),
|
|
197
|
-
};
|
|
76
|
+
const calculatePreviewBaseScale = useCallback(() => {
|
|
77
|
+
if (!previewImageRef.current || !previewContainerRef.current) return;
|
|
78
|
+
const fit = Math.max(previewContainerRef.current.offsetWidth / previewImageRef.current.naturalWidth,
|
|
79
|
+
previewContainerRef.current.offsetHeight / previewImageRef.current.naturalHeight);
|
|
80
|
+
setPreviewBaseScale(fit);
|
|
81
|
+
}, []);
|
|
198
82
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
setIsOpen(false);
|
|
204
|
-
};
|
|
83
|
+
// Handle editor save - delegate to parent callbacks instead of saving directly
|
|
84
|
+
// This ensures all saves go through a single location (GlobalImageEditor.saveImageTransform)
|
|
85
|
+
const handleEditorSave = async () => {
|
|
86
|
+
if (!editorRef.current || !selectedImage) return;
|
|
205
87
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
88
|
+
try {
|
|
89
|
+
// 1. Get current values from Editor UI (this will also call onBrightnessChange and onBlurChange)
|
|
90
|
+
const final = await editorRef.current.flushSave();
|
|
91
|
+
|
|
92
|
+
// 2. Normalize position values - if they're -50% (centering value), treat as 0
|
|
93
|
+
const normalizedPositionX = final.positionX === -50 ? 0 : final.positionX;
|
|
94
|
+
const normalizedPositionY = final.positionY === -50 ? 0 : final.positionY;
|
|
95
|
+
|
|
96
|
+
// 3. If onEditorSave is provided, use it exclusively to prevent duplicate saves
|
|
97
|
+
// Otherwise, update parent state through individual callbacks
|
|
98
|
+
console.log('[ImagePicker] handleEditorSave - final values:', final, 'has onEditorSave:', !!onEditorSave);
|
|
99
|
+
if (onEditorSave) {
|
|
100
|
+
// onEditorSave handles everything - don't call individual handlers to avoid duplicates
|
|
101
|
+
console.log('[ImagePicker] Calling onEditorSave with:', { scale: final.scale, positionX: normalizedPositionX, positionY: normalizedPositionY, brightness: final.brightness, blur: final.blur });
|
|
102
|
+
onEditorSave(final.scale, normalizedPositionX, normalizedPositionY, final.brightness, final.blur);
|
|
103
|
+
} else {
|
|
104
|
+
// Fallback: update parent state through individual callbacks
|
|
105
|
+
// Since onEditorSave is not provided, call all callbacks and they should handle saving
|
|
106
|
+
// The last one (onPositionYChange) will trigger the final save
|
|
107
|
+
console.log('[ImagePicker] No onEditorSave, using individual callbacks');
|
|
108
|
+
// Update scale first
|
|
109
|
+
onScaleChange?.(final.scale);
|
|
110
|
+
// Update positions - these will trigger saves
|
|
111
|
+
onPositionXChange?.(normalizedPositionX);
|
|
112
|
+
// Last callback - this should trigger the final save with all values
|
|
113
|
+
// We need to ensure this saves immediately with all final values including brightness/blur
|
|
114
|
+
onPositionYChange?.(normalizedPositionY);
|
|
211
115
|
}
|
|
212
|
-
};
|
|
213
116
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
117
|
+
// 5. Close the editor
|
|
118
|
+
setIsEditorOpen(false);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error('[ImagePicker] Failed to get editor values:', error);
|
|
217
121
|
}
|
|
218
|
-
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
const aspectValue = useMemo(() => {
|
|
126
|
+
if (aspectRatio === 'auto') return undefined;
|
|
127
|
+
const [w, h] = aspectRatio.split('/').map(Number);
|
|
128
|
+
return w / h;
|
|
129
|
+
}, [aspectRatio]);
|
|
219
130
|
|
|
220
131
|
return (
|
|
221
|
-
<div className="space-y-
|
|
222
|
-
{/* Current Image Preview */}
|
|
132
|
+
<div className="space-y-6">
|
|
223
133
|
{selectedImage ? (
|
|
224
|
-
<div className="relative group">
|
|
225
|
-
<div className=
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
134
|
+
<div className="relative group max-w-md mx-auto">
|
|
135
|
+
<div className={`relative ${borderRadius} overflow-hidden border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 shadow-lg transition-all duration-300 hover:shadow-xl`} style={{ aspectRatio: aspectValue, width: '100%' }}>
|
|
136
|
+
<div ref={previewContainerRef} className="relative w-full h-full overflow-hidden">
|
|
137
|
+
{previewImageError ? (
|
|
138
|
+
<img
|
|
139
|
+
src={getFallbackImageUrl()}
|
|
140
|
+
alt={selectedImage.filename || 'Image not found'}
|
|
141
|
+
className="absolute max-w-none"
|
|
142
|
+
style={{
|
|
143
|
+
top: '50%', left: '50%', width: 'auto', height: 'auto',
|
|
144
|
+
minWidth: '100%', minHeight: '100%',
|
|
145
|
+
transform: 'translate(-50%, -50%)',
|
|
146
|
+
transformOrigin: 'center center',
|
|
147
|
+
objectFit: 'cover',
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<img
|
|
152
|
+
ref={previewImageRef}
|
|
153
|
+
src={selectedImage.url}
|
|
154
|
+
alt={selectedImage.filename}
|
|
155
|
+
className="absolute max-w-none"
|
|
156
|
+
onLoad={calculatePreviewBaseScale}
|
|
157
|
+
onError={() => setPreviewImageError(true)}
|
|
158
|
+
style={{
|
|
159
|
+
top: '50%', left: '50%', width: 'auto', height: 'auto',
|
|
160
|
+
minWidth: '100%', minHeight: '100%',
|
|
161
|
+
filter: getImageFilter(brightness, blur),
|
|
162
|
+
transform: previewBaseScale ? getImageTransform({ scale: transforms.scale, positionX: transforms.positionX, positionY: transforms.positionY, baseScale: previewBaseScale }, true) : 'translate(-50%, -50%)',
|
|
163
|
+
transformOrigin: 'center center',
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
241
167
|
</div>
|
|
168
|
+
{/* Overlay on hover */}
|
|
169
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors duration-300 pointer-events-none" />
|
|
242
170
|
</div>
|
|
243
|
-
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2 truncate">
|
|
244
|
-
{selectedImage.filename}
|
|
245
|
-
</p>
|
|
246
171
|
</div>
|
|
247
172
|
) : (
|
|
248
173
|
<div
|
|
249
|
-
onClick={() =>
|
|
250
|
-
className="
|
|
174
|
+
onClick={() => setIsBrowserOpen(true)}
|
|
175
|
+
className="aspect-video bg-gradient-to-br from-neutral-50 to-neutral-100 dark:from-neutral-900 dark:to-neutral-950 rounded-2xl border-2 border-dashed border-neutral-300 dark:border-neutral-800 flex flex-col items-center justify-center text-neutral-400 hover:border-primary hover:text-primary cursor-pointer transition-all duration-300 hover:shadow-lg group"
|
|
251
176
|
>
|
|
252
|
-
<
|
|
253
|
-
|
|
177
|
+
<div className="p-4 bg-white/50 dark:bg-neutral-800/50 rounded-full mb-3 group-hover:scale-110 transition-transform duration-300">
|
|
178
|
+
<ImageIcon size={28} className="group-hover:scale-110 transition-transform duration-300" />
|
|
179
|
+
</div>
|
|
180
|
+
<span className="text-xs font-bold uppercase tracking-[0.15em] group-hover:tracking-[0.2em] transition-all duration-300">Select Image</span>
|
|
254
181
|
</div>
|
|
255
182
|
)}
|
|
256
183
|
|
|
257
|
-
|
|
258
|
-
<div className="flex gap-2">
|
|
184
|
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
259
185
|
<button
|
|
260
|
-
onClick={() =>
|
|
261
|
-
className="flex-
|
|
186
|
+
onClick={() => setIsBrowserOpen(true)}
|
|
187
|
+
className="flex items-center justify-center gap-2 px-5 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-sm font-semibold text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-750 hover:border-neutral-300 dark:hover:border-neutral-600 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98]"
|
|
262
188
|
>
|
|
263
189
|
<Search size={16} />
|
|
264
190
|
Browse
|
|
265
191
|
</button>
|
|
266
192
|
<button
|
|
267
|
-
onClick={() =>
|
|
268
|
-
disabled={
|
|
269
|
-
className="flex-
|
|
270
|
-
>
|
|
271
|
-
<Upload size={16} />
|
|
272
|
-
{uploading ? 'Uploading...' : 'Upload'}
|
|
273
|
-
</button>
|
|
274
|
-
<button
|
|
275
|
-
onClick={() => setShowUrlInput(!showUrlInput)}
|
|
276
|
-
className="px-4 py-2 bg-dashboard-card border border-dashboard-border rounded-xl text-sm font-bold text-dashboard-text hover:bg-dashboard-bg transition-colors flex items-center justify-center gap-2"
|
|
193
|
+
onClick={() => setIsEditorOpen(true)}
|
|
194
|
+
disabled={!selectedImage}
|
|
195
|
+
className="flex items-center justify-center gap-2 px-5 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-sm font-semibold text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-750 hover:border-neutral-300 dark:hover:border-neutral-600 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:shadow-sm disabled:hover:scale-100"
|
|
277
196
|
>
|
|
278
|
-
<
|
|
279
|
-
|
|
197
|
+
<Settings size={16} />
|
|
198
|
+
Edit
|
|
280
199
|
</button>
|
|
281
200
|
</div>
|
|
282
201
|
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
Use
|
|
307
|
-
</button>
|
|
308
|
-
<button
|
|
309
|
-
onClick={() => {
|
|
310
|
-
setShowUrlInput(false);
|
|
311
|
-
setExternalUrl('');
|
|
312
|
-
}}
|
|
313
|
-
className="px-4 py-2 bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400 rounded-lg text-sm font-bold hover:bg-neutral-300 dark:hover:bg-neutral-600 transition-colors"
|
|
314
|
-
>
|
|
315
|
-
Cancel
|
|
316
|
-
</button>
|
|
317
|
-
</div>
|
|
318
|
-
</div>
|
|
319
|
-
)}
|
|
320
|
-
|
|
321
|
-
{/* Effects Controls */}
|
|
322
|
-
{showEffects && selectedImage && (
|
|
323
|
-
<div className="space-y-3 p-4 bg-dashboard-bg rounded-xl border border-dashboard-border">
|
|
324
|
-
<div className="flex items-center gap-2 mb-3">
|
|
325
|
-
<SlidersHorizontal size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
326
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold">
|
|
327
|
-
Image Effects
|
|
328
|
-
</label>
|
|
329
|
-
</div>
|
|
330
|
-
|
|
331
|
-
{/* Brightness */}
|
|
332
|
-
<div>
|
|
333
|
-
<div className="flex items-center justify-between mb-2">
|
|
334
|
-
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400">
|
|
335
|
-
Brightness
|
|
336
|
-
</label>
|
|
337
|
-
<span className="text-xs font-bold text-neutral-500 dark:text-neutral-500">
|
|
338
|
-
{brightness}%
|
|
339
|
-
</span>
|
|
340
|
-
</div>
|
|
341
|
-
<input
|
|
342
|
-
type="range"
|
|
343
|
-
min="0"
|
|
344
|
-
max="200"
|
|
345
|
-
value={brightness}
|
|
346
|
-
onChange={(e) => onBrightnessChange?.(parseInt(e.target.value))}
|
|
347
|
-
className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-primary"
|
|
348
|
-
/>
|
|
349
|
-
</div>
|
|
350
|
-
|
|
351
|
-
{/* Blur */}
|
|
352
|
-
<div>
|
|
353
|
-
<div className="flex items-center justify-between mb-2">
|
|
354
|
-
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400">
|
|
355
|
-
Blur
|
|
356
|
-
</label>
|
|
357
|
-
<span className="text-xs font-bold text-neutral-500 dark:text-neutral-500">
|
|
358
|
-
{blur}px
|
|
359
|
-
</span>
|
|
360
|
-
</div>
|
|
361
|
-
<input
|
|
362
|
-
type="range"
|
|
363
|
-
min="0"
|
|
364
|
-
max="20"
|
|
365
|
-
value={blur}
|
|
366
|
-
onChange={(e) => onBlurChange?.(parseInt(e.target.value))}
|
|
367
|
-
className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-primary"
|
|
368
|
-
/>
|
|
369
|
-
</div>
|
|
370
|
-
</div>
|
|
371
|
-
)}
|
|
372
|
-
|
|
373
|
-
{/* Hidden file input */}
|
|
374
|
-
<input
|
|
375
|
-
ref={fileInputRef}
|
|
376
|
-
type="file"
|
|
377
|
-
accept="image/*"
|
|
378
|
-
onChange={handleFileSelect}
|
|
379
|
-
className="hidden"
|
|
380
|
-
/>
|
|
381
|
-
|
|
382
|
-
{/* Image Browser Modal */}
|
|
383
|
-
{isOpen && (
|
|
384
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
385
|
-
<div
|
|
386
|
-
ref={modalRef}
|
|
387
|
-
className={`w-full max-w-4xl max-h-[80vh] bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 shadow-2xl flex flex-col ${
|
|
388
|
-
darkMode ? 'dark' : ''
|
|
389
|
-
}`}
|
|
390
|
-
>
|
|
391
|
-
{/* Modal Header */}
|
|
392
|
-
<div className="p-6 border-b border-dashboard-border flex items-center justify-between">
|
|
393
|
-
<h2 className="text-lg font-black uppercase tracking-tighter text-dashboard-text">
|
|
394
|
-
Select Image
|
|
395
|
-
</h2>
|
|
396
|
-
<button
|
|
397
|
-
onClick={() => setIsOpen(false)}
|
|
398
|
-
className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
|
|
399
|
-
>
|
|
400
|
-
<X size={20} className="text-neutral-500 dark:text-neutral-400" />
|
|
401
|
-
</button>
|
|
402
|
-
</div>
|
|
403
|
-
|
|
404
|
-
{/* Search Bar */}
|
|
405
|
-
<div className="p-4 border-b border-dashboard-border">
|
|
406
|
-
<div className="relative">
|
|
407
|
-
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
|
|
408
|
-
<input
|
|
409
|
-
type="text"
|
|
410
|
-
value={searchQuery}
|
|
411
|
-
onChange={(e) => {
|
|
412
|
-
setSearchQuery(e.target.value);
|
|
413
|
-
setPage(1);
|
|
414
|
-
}}
|
|
415
|
-
placeholder="Search images..."
|
|
416
|
-
className="w-full pl-10 pr-4 py-2 bg-dashboard-card border border-dashboard-border rounded-xl text-sm font-bold outline-none focus:border-primary transition-all text-dashboard-text"
|
|
417
|
-
/>
|
|
418
|
-
</div>
|
|
419
|
-
</div>
|
|
420
|
-
|
|
421
|
-
{/* Image Grid */}
|
|
422
|
-
<div className="flex-1 overflow-y-auto p-4">
|
|
423
|
-
{loading && images.length === 0 ? (
|
|
424
|
-
<div className="text-center py-12">
|
|
425
|
-
<div className="animate-pulse text-neutral-400 dark:text-neutral-500">
|
|
426
|
-
Loading images...
|
|
427
|
-
</div>
|
|
428
|
-
</div>
|
|
429
|
-
) : images.length === 0 ? (
|
|
430
|
-
<div className="text-center py-12">
|
|
431
|
-
<ImageIcon size={48} className="mx-auto text-neutral-300 dark:text-neutral-700 mb-4" />
|
|
432
|
-
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-2">
|
|
433
|
-
No images found
|
|
434
|
-
</p>
|
|
435
|
-
<button
|
|
436
|
-
onClick={() => fileInputRef.current?.click()}
|
|
437
|
-
className="text-sm font-bold text-primary hover:underline"
|
|
438
|
-
>
|
|
439
|
-
Upload your first image
|
|
440
|
-
</button>
|
|
441
|
-
</div>
|
|
442
|
-
) : (
|
|
443
|
-
<div className="grid grid-cols-4 gap-4">
|
|
444
|
-
{images.map((image) => (
|
|
445
|
-
<button
|
|
446
|
-
key={image.id}
|
|
447
|
-
onClick={() => {
|
|
448
|
-
handleSelectImage(image);
|
|
449
|
-
setIsOpen(false);
|
|
450
|
-
}}
|
|
451
|
-
className={`relative aspect-square rounded-xl overflow-hidden border-2 transition-all ${
|
|
452
|
-
selectedImage?.id === image.id
|
|
453
|
-
? 'border-primary ring-2 ring-primary/20'
|
|
454
|
-
: 'border-neutral-200 dark:border-neutral-700 hover:border-primary/50'
|
|
455
|
-
}`}
|
|
456
|
-
>
|
|
457
|
-
<img
|
|
458
|
-
src={image.url}
|
|
459
|
-
alt={image.alt || image.filename}
|
|
460
|
-
className="w-full h-full object-cover"
|
|
461
|
-
/>
|
|
462
|
-
{selectedImage?.id === image.id && (
|
|
463
|
-
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
|
|
464
|
-
<div className="bg-primary text-white rounded-full p-2">
|
|
465
|
-
<Check size={16} />
|
|
466
|
-
</div>
|
|
467
|
-
</div>
|
|
468
|
-
)}
|
|
469
|
-
</button>
|
|
470
|
-
))}
|
|
471
|
-
</div>
|
|
472
|
-
)}
|
|
473
|
-
|
|
474
|
-
{/* Load More */}
|
|
475
|
-
{hasMore && !loading && (
|
|
476
|
-
<div className="text-center mt-4">
|
|
477
|
-
<button
|
|
478
|
-
onClick={() => loadImages(false)}
|
|
479
|
-
className="px-4 py-2 bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-lg text-sm font-bold hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
|
480
|
-
>
|
|
481
|
-
Load More
|
|
482
|
-
</button>
|
|
483
|
-
</div>
|
|
484
|
-
)}
|
|
485
|
-
</div>
|
|
486
|
-
|
|
487
|
-
{/* External URL Input in Modal */}
|
|
488
|
-
<div className="p-4 border-t border-neutral-200 dark:border-neutral-800">
|
|
489
|
-
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase mb-2 block">
|
|
490
|
-
Or use external URL
|
|
491
|
-
</label>
|
|
492
|
-
<div className="flex gap-2">
|
|
493
|
-
<input
|
|
494
|
-
type="url"
|
|
495
|
-
value={externalUrl}
|
|
496
|
-
onChange={(e) => setExternalUrl(e.target.value)}
|
|
497
|
-
onKeyDown={(e) => {
|
|
498
|
-
if (e.key === 'Enter') {
|
|
499
|
-
handleUseExternalUrl();
|
|
500
|
-
}
|
|
501
|
-
}}
|
|
502
|
-
placeholder="https://example.com/image.jpg"
|
|
503
|
-
className="flex-1 px-3 py-2 bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 rounded-lg text-sm font-bold outline-none focus:border-primary transition-all dark:text-neutral-100"
|
|
504
|
-
/>
|
|
505
|
-
<button
|
|
506
|
-
onClick={handleUseExternalUrl}
|
|
507
|
-
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-bold hover:bg-primary/90 transition-colors flex items-center gap-2"
|
|
508
|
-
>
|
|
509
|
-
<LinkIcon size={16} />
|
|
510
|
-
Use URL
|
|
511
|
-
</button>
|
|
512
|
-
</div>
|
|
202
|
+
{isEditorOpen && selectedImage && mounted && portalTarget && createPortal(
|
|
203
|
+
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-neutral-950/80 dark:bg-neutral-950/90 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
|
204
|
+
<div className="w-full max-w-4xl bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden shadow-2xl dark:shadow-neutral-950/50 flex flex-col max-h-[85vh] border border-neutral-200 dark:border-neutral-800 animate-in zoom-in-95 duration-300">
|
|
205
|
+
<div className="flex-1 overflow-hidden p-4 lg:p-6">
|
|
206
|
+
<ImageEditor
|
|
207
|
+
ref={editorRef}
|
|
208
|
+
imageUrl={selectedImage.url}
|
|
209
|
+
scale={transforms.scale}
|
|
210
|
+
positionX={transforms.positionX}
|
|
211
|
+
positionY={transforms.positionY}
|
|
212
|
+
brightness={brightness}
|
|
213
|
+
blur={blur}
|
|
214
|
+
onScaleChange={(s) => setTransforms(t => ({ ...t, scale: s }))}
|
|
215
|
+
onPositionChange={(x, y) => {
|
|
216
|
+
// Only update local state during drag - don't trigger saves
|
|
217
|
+
// Saves will happen when editor closes via onEditorSave
|
|
218
|
+
setTransforms(t => ({ ...t, positionX: x, positionY: y }));
|
|
219
|
+
}}
|
|
220
|
+
onBrightnessChange={onBrightnessChange}
|
|
221
|
+
onBlurChange={onBlurChange}
|
|
222
|
+
aspectRatio={aspectRatio}
|
|
223
|
+
borderRadius={borderRadius}
|
|
224
|
+
/>
|
|
513
225
|
</div>
|
|
514
|
-
|
|
515
|
-
{/* Modal Footer */}
|
|
516
|
-
<div className="p-4 border-t border-neutral-200 dark:border-neutral-800 flex items-center justify-between">
|
|
226
|
+
<div className="px-6 py-5 border-t border-neutral-200 dark:border-neutral-800 bg-neutral-50/80 dark:bg-neutral-900/80 backdrop-blur-sm flex justify-end gap-3">
|
|
517
227
|
<button
|
|
518
|
-
onClick={() =>
|
|
519
|
-
|
|
520
|
-
className="px-4 py-2 bg-primary text-white rounded-xl text-sm font-bold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
|
228
|
+
onClick={() => setIsEditorOpen(false)}
|
|
229
|
+
className="px-6 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl font-semibold text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98]"
|
|
521
230
|
>
|
|
522
|
-
|
|
523
|
-
{uploading ? 'Uploading...' : 'Upload New Image'}
|
|
231
|
+
Cancel
|
|
524
232
|
</button>
|
|
525
233
|
<button
|
|
526
|
-
onClick={
|
|
527
|
-
|
|
528
|
-
setExternalUrl('');
|
|
529
|
-
}}
|
|
530
|
-
className="px-4 py-2 bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-xl text-sm font-bold hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
|
234
|
+
onClick={handleEditorSave}
|
|
235
|
+
className="px-8 py-2.5 bg-primary text-white rounded-xl font-semibold shadow-md hover:shadow-lg hover:bg-primary/90 transition-all duration-200 active:scale-[0.98]"
|
|
531
236
|
>
|
|
532
|
-
|
|
237
|
+
Done
|
|
533
238
|
</button>
|
|
534
239
|
</div>
|
|
535
240
|
</div>
|
|
536
|
-
</div
|
|
241
|
+
</div>,
|
|
242
|
+
portalTarget
|
|
537
243
|
)}
|
|
244
|
+
|
|
245
|
+
<ImageBrowserModal
|
|
246
|
+
isOpen={isBrowserOpen}
|
|
247
|
+
onClose={() => setIsBrowserOpen(false)}
|
|
248
|
+
onSelectImage={(image) => {
|
|
249
|
+
setSelectedImage(image);
|
|
250
|
+
onChange?.(image);
|
|
251
|
+
setIsBrowserOpen(false);
|
|
252
|
+
}}
|
|
253
|
+
selectedImageId={(() => {
|
|
254
|
+
// Use resolved image's filename/URL if available, otherwise fall back to value
|
|
255
|
+
// This ensures semantic IDs are resolved to actual filenames for matching
|
|
256
|
+
if (selectedImage) {
|
|
257
|
+
return selectedImage.filename || selectedImage.url || selectedImage.id || value;
|
|
258
|
+
}
|
|
259
|
+
return value;
|
|
260
|
+
})()}
|
|
261
|
+
darkMode={false}
|
|
262
|
+
/>
|
|
538
263
|
</div>
|
|
539
264
|
);
|
|
540
|
-
}
|
|
541
|
-
|
|
265
|
+
}
|