@jhits/plugin-images 0.0.4 → 0.0.5

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.
@@ -1,541 +1,227 @@
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 { Upload, Search, X, Image as ImageIcon, SlidersHorizontal, Check, Link as LinkIcon } from 'lucide-react';
10
- import type { ImageMetadata, ImagePickerProps } from '../types';
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';
11
12
 
12
13
  export function ImagePicker({
13
- value,
14
- onChange,
15
- darkMode = false,
16
- showEffects = true,
17
- brightness = 100,
18
- blur = 0,
19
- onBrightnessChange,
20
- onBlurChange,
14
+ value, onChange, brightness = 100, blur = 0, scale = 1.0, positionX = 0, positionY = 0,
15
+ aspectRatio = '16/9', borderRadius = 'rounded-xl', onBrightnessChange, onBlurChange,
16
+ onScaleChange, onPositionXChange, onPositionYChange, onEditorSave, autoOpenEditor = false,
21
17
  }: 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
18
 
35
- // Load images
36
- const loadImages = async (reset = false) => {
37
- if (loading) return;
38
-
39
- setLoading(true);
40
- try {
41
- const currentPage = reset ? 1 : page;
42
- const response = await fetch(
43
- `/api/plugin-images/list?page=${currentPage}&limit=20&search=${encodeURIComponent(searchQuery)}`
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
- };
19
+ const [transforms, setTransforms] = useState({ scale: scale >= 0.1 ? scale : 1.0, positionX, positionY });
20
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
21
+ const [isBrowserOpen, setIsBrowserOpen] = useState(false);
22
+ const [previewBaseScale, setPreviewBaseScale] = useState<number | null>(null);
23
+ const [mounted, setMounted] = useState(false);
24
+ const previewImageRef = useRef<HTMLImageElement>(null);
25
+ const previewContainerRef = useRef<HTMLDivElement>(null);
26
+ const editorRef = useRef<ImageEditorHandle | null>(null);
27
+
28
+ const { selectedImage, setSelectedImage } = useImagePicker({ value, images: [] });
62
29
 
63
- // Load images when modal opens
30
+ // Handle SSR - ensure we only render portal on client
64
31
  useEffect(() => {
65
- if (isOpen) {
66
- loadImages(true);
67
- }
68
- }, [isOpen, searchQuery]);
32
+ setMounted(true);
33
+ }, []);
69
34
 
70
- // Find selected image from value (can be ID or URL)
35
+ // Auto-open editor if requested (e.g., when opening from edit button)
36
+ // Only auto-open once when the prop first becomes true, not on every render
37
+ const hasAutoOpenedRef = useRef(false);
71
38
  useEffect(() => {
72
- if (value) {
73
- // First, try to find by ID (preferred method)
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);
39
+ if (autoOpenEditor && selectedImage && !isEditorOpen && !hasAutoOpenedRef.current) {
40
+ // Small delay to ensure ImagePicker is fully rendered
41
+ const timer = setTimeout(() => {
42
+ setIsEditorOpen(true);
43
+ hasAutoOpenedRef.current = true;
44
+ }, 100);
45
+ return () => clearTimeout(timer);
46
+ }
47
+ // Reset the flag when autoOpenEditor becomes false
48
+ if (!autoOpenEditor) {
49
+ hasAutoOpenedRef.current = false;
101
50
  }
102
- }, [value, images]);
51
+ }, [autoOpenEditor, selectedImage, isEditorOpen]);
103
52
 
104
- // Handle file upload
105
- const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
106
- const file = e.target.files?.[0];
107
- if (!file) return;
53
+ useEffect(() => {
54
+ if (!isEditorOpen) setTransforms({ scale: scale >= 0.1 ? scale : 1.0, positionX, positionY });
55
+ }, [scale, positionX, positionY, isEditorOpen]);
108
56
 
