@jhits/plugin-images 0.0.11 → 0.0.12

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.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/src/api/fallback/route.ts +69 -0
  3. package/src/api/index.ts +10 -0
  4. package/src/api/list/index.ts +96 -0
  5. package/src/api/resolve/route.ts +241 -0
  6. package/src/api/router.ts +85 -0
  7. package/src/api/upload/index.ts +88 -0
  8. package/src/api/uploads/[filename]/route.ts +93 -0
  9. package/src/api-server.ts +11 -0
  10. package/src/assets/noimagefound.jpg +0 -0
  11. package/src/components/BackgroundImage.d.ts +11 -0
  12. package/src/components/BackgroundImage.d.ts.map +1 -0
  13. package/src/components/BackgroundImage.tsx +92 -0
  14. package/src/components/GlobalImageEditor/config.d.ts +9 -0
  15. package/src/components/GlobalImageEditor/config.d.ts.map +1 -0
  16. package/src/components/GlobalImageEditor/config.ts +21 -0
  17. package/src/components/GlobalImageEditor/eventHandlers.d.ts +20 -0
  18. package/src/components/GlobalImageEditor/eventHandlers.d.ts.map +1 -0
  19. package/src/components/GlobalImageEditor/eventHandlers.ts +267 -0
  20. package/src/components/GlobalImageEditor/imageDetection.d.ts +16 -0
  21. package/src/components/GlobalImageEditor/imageDetection.d.ts.map +1 -0
  22. package/src/components/GlobalImageEditor/imageDetection.ts +160 -0
  23. package/src/components/GlobalImageEditor/imageSetup.d.ts +9 -0
  24. package/src/components/GlobalImageEditor/imageSetup.d.ts.map +1 -0
  25. package/src/components/GlobalImageEditor/imageSetup.ts +306 -0
  26. package/src/components/GlobalImageEditor/saveLogic.d.ts +26 -0
  27. package/src/components/GlobalImageEditor/saveLogic.d.ts.map +1 -0
  28. package/src/components/GlobalImageEditor/saveLogic.ts +133 -0
  29. package/src/components/GlobalImageEditor/stylingDetection.d.ts +9 -0
  30. package/src/components/GlobalImageEditor/stylingDetection.d.ts.map +1 -0
  31. package/src/components/GlobalImageEditor/stylingDetection.ts +122 -0
  32. package/src/components/GlobalImageEditor/transformParsing.d.ts +16 -0
  33. package/src/components/GlobalImageEditor/transformParsing.d.ts.map +1 -0
  34. package/src/components/GlobalImageEditor/transformParsing.ts +83 -0
  35. package/src/components/GlobalImageEditor/types.d.ts +36 -0
  36. package/src/components/GlobalImageEditor/types.d.ts.map +1 -0
  37. package/src/components/GlobalImageEditor/types.ts +39 -0
  38. package/src/components/GlobalImageEditor.d.ts +8 -0
  39. package/src/components/GlobalImageEditor.d.ts.map +1 -0
  40. package/src/components/GlobalImageEditor.tsx +327 -0
  41. package/src/components/Image.d.ts +22 -0
  42. package/src/components/Image.d.ts.map +1 -0
  43. package/src/components/Image.tsx +343 -0
  44. package/src/components/ImageBrowserModal.d.ts +13 -0
  45. package/src/components/ImageBrowserModal.d.ts.map +1 -0
  46. package/src/components/ImageBrowserModal.tsx +837 -0
  47. package/src/components/ImageEditor.d.ts +27 -0
  48. package/src/components/ImageEditor.d.ts.map +1 -0
  49. package/src/components/ImageEditor.tsx +323 -0
  50. package/src/components/ImageEffectsPanel.tsx +116 -0
  51. package/src/components/ImagePicker.d.ts +3 -0
  52. package/src/components/ImagePicker.d.ts.map +1 -0
  53. package/src/components/ImagePicker.tsx +265 -0
  54. package/src/components/ImagesPluginInit.d.ts +24 -0
  55. package/src/components/ImagesPluginInit.d.ts.map +1 -0
  56. package/src/components/ImagesPluginInit.tsx +31 -0
  57. package/src/components/index.ts +10 -0
  58. package/src/config.ts +179 -0
  59. package/src/hooks/useImagePicker.d.ts +20 -0
  60. package/src/hooks/useImagePicker.d.ts.map +1 -0
  61. package/src/hooks/useImagePicker.ts +344 -0
  62. package/src/index.server.ts +12 -0
  63. package/src/index.tsx +56 -0
  64. package/src/init.tsx +58 -0
  65. package/src/types/index.d.ts +80 -0
  66. package/src/types/index.d.ts.map +1 -0
  67. package/src/types/index.ts +84 -0
  68. package/src/utils/fallback.d.ts +27 -0
  69. package/src/utils/fallback.d.ts.map +1 -0
  70. package/src/utils/fallback.ts +73 -0
  71. package/src/utils/transforms.d.ts +26 -0
  72. package/src/utils/transforms.d.ts.map +1 -0
  73. package/src/utils/transforms.ts +54 -0
  74. package/src/views/ImageManager.d.ts +10 -0
  75. package/src/views/ImageManager.d.ts.map +1 -0
  76. package/src/views/ImageManager.tsx +30 -0