109
- setUploading(true);
110
- try {
111
- const formData = new FormData();
112
- formData.append('file', file);
57
+ const calculatePreviewBaseScale = useCallback(() => {
58
+ if (!previewImageRef.current || !previewContainerRef.current) return;
59
+ const fit = Math.max(previewContainerRef.current.offsetWidth / previewImageRef.current.naturalWidth,
60
+ previewContainerRef.current.offsetHeight / previewImageRef.current.naturalHeight);
61
+ setPreviewBaseScale(fit);
62
+ }, []);
113
63
 
114
- const response = await fetch('/api/plugin-images/upload', {
115
- method: 'POST',
116
- body: formData,
117
- });
64
+ // Handle editor save - delegate to parent callbacks instead of saving directly
65
+ // This ensures all saves go through a single location (GlobalImageEditor.saveImageTransform)
66
+ const handleEditorSave = async () => {
67
+ if (!editorRef.current || !selectedImage) return;
118
68
 
119
- const data = await response.json();
120
-
121
- if (data.success && data.image) {
122
- // Reload images list
123
- await loadImages(true);
124
- // Select the newly uploaded image
125
- setSelectedImage(data.image);
126
- onChange(data.image);
69
+ try {
70
+ // 1. Get current values from Editor UI (this will also call onBrightnessChange and onBlurChange)
71
+ const final = await editorRef.current.flushSave();
72
+
73
+ // 2. Normalize position values - if they're -50% (centering value), treat as 0
74
+ const normalizedPositionX = final.positionX === -50 ? 0 : final.positionX;
75
+ const normalizedPositionY = final.positionY === -50 ? 0 : final.positionY;
76
+
77
+ // 3. If onEditorSave is provided, use it exclusively to prevent duplicate saves
78
+ // Otherwise, update parent state through individual callbacks
79
+ console.log('[ImagePicker] handleEditorSave - final values:', final, 'has onEditorSave:', !!onEditorSave);
80
+ if (onEditorSave) {
81
+ // onEditorSave handles everything - don't call individual handlers to avoid duplicates
82
+ console.log('[ImagePicker] Calling onEditorSave with:', { scale: final.scale, positionX: normalizedPositionX, positionY: normalizedPositionY, brightness: final.brightness, blur: final.blur });
83
+ onEditorSave(final.scale, normalizedPositionX, normalizedPositionY, final.brightness, final.blur);
127
84
  } else {
128
- alert(data.error || 'Failed to upload image');
85
+ // Fallback: update parent state through individual callbacks
86
+ // Since onEditorSave is not provided, call all callbacks and they should handle saving
87
+ // The last one (onPositionYChange) will trigger the final save
88
+ console.log('[ImagePicker] No onEditorSave, using individual callbacks');
89
+ // Update scale first
90
+ onScaleChange?.(final.scale);
91
+ // Update positions - these will trigger saves
92
+ onPositionXChange?.(normalizedPositionX);
93
+ // Last callback - this should trigger the final save with all values
94
+ // We need to ensure this saves immediately with all final values including brightness/blur
95
+ onPositionYChange?.(normalizedPositionY);
129
96
  }
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
- }
138
- }
139
- };
140
97
 
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
- };
149
-
150
- // Handle remove image
151
- const handleRemove = () => {
152
- setSelectedImage(null);
153
- onChange(null);
154
- };
155
-
156
- // Validate if URL is an image
157
- const isValidImageUrl = (url: string): boolean => {
158
- try {
159
- const urlObj = new URL(url);
160
- const pathname = urlObj.pathname.toLowerCase();
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;
170
- }
171
- };
172
-
173
- // Handle external URL
174
- const handleUseExternalUrl = () => {
175
- if (!externalUrl.trim()) {
176
- alert('Please enter an image URL');
177
- return;
178
- }
179
-
180
- if (!isValidImageUrl(externalUrl)) {
181
- if (!confirm('This might not be a valid image URL. Continue anyway?')) {
182
- return;
183
- }
98
+ // 5. Close the editor
99
+ setIsEditorOpen(false);
100
+ } catch (error) {
101
+ console.error('[ImagePicker] Failed to get editor values:', error);
184
102
  }
185
-
186
- // Create image metadata from external URL
187
- const urlParts = externalUrl.split('/');
188
- const filename = urlParts[urlParts.length - 1].split('?')[0] || 'external-image.jpg';
189
-
190
- const externalImage: ImageMetadata = {
191
- id: `external-${Date.now()}`,
192
- filename,
193
- url: externalUrl,
194
- size: 0,
195
- mimeType: 'image/jpeg',
196
- uploadedAt: new Date().toISOString(),
197
- };
198
-
199
- setSelectedImage(externalImage);
200
- onChange(externalImage);
201
- setExternalUrl('');
202
- setShowUrlInput(false);
203
- setIsOpen(false);
204
103
  };
205
104
 
206
- // Close modal on outside click
207
- useEffect(() => {
208
- const handleClickOutside = (event: MouseEvent) => {
209
- if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
210
- setIsOpen(false);
211
- }
212
- };
213
105
 
214
- if (isOpen) {
215
- document.addEventListener('mousedown', handleClickOutside);
216
- return () => document.removeEventListener('mousedown', handleClickOutside);
217
- }
218
- }, [isOpen]);
106
+ const aspectValue = useMemo(() => {
107
+ if (aspectRatio === 'auto') return undefined;
108
+ const [w, h] = aspectRatio.split('/').map(Number);
109
+ return w / h;
110
+ }, [aspectRatio]);
219
111
 
220
112
  return (
221
- <div className="space-y-4">
222
- {/* Current Image Preview */}
113
+ <div className="space-y-6">
223
114
  {selectedImage ? (
224
- <div className="relative group">
225
- <div className="relative rounded-xl overflow-hidden border-2 border-dashboard-border aspect-video bg-dashboard-bg">
226
- <img
227
- src={selectedImage.url}
228
- alt={selectedImage.alt || selectedImage.filename}
229
- className="w-full h-full object-cover"
230
- style={{
231
- filter: `brightness(${brightness}%) blur(${blur}px)`,
232
- }}
233
- />
234
- <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
235
- <button
236
- onClick={handleRemove}
237
- className="opacity-0 group-hover:opacity-100 p-2 bg-red-500 text-white rounded-full transition-opacity"
238
- >
239
- <X size={16} />
240
- </button>
115
+ <div className="relative group max-w-md mx-auto">
116
+ <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%' }}>
117
+ <div ref={previewContainerRef} className="relative w-full h-full overflow-hidden">
118
+ <img
119
+ ref={previewImageRef} src={selectedImage.url} alt={selectedImage.filename}
120
+ className="absolute max-w-none" onLoad={calculatePreviewBaseScale}
121
+ style={{
122
+ top: '50%', left: '50%', width: 'auto', height: 'auto',
123
+ minWidth: '100%', minHeight: '100%',
124
+ filter: getImageFilter(brightness, blur),
125
+ transform: previewBaseScale ? getImageTransform({ scale: transforms.scale, positionX: transforms.positionX, positionY: transforms.positionY, baseScale: previewBaseScale }, true) : 'translate(-50%, -50%)',
126
+ transformOrigin: 'center center',
127
+ }}
128
+ />
241
129
  </div>
130
+ {/* Overlay on hover */}
131
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors duration-300 pointer-events-none" />
242
132
  </div>
243
- <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2 truncate">
244
- {selectedImage.filename}
245
- </p>
246
133
  </div>
247
134
  ) : (
248
135
  <div
249
- onClick={() => setIsOpen(true)}
250
- className="relative aspect-video bg-dashboard-bg rounded-xl border-2 border-dashed border-dashboard-border flex flex-col items-center justify-center text-neutral-400 dark:text-neutral-500 hover:bg-dashboard-card hover:border-primary cursor-pointer transition-all"
136
+ onClick={() => setIsBrowserOpen(true)}
137
+ 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
138
  >
252
- <ImageIcon size={32} className="mb-2" />
253
- <span className="text-xs font-bold uppercase tracking-wider">Select Image</span>
139
+ <div className="p-4 bg-white/50 dark:bg-neutral-800/50 rounded-full mb-3 group-hover:scale-110 transition-transform duration-300">
140
+ <ImageIcon size={28} className="group-hover:scale-110 transition-transform duration-300" />
141
+ </div>
142
+ <span className="text-xs font-bold uppercase tracking-[0.15em] group-hover:tracking-[0.2em] transition-all duration-300">Select Image</span>
254
143
  </div>
255
144
  )}
256
145
 
257
- {/* Action Buttons */}
258
- <div className="flex gap-2">
146
+ <div className="flex flex-wrap items-center justify-center gap-3">
259
147
  <button
260
- onClick={() => setIsOpen(true)}
261
- className="flex-1 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"
148
+ onClick={() => setIsBrowserOpen(true)}
149
+ 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
150
  >
263
151
  <Search size={16} />
264
152
  Browse
265
153
  </button>
266
154
  <button
267
- onClick={() => fileInputRef.current?.click()}
268
- disabled={uploading}
269
- className="flex-1 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 justify-center gap-2"
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"
155
+ onClick={() => setIsEditorOpen(true)}
156
+ disabled={!selectedImage}
157
+ 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
158
  >
278
- <LinkIcon size={16} />
279
- URL
159
+ <Settings size={16} />
160
+ Edit
280
161
  </button>
281
162
  </div>
282
163
 
283
- {/* External URL Input */}
284
- {showUrlInput && (
285
- <div className="p-4 bg-dashboard-bg rounded-xl border border-dashboard-border space-y-2">
286
- <label className="text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">
287
- External Image URL
288
- </label>
289
- <div className="flex gap-2">
290
- <input
291
- type="url"
292
- value={externalUrl}
293
- onChange={(e) => setExternalUrl(e.target.value)}
294
- onKeyDown={(e) => {
295
- if (e.key === 'Enter') {
296
- handleUseExternalUrl();
297
- }
298
- }}
299
- placeholder="https://example.com/image.jpg"
300
- 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"
301
- />
302
- <button
303
- onClick={handleUseExternalUrl}
304
- className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-bold hover:bg-primary/90 transition-colors"
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>
164
+ {isEditorOpen && selectedImage && mounted && createPortal(
165
+ <div className="fixed inset-0 z-[100] 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">
166
+ <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">
167
+ <div className="flex-1 overflow-hidden p-4 lg:p-6">
168
+ <ImageEditor
169
+ ref={editorRef}
170
+ imageUrl={selectedImage.url}
171
+ scale={transforms.scale}
172
+ positionX={transforms.positionX}
173
+ positionY={transforms.positionY}
174
+ brightness={brightness}
175
+ blur={blur}
176
+ onScaleChange={(s) => setTransforms(t => ({ ...t, scale: s }))}
177
+ onPositionChange={(x, y) => {
178
+ // Only update local state during drag - don't trigger saves
179
+ // Saves will happen when editor closes via onEditorSave
180
+ setTransforms(t => ({ ...t, positionX: x, positionY: y }));
181
+ }}
182
+ onBrightnessChange={onBrightnessChange}
183
+ onBlurChange={onBlurChange}
184
+ aspectRatio={aspectRatio}
185
+ borderRadius={borderRadius}
186
+ />
513
187
  </div>
514
-
515
- {/* Modal Footer */}
516
- <div className="p-4 border-t border-neutral-200 dark:border-neutral-800 flex items-center justify-between">
188
+ <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
189
  <button
518
- onClick={() => fileInputRef.current?.click()}
519
- disabled={uploading}
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"
190
+ onClick={() => setIsEditorOpen(false)}
191
+ 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
192
  >
522
- <Upload size={16} />
523
- {uploading ? 'Uploading...' : 'Upload New Image'}
193
+ Cancel
524
194
  </button>
525
195
  <button
526
- onClick={() => {
527
- setIsOpen(false);
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"
196
+ onClick={handleEditorSave}
197
+ 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
198
  >
532
- Close
199
+ Done
533
200
  </button>
534
201
  </div>
535
202
  </div>
536
- </div>
203
+ </div>,
204
+ document.body
537
205
  )}
206
+
207
+ <ImageBrowserModal
208
+ isOpen={isBrowserOpen}
209
+ onClose={() => setIsBrowserOpen(false)}
210
+ onSelectImage={(image) => {
211
+ setSelectedImage(image);
212
+ onChange?.(image);
213
+ setIsBrowserOpen(false);
214
+ }}
215
+ selectedImageId={(() => {
216
+ // Use resolved image's filename/URL if available, otherwise fall back to value
217
+ // This ensures semantic IDs are resolved to actual filenames for matching
218
+ if (selectedImage) {
219
+ return selectedImage.filename || selectedImage.url || selectedImage.id || value;
220
+ }
221
+ return value;
222
+ })()}
223
+ darkMode={false}
224
+ />
538
225
  </div>
539
226
  );
540
- }
541
-
227
+ }