@@ -0,0 +1,837 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { Upload, Search, X, Image as ImageIcon, Check, Link as LinkIcon, Loader2, Grid3x3, Grid } from 'lucide-react';
6
+ import type { ImageMetadata } from '../types';
7
+
8
+ // Masonry Grid Component that maintains item positions
9
+ function MasonryGrid({
10
+ images,
11
+ selectedImageId,
12
+ onSelectImage,
13
+ onClose,
14
+ fixedColumns,
15
+ }: {
16
+ images: ImageMetadata[];
17
+ selectedImageId?: string;
18
+ onSelectImage: (image: ImageMetadata) => void;
19
+ onClose: () => void;
20
+ fixedColumns?: number;
21
+ }) {
22
+
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+ const [columnCount, setColumnCount] = useState(fixedColumns || 2);
25
+
26
+ // Calculate column count based on screen size (only if not fixed)
27
+ useEffect(() => {
28
+ if (fixedColumns) {
29
+ setColumnCount(fixedColumns);
30
+ return;
31
+ }
32
+
33
+ const updateColumnCount = () => {
34
+ const width = window.innerWidth;
35
+ let cols = 2;
36
+ if (width >= 1280) cols = 6;
37
+ else if (width >= 1024) cols = 5;
38
+ else if (width >= 768) cols = 4;
39
+ else if (width >= 640) cols = 3;
40
+ setColumnCount(cols);
41
+ };
42
+
43
+ updateColumnCount();
44
+ window.addEventListener('resize', updateColumnCount);
45
+ return () => window.removeEventListener('resize', updateColumnCount);
46
+ }, [fixedColumns]);
47
+
48
+ // Distribute images into columns using stable round-robin assignment
49
+ // This ensures items NEVER move once rendered - they always go to the same column
50
+ // based on their index, preventing reshuffling when new items are added
51
+ const columns = useMemo(() => {
52
+ const cols: ImageMetadata[][] = Array.from({ length: columnCount }, () => []);
53
+
54
+ images.forEach((image, index) => {
55
+ // Round-robin: image at index N always goes to column (N % columnCount)
56
+ // This guarantees stable positions - items never move columns
57
+ const columnIndex = index % columnCount;
58
+ cols[columnIndex].push(image);
59
+ });
60
+
61
+ return cols;
62
+ }, [images, columnCount]);
63
+
64
+ return (
65
+ <div
66
+ ref={containerRef}
67
+ className="grid gap-4"
68
+ style={{
69
+ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
70
+ }}
71
+ >
72
+ {columns.map((columnImages, columnIndex) => (
73
+ <div key={columnIndex} className="flex flex-col gap-4">
74
+ {columnImages.map((image, imageIndex) => {
75
+ // Normalize both selectedImageId and image identifiers for matching
76
+ let normalizedSelectedId = selectedImageId;
77
+ let normalizedImageUrl = image.url;
78
+ let normalizedImageId = image.id;
79
+ let normalizedImageFilename = image.filename;
80
+
81
+ // Extract filename from selectedImageId if it's a URL
82
+ if (selectedImageId) {
83
+ if (selectedImageId.includes('/')) {
84
+ const urlParts = selectedImageId.split('/');
85
+ normalizedSelectedId = urlParts[urlParts.length - 1]?.split('?')[0] || selectedImageId;
86
+ }
87
+ }
88
+
89
+ // Normalize image URL (remove protocol, domain, query params)
90
+ if (image.url) {
91
+ // Remove protocol and domain if present
92
+ normalizedImageUrl = image.url.replace(/^https?:\/\/[^\/]+/, '');
93
+ // Remove query params
94
+ normalizedImageUrl = normalizedImageUrl.split('?')[0];
95
+ // Extract just the filename
96
+ const urlParts = normalizedImageUrl.split('/');
97
+ normalizedImageUrl = urlParts[urlParts.length - 1] || normalizedImageUrl;
98
+ }
99
+
100
+ // Check if image is selected by comparing all possible combinations
101
+ const isSelected = Boolean(
102
+ selectedImageId && (
103
+ // Direct matches
104
+ selectedImageId === image.id ||
105
+ selectedImageId === image.url ||
106
+ selectedImageId === image.filename ||
107
+ // Normalized filename matches
108
+ normalizedSelectedId === image.id ||
109
+ normalizedSelectedId === image.filename ||
110
+ normalizedSelectedId === normalizedImageUrl ||
111
+ normalizedSelectedId === normalizedImageId ||
112
+ normalizedSelectedId === normalizedImageFilename ||
113
+ // URL contains/ends with matches
114
+ (image.url && selectedImageId && (
115
+ image.url.includes(selectedImageId) ||
116
+ image.url.endsWith(selectedImageId) ||
117
+ selectedImageId.includes(image.url) ||
118
+ selectedImageId.endsWith(image.url) ||
119
+ (normalizedSelectedId && (
120
+ image.url.includes(normalizedSelectedId) ||
121
+ image.url.endsWith(normalizedSelectedId)
122
+ ))
123
+ )) ||
124
+ // Filename matches
125
+ (image.filename && (
126
+ image.filename === normalizedSelectedId ||
127
+ normalizedSelectedId === image.filename
128
+ )) ||
129
+ // ID matches
130
+ (image.id && (
131
+ image.id === normalizedSelectedId ||
132
+ normalizedSelectedId === image.id
133
+ ))
134
+ )
135
+ );
136
+
137
+ // Use a unique key combining column index, image index, and image id
138
+ // This ensures uniqueness even if image.id is duplicated
139
+ const uniqueKey = `${columnIndex}-${imageIndex}-${image.id}-${image.url}`;
140
+
141
+ return (
142
+ <ImageCard
143
+ key={uniqueKey}
144
+ image={image}
145
+ isSelected={isSelected}
146
+ onSelect={() => {
147
+ onSelectImage(image);
148
+ onClose();
149
+ }}
150
+ />
151
+ );
152
+ })}
153
+ </div>
154
+ ))}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // Image Card Component for Masonry Layout
160
+ function ImageCard({
161
+ image,
162
+ isSelected,
163
+ onSelect
164
+ }: {
165
+ image: ImageMetadata;
166
+ isSelected: boolean;
167
+ onSelect: () => void;
168
+ }) {
169
+ const [imageLoaded, setImageLoaded] = useState(false);
170
+ const [imageError, setImageError] = useState(false);
171
+ const imgRef = useRef<HTMLImageElement>(null);
172
+
173
+ useEffect(() => {
174
+ // Check if image is already loaded (cached)
175
+ const img = imgRef.current;
176
+ if (img) {
177
+ if (img.complete && img.naturalWidth > 0) {
178
+ setImageLoaded(true);
179
+ } else {
180
+ // Fallback: if image doesn't load within 5 seconds, show it anyway
181
+ const timeout = setTimeout(() => {
182
+ setImageLoaded(true);
183
+ }, 5000);
184
+
185
+ return () => clearTimeout(timeout);
186
+ }
187
+ }
188
+ }, [image.url]);
189
+
190
+ const handleLoad = () => {
191
+ setImageLoaded(true);
192
+ setImageError(false);
193
+ };
194
+
195
+ const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
196
+ console.error('[ImageCard] Image failed to load:', image.url, e);
197
+ setImageError(true);
198
+ setImageLoaded(false);
199
+ };
200
+
201
+ return (
202
+ <button
203
+ onClick={onSelect}
204
+ className={`group relative w-full rounded-xl overflow-hidden bg-neutral-100 dark:bg-neutral-800 transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-lg block ${
205
+ isSelected ? 'ring-2 ring-primary ring-offset-1 ring-offset-white dark:ring-offset-neutral-900' : ''
206
+ }`}
207
+ >
208
+ {!imageError ? (
209
+ <div className="relative w-full group/image">
210
+ <img
211
+ ref={imgRef}
212
+ src={image.url}
213
+ alt={image.filename}
214
+ onLoad={handleLoad}
215
+ onError={handleError}
216
+ className={`w-full h-auto object-cover transition-all duration-300 group-hover/image:scale-105 block ${!imageLoaded ? 'opacity-0' : 'opacity-100'}`}
217
+ loading="lazy"
218
+ />
219
+
220
+ {/* Loading Overlay - only show if not loaded */}
221
+ {!imageLoaded && (
222
+ <div className="absolute inset-0 flex items-center justify-center bg-neutral-200 dark:bg-neutral-800 z-10">
223
+ <Loader2 size={20} className="text-neutral-400 animate-spin" />
224
+ </div>
225
+ )}
226
+ </div>
227
+ ) : (
228
+ <div className="w-full aspect-square flex flex-col items-center justify-center bg-neutral-200 dark:bg-neutral-800 text-neutral-400">
229
+ <ImageIcon size={24} className="mb-2" />
230
+ <p className="text-xs font-medium">Failed to load</p>
231
+ </div>
232
+ )}
233
+
234
+ {/* Selection Indicator */}
235
+ {isSelected && (
236
+ <>
237
+ {/* Border - refined but visible */}
238
+ <div className="absolute inset-0 border-2 border-primary rounded-xl pointer-events-none z-30 ring-2 ring-primary/20" />
239
+ {/* Badge - refined design */}
240
+ <div className="absolute top-2 right-2 bg-primary text-white rounded-md px-2 py-1 shadow-md z-30 flex items-center gap-1.5 backdrop-blur-sm">
241
+ <Check size={13} strokeWidth={2.5} className="flex-shrink-0" />
242
+ <span className="text-[10px] font-semibold uppercase tracking-wide">Selected</span>
243
+ </div>
244
+ {/* Subtle background overlay */}
245
+ <div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl pointer-events-none z-20" />
246
+ </>
247
+ )}
248
+
249
+ {/* Hover Overlay - darken background on button hover, but don't trigger image scaling */}
250
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 rounded-xl pointer-events-none" />
251
+
252
+ {/* Filename Tooltip */}
253
+ <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/70 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-b-xl pointer-events-none">
254
+ <p className="text-[10px] text-white font-semibold truncate leading-tight">{image.filename}</p>
255
+ </div>
256
+ </button>
257
+ );
258
+ }
259
+
260
+ export interface ImageBrowserModalProps {
261
+ isOpen: boolean;
262
+ onClose: () => void;
263
+ onSelectImage: (image: ImageMetadata) => void;
264
+ selectedImageId?: string;
265
+ darkMode?: boolean;
266
+ onUpload?: (file: File) => Promise<void>;
267
+ uploading?: boolean;
268
+ }
269
+
270
+ const ITEMS_PER_PAGE = 100; // Increased from 24 to load more images per request
271
+
272
+ export function ImageBrowserModal({
273
+ isOpen,
274
+ onClose,
275
+ onSelectImage,
276
+ selectedImageId,
277
+ darkMode = false,
278
+ onUpload,
279
+ uploading = false,
280
+ }: ImageBrowserModalProps) {
281
+ const [searchQuery, setSearchQuery] = useState('');
282
+ const [externalUrl, setExternalUrl] = useState('');
283
+ const [images, setImages] = useState<ImageMetadata[]>([]);
284
+ const [loading, setLoading] = useState(false);
285
+ const [loadingMore, setLoadingMore] = useState(false);
286
+ const [page, setPage] = useState(1);
287
+ const [hasMore, setHasMore] = useState(true);
288
+ const [total, setTotal] = useState(0);
289
+ const [resolvedSelectedId, setResolvedSelectedId] = useState<string | undefined>(selectedImageId);
290
+ const [internalUploading, setInternalUploading] = useState(false);
291
+ const [mounted, setMounted] = useState(false);
292
+ const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
293
+
294
+ // Handle SSR & portal target - ensure we only render portal on client
295
+ useEffect(() => {
296
+ setMounted(true);
297
+
298
+ if (typeof document === 'undefined') return;
299
+
300
+ // If we're inside the GlobalImageEditor overlay, portal into that instead of document.body
301
+ // This ensures the browser modal shares the same stacking context as the global editor
302
+ const editorContainer = document.querySelector('[data-image-editor="true"]') as HTMLElement | null;
303
+ if (editorContainer) {
304
+ setPortalTarget(editorContainer);
305
+ } else {
306
+ setPortalTarget(document.body);
307
+ }
308
+ }, []);
309
+
310
+ // Debug: Log when selectedImageId prop changes
311
+ useEffect(() => {
312
+ setResolvedSelectedId(selectedImageId);
313
+ }, [selectedImageId]);
314
+
315
+ // Load column count from localStorage, default to 3
316
+ const getStoredColumnCount = (): number => {
317
+ if (typeof window === 'undefined') return 3;
318
+ try {
319
+ const stored = localStorage.getItem('plugin-images-column-count');
320
+ if (stored) {
321
+ const parsed = parseInt(stored, 10);
322
+ // Validate: must be between 3 and 9
323
+ if (!isNaN(parsed) && parsed >= 3 && parsed <= 9) {
324
+ return parsed;
325
+ }
326
+ }
327
+ } catch (error) {
328
+ // Silently fail - use default
329
+ }
330
+ return 3;
331
+ };
332
+
333
+ const [columnCount, setColumnCount] = useState(getStoredColumnCount);
334
+
335
+ // Save column count to localStorage whenever it changes
336
+ useEffect(() => {
337
+ if (typeof window === 'undefined') return;
338
+ try {
339
+ localStorage.setItem('plugin-images-column-count', columnCount.toString());
340
+ } catch (error) {
341
+ // Silently fail
342
+ }
343
+ }, [columnCount]);
344
+
345
+ const modalRef = useRef<HTMLDivElement>(null);
346
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
347
+ const fileInputRef = useRef<HTMLInputElement>(null);
348
+ const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
349
+
350
+ // Load images from API with pagination
351
+ const loadImages = useCallback(async (pageNum: number = 1, append: boolean = false) => {
352
+ if (append) {
353
+ setLoadingMore(true);
354
+ } else {
355
+ setLoading(true);
356
+ }
357
+
358
+ try {
359
+ const searchParam = searchQuery.trim() ? `&search=${encodeURIComponent(searchQuery.trim())}` : '';
360
+ const response = await fetch(`/api/plugin-images/list?page=${pageNum}&limit=${ITEMS_PER_PAGE}${searchParam}`);
361
+
362
+ if (response.ok) {
363
+ const data = await response.json();
364
+ const newImages = (data.images || []).map((img: ImageMetadata) => {
365
+ const imageUrl = img.url || `/api/uploads/${img.filename}`;
366
+ // Ensure URL is absolute if it's a relative path
367
+ const finalUrl = imageUrl.startsWith('http') ? imageUrl : imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
368
+ return {
369
+ ...img,
370
+ url: finalUrl
371
+ };
372
+ });
373
+
374
+ setTotal(data.total || 0);
375
+
376
+ if (append) {
377
+ setImages(prev => {
378
+ // Deduplicate: use a Map to track unique images by id
379
+ const imageMap = new Map<string, ImageMetadata>();
380
+
381
+ // Add existing images
382
+ prev.forEach((img: ImageMetadata) => {
383
+ imageMap.set(img.id, img);
384
+ });
385
+
386
+ // Add new images (will overwrite duplicates)
387
+ newImages.forEach((img: ImageMetadata) => {
388
+ imageMap.set(img.id, img);
389
+ });
390
+
391
+ const updated = Array.from(imageMap.values());
392
+ // More images available if: we got a full page AND haven't loaded all images
393
+ setHasMore(newImages.length === ITEMS_PER_PAGE && updated.length < (data.total || 0));
394
+ return updated;
395
+ });
396
+ } else {
397
+ // Deduplicate even for initial load
398
+ const imageMap = new Map<string, ImageMetadata>();
399
+ newImages.forEach((img: ImageMetadata) => {
400
+ imageMap.set(img.id, img);
401
+ });
402
+ const deduplicated = Array.from(imageMap.values());
403
+ setImages(deduplicated);
404
+ // More images available if: we got a full page AND haven't loaded all images
405
+ setHasMore(deduplicated.length === ITEMS_PER_PAGE && deduplicated.length < (data.total || 0));
406
+ }
407
+
408
+ setPage(pageNum);
409
+ } else {
410
+ console.error('[ImageBrowserModal] Failed to load images:', response.status);
411
+ }
412
+ } catch (error) {
413
+ console.error('[ImageBrowserModal] Failed to load images:', error);
414
+ } finally {
415
+ setLoading(false);
416
+ setLoadingMore(false);
417
+ }
418
+ }, [searchQuery]);
419
+
420
+ // Load multiple pages in parallel for faster initial load
421
+ const loadInitialImages = useCallback(async () => {
422
+ setLoading(true);
423
+ try {
424
+ const searchParam = searchQuery.trim() ? `&search=${encodeURIComponent(searchQuery.trim())}` : '';
425
+
426
+ // Load first 2-3 pages in parallel to fill the screen quickly
427
+ const pagesToLoad = 3;
428
+ const requests = Array.from({ length: pagesToLoad }, (_, i) =>
429
+ fetch(`/api/plugin-images/list?page=${i + 1}&limit=${ITEMS_PER_PAGE}${searchParam}`)
430
+ );
431
+
432
+ const responses = await Promise.all(requests);
433
+ const results = await Promise.all(responses.map(r => r.ok ? r.json() : { images: [], total: 0 }));
434
+
435
+ // Combine all images
436
+ const allImages: ImageMetadata[] = [];
437
+ let totalCount = 0;
438
+
439
+ results.forEach((data, index) => {
440
+ if (data.images && data.images.length > 0) {
441
+ const pageImages = data.images.map((img: ImageMetadata) => {
442
+ const imageUrl = img.url || `/api/uploads/${img.filename}`;
443
+ const finalUrl = imageUrl.startsWith('http') ? imageUrl : imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
444
+ return {
445
+ ...img,
446
+ url: finalUrl
447
+ };
448
+ });
449
+ allImages.push(...pageImages);
450
+ }
451
+ if (index === 0 && data.total) {
452
+ totalCount = data.total;
453
+ }
454
+ });
455
+
456
+ // Deduplicate
457
+ const imageMap = new Map<string, ImageMetadata>();
458
+ allImages.forEach((img: ImageMetadata) => {
459
+ imageMap.set(img.id, img);
460
+ });
461
+ const deduplicated = Array.from(imageMap.values());
462
+
463
+ setImages(deduplicated);
464
+ setTotal(totalCount);
465
+ setHasMore(deduplicated.length < totalCount);
466
+ setPage(pagesToLoad);
467
+ } catch (error) {
468
+ console.error('[ImageBrowserModal] Error loading initial images:', error);
469
+ // Fallback to single page load
470
+ loadImages(1, false);
471
+ } finally {
472
+ setLoading(false);
473
+ }
474
+ }, [searchQuery, loadImages]);
475
+
476
+ // Load more images when scrolling near bottom
477
+ const handleScroll = useCallback(() => {
478
+ const container = scrollContainerRef.current;
479
+ if (!container || loadingMore || !hasMore || loading) return;
480
+
481
+ const scrollTop = container.scrollTop;
482
+ const scrollHeight = container.scrollHeight;
483
+ const clientHeight = container.clientHeight;
484
+
485
+ // Calculate distance from bottom
486
+ const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
487
+
488
+ // Load more when within 200px of bottom
489
+ if (distanceFromBottom < 200) {
490
+ loadImages(page + 1, true);
491
+ }
492
+ }, [page, hasMore, loadingMore, loading, loadImages]);
493
+
494
+ // Check if viewport needs more images to fill the screen
495
+ const checkAndLoadMoreIfNeeded = useCallback(() => {
496
+ const container = scrollContainerRef.current;
497
+ if (!container || loading || loadingMore || !hasMore) return;
498
+
499
+ // Check if content fills the viewport (with some margin)
500
+ const scrollHeight = container.scrollHeight;
501
+ const clientHeight = container.clientHeight;
502
+ const needsMoreContent = scrollHeight <= clientHeight + 100; // 100px margin
503
+
504
+ if (needsMoreContent) {
505
+ loadImages(page + 1, true);
506
+ }
507
+ }, [loading, loadingMore, hasMore, images.length, page, loadImages]);
508
+
509
+ // Auto-load more images when images change and viewport isn't filled
510
+ useEffect(() => {
511
+ if (!isOpen || images.length === 0 || loading || loadingMore) return;
512
+
513
+ // Wait for DOM to update after images are set and rendered
514
+ const timeoutId = setTimeout(() => {
515
+ checkAndLoadMoreIfNeeded();
516
+ }, 200); // Delay to allow images to render and layout to settle
517
+
518
+ return () => clearTimeout(timeoutId);
519
+ }, [images, isOpen, loading, loadingMore, checkAndLoadMoreIfNeeded]);
520
+
521
+ // Check again after loading completes
522
+ useEffect(() => {
523
+ if (!isOpen || images.length === 0) return;
524
+ if (!loading && !loadingMore) {
525
+ // Loading just finished, check if we need more
526
+ const timeoutId = setTimeout(() => {
527
+ checkAndLoadMoreIfNeeded();
528
+ }, 300); // Give images time to render
529
+ return () => clearTimeout(timeoutId);
530
+ }
531
+ }, [loading, loadingMore, isOpen, images.length, checkAndLoadMoreIfNeeded]);
532
+
533
+ // Also check when column count changes (more columns = need more images)
534
+ useEffect(() => {
535
+ if (!isOpen || images.length === 0 || loading || loadingMore) return;
536
+ const timeoutId = setTimeout(() => {
537
+ checkAndLoadMoreIfNeeded();
538
+ }, 200);
539
+ return () => clearTimeout(timeoutId);
540
+ }, [columnCount, isOpen, loading, loadingMore, checkAndLoadMoreIfNeeded]);
541
+
542
+ // Initial load and reset on open
543
+ useEffect(() => {
544
+ if (isOpen) {
545
+ document.body.style.overflow = 'hidden';
546
+ setPage(1);
547
+ setImages([]);
548
+ setHasMore(true);
549
+ // Use parallel loading for faster initial load
550
+ loadInitialImages();
551
+ } else {
552
+ document.body.style.overflow = 'unset';
553
+ setSearchQuery('');
554
+ setImages([]);
555
+ setPage(1);
556
+ }
557
+ return () => { document.body.style.overflow = 'unset'; };
558
+ }, [isOpen, loadInitialImages, selectedImageId]);
559
+
560
+ // Debounced search
561
+ useEffect(() => {
562
+ if (!isOpen) return;
563
+
564
+ if (searchTimeoutRef.current) {
565
+ clearTimeout(searchTimeoutRef.current);
566
+ }
567
+
568
+ searchTimeoutRef.current = setTimeout(() => {
569
+ setPage(1);
570
+ setImages([]);
571
+ setHasMore(true);
572
+ // For search, use parallel loading if no search query (faster), otherwise single page
573
+ if (!searchQuery.trim()) {
574
+ loadInitialImages();
575
+ } else {
576
+ loadImages(1, false);
577
+ }
578
+ }, 300);
579
+
580
+ return () => {
581
+ if (searchTimeoutRef.current) {
582
+ clearTimeout(searchTimeoutRef.current);
583
+ }
584
+ };
585
+ }, [searchQuery, isOpen, loadImages, loadInitialImages]);
586
+
587
+ // Reload after upload
588
+ useEffect(() => {
589
+ if (!uploading && isOpen && images.length > 0) {
590
+ // Reload first page to show newly uploaded image
591
+ loadImages(1, false);
592
+ }
593
+ }, [uploading, isOpen, loadImages]);
594
+
595
+ // Attach scroll listener
596
+ useEffect(() => {
597
+ const container = scrollContainerRef.current;
598
+ if (!container) return;
599
+
600
+ container.addEventListener('scroll', handleScroll);
601
+ return () => container.removeEventListener('scroll', handleScroll);
602
+ }, [handleScroll]);
603
+
604
+ // Handle file upload (default handler if onUpload prop is not provided)
605
+ const handleFileUpload = async (file: File) => {
606
+ // If onUpload prop is provided, use it
607
+ if (onUpload) {
608
+ await onUpload(file);
609
+ return;
610
+ }
611
+
612
+ // Otherwise, handle upload internally
613
+ setInternalUploading(true);
614
+ try {
615
+ const formData = new FormData();
616
+ formData.append('file', file);
617
+
618
+ const response = await fetch('/api/plugin-images/upload', {
619
+ method: 'POST',
620
+ body: formData,
621
+ });
622
+
623
+ const data = await response.json();
624
+
625
+ if (data.success && data.image) {
626
+ // Reload images to show the newly uploaded image
627
+ await loadImages(1, false);
628
+ // Optionally select the newly uploaded image
629
+ // onSelectImage(data.image);
630
+ } else {
631
+ console.error('[ImageBrowserModal] Upload failed:', data.error || 'Unknown error');
632
+ alert(data.error || 'Failed to upload image');
633
+ }
634
+ } catch (error) {
635
+ console.error('[ImageBrowserModal] Upload error:', error);
636
+ alert('Failed to upload image');
637
+ } finally {
638
+ setInternalUploading(false);
639
+ // Reset file input
640
+ if (fileInputRef.current) {
641
+ fileInputRef.current.value = '';
642
+ }
643
+ }
644
+ };
645
+
646
+ // 4. Handle External URL Logic
647
+ const handleUseExternalUrl = () => {
648
+ const url = externalUrl.trim();
649
+ if (!url) return;
650
+
651
+ const externalImage: ImageMetadata = {
652
+ id: `external-${Date.now()}`,
653
+ filename: url.split('/').pop()?.split('?')[0] || 'external-image',
654
+ url: url,
655
+ size: 0,
656
+ mimeType: 'image/external',
657
+ uploadedAt: new Date().toISOString(),
658
+ };
659
+
660
+ onSelectImage(externalImage);
661
+ setExternalUrl('');
662
+ onClose();
663
+ };
664
+
665
+ if (!isOpen || !mounted || !portalTarget) return null;
666
+
667
+ const modalContent = (
668
+ <div className="fixed inset-0 z-[200] flex items-center justify-center p-4 animate-in fade-in duration-200">
669
+ {/* Backdrop */}
670
+ <div
671
+ className="absolute inset-0 bg-neutral-950/70 backdrop-blur-md"
672
+ onClick={onClose}
673
+ />
674
+
675
+ <div
676
+ ref={modalRef}
677
+ className={`relative w-full max-w-6xl h-[90vh] bg-white dark:bg-neutral-900 rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-neutral-200 dark:border-neutral-800 animate-in zoom-in-95 duration-300 ${darkMode ? 'dark' : ''}`}
678
+ >
679
+ {/* Header */}
680
+ <div className="px-6 py-5 border-b border-neutral-200 dark:border-neutral-800 bg-gradient-to-r from-neutral-50 to-white dark:from-neutral-900 dark:to-neutral-950 flex items-center justify-between">
681
+ <div>
682
+ <h2 className="text-2xl font-black uppercase tracking-tight dark:text-white">Media Library</h2>
683
+ {total > 0 && (
684
+ <p className="text-xs text-neutral-500 font-semibold uppercase tracking-wider mt-1">
685
+ {total} {total === 1 ? 'image' : 'images'}
686
+ </p>
687
+ )}
688
+ </div>
689
+ <button
690
+ onClick={onClose}
691
+ className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-xl transition-all duration-200 hover:scale-110 active:scale-95"
692
+ aria-label="Close"
693
+ >
694
+ <X size={20} className="text-neutral-500 dark:text-neutral-300 hover:text-neutral-700 dark:hover:text-white transition-colors" />
695
+ </button>
696
+ </div>
697
+
698
+ {/* Toolbar */}
699
+ <div className="px-6 py-4 bg-neutral-50/50 dark:bg-neutral-900/30 border-b border-neutral-200 dark:border-neutral-800 flex flex-col sm:flex-row gap-3">
700
+ <div className="relative flex-1">
701
+ <Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400" />
702
+ <input
703
+ type="text"
704
+ placeholder="Search images..."
705
+ value={searchQuery}
706
+ onChange={(e) => setSearchQuery(e.target.value)}
707
+ className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-xl text-sm font-medium focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-all shadow-sm"
708
+ />
709
+ </div>
710
+ <div className="flex items-center gap-3">
711
+ {/* Column Count Slider */}
712
+ <div className="flex items-center gap-3 bg-white dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-xl px-4 py-2.5 shadow-sm">
713
+ <Grid3x3 size={16} className="text-neutral-400 flex-shrink-0" />
714
+ <div className="flex items-center gap-3 min-w-[140px]">
715
+ <span className="text-sm font-bold text-neutral-700 dark:text-neutral-300 min-w-[24px] text-right tabular-nums">
716
+ {columnCount}
717
+ </span>
718
+ <input
719
+ type="range"
720
+ min="3"
721
+ max="9"
722
+ value={columnCount}
723
+ onChange={(e) => setColumnCount(parseInt(e.target.value))}
724
+ className="flex-1 h-2 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-primary"
725
+ />
726
+ </div>
727
+ <span className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
728
+ cols
729
+ </span>
730
+ </div>
731
+ <button
732
+ onClick={() => fileInputRef.current?.click()}
733
+ disabled={uploading || internalUploading}
734
+ className="flex items-center justify-center gap-2 px-5 py-2.5 bg-primary text-white rounded-xl font-semibold text-sm hover:bg-primary/90 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg active:scale-[0.98]"
735
+ >
736
+ {(uploading || internalUploading) ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
737
+ Upload
738
+ </button>
739
+ </div>
740
+ </div>
741
+
742
+ {/* Content Area */}
743
+ <div
744
+ ref={scrollContainerRef}
745
+ className="flex-1 overflow-y-auto p-6"
746
+ style={{ scrollbarWidth: 'thin' }}
747
+ >
748
+ {loading && images.length === 0 ? (
749
+ <div className="h-full flex flex-col items-center justify-center space-y-4">
750
+ <Loader2 size={48} className="text-primary animate-spin" />
751
+ <p className="text-sm font-semibold text-neutral-500 uppercase tracking-wider">Loading Library...</p>
752
+ </div>
753
+ ) : images.length === 0 ? (
754
+ <div className="h-full flex flex-col items-center justify-center text-center py-16">
755
+ <div className="p-5 bg-gradient-to-br from-neutral-100 to-neutral-200 dark:from-neutral-800 dark:to-neutral-900 rounded-2xl mb-4 shadow-inner">
756
+ <ImageIcon size={36} className="text-neutral-400" />
757
+ </div>
758
+ <h3 className="text-lg font-bold dark:text-white mb-2">No images found</h3>
759
+ <p className="text-sm text-neutral-500 max-w-sm">
760
+ {searchQuery.trim()
761
+ ? 'Try a different search term or upload a new image.'
762
+ : 'Upload your first image to get started.'}
763
+ </p>
764
+ </div>
765
+ ) : (
766
+ <>
767
+ <MasonryGrid
768
+ images={images}
769
+ selectedImageId={resolvedSelectedId}
770
+ onSelectImage={(image) => {
771
+ onSelectImage(image);
772
+ }}
773
+ onClose={onClose}
774
+ fixedColumns={columnCount}
775
+ />
776
+
777
+ {/* Loading More Indicator */}
778
+ {loadingMore && (
779
+ <div className="flex justify-center items-center py-8">
780
+ <Loader2 size={24} className="text-primary animate-spin" />
781
+ </div>
782
+ )}
783
+
784
+ {/* End of List Indicator */}
785
+ {!hasMore && images.length > 0 && (
786
+ <div className="text-center py-6">
787
+ <p className="text-xs text-neutral-400 font-semibold uppercase tracking-wider">
788
+ All {total} images loaded
789
+ </p>
790
+ </div>
791
+ )}
792
+ </>
793
+ )}
794
+ </div>
795
+
796
+ {/* Footer / External URL */}
797
+ <div className="px-6 py-4 bg-gradient-to-r from-neutral-50 to-white dark:from-neutral-900 dark:to-neutral-950 border-t border-neutral-200 dark:border-neutral-800 flex flex-col sm:flex-row items-center gap-3">
798
+ <div className="flex items-center gap-2 text-neutral-400 min-w-max">
799
+ <LinkIcon size={14} />
800
+ <span className="text-[10px] font-bold uppercase tracking-wider">External URL</span>
801
+ </div>
802
+ <div className="flex-1 flex w-full gap-2">
803
+ <input
804
+ type="text"
805
+ placeholder="Paste image URL here..."
806
+ value={externalUrl}
807
+ onChange={(e) => setExternalUrl(e.target.value)}
808
+ onKeyDown={(e) => e.key === 'Enter' && handleUseExternalUrl()}
809
+ className="flex-1 px-3 py-2 bg-white dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-lg text-xs font-medium focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-all shadow-sm"
810
+ />
811
+ <button
812
+ onClick={handleUseExternalUrl}
813
+ className="px-4 py-2 bg-neutral-900 dark:bg-white dark:text-neutral-900 text-white rounded-lg text-xs font-semibold hover:opacity-90 transition-all duration-200 active:scale-[0.98]"
814
+ >
815
+ Import
816
+ </button>
817
+ </div>
818
+ </div>
819
+
820
+ <input
821
+ ref={fileInputRef}
822
+ type="file"
823
+ accept="image/*"
824
+ onChange={(e) => {
825
+ const file = e.target.files?.[0];
826
+ if (file) {
827
+ handleFileUpload(file);
828
+ }
829
+ }}
830
+ className="hidden"
831
+ />
832
+ </div>
833
+ </div>
834
+ );
835
+
836
+ return createPortal(modalContent, portalTarget);
837
+ }