@jhits/plugin-images 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Global Image Editor Component
3
+ * Allows clicking on any image in the client app to edit it (admin/dev only)
4
+ *
5
+ * Reads configuration from window.__JHITS_PLUGIN_PROPS__['plugin-images']
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import React, { useState, useEffect, useRef, useMemo } from 'react';
11
+ import { X, Edit2 } from 'lucide-react';
12
+ import { ImagePicker } from './ImagePicker';
13
+ import type { ImageMetadata } from '../types';
14
+
15
+ export function GlobalImageEditor() {
16
+ // Read config from window global (set by initImagesPlugin)
17
+ const config = useMemo(() => {
18
+ if (typeof window === 'undefined') {
19
+ return { enabled: false };
20
+ }
21
+
22
+ const pluginProps = (window as any).__JHITS_PLUGIN_PROPS__?.['plugin-images'];
23
+ return {
24
+ enabled: pluginProps?.enabled !== undefined ? pluginProps.enabled : true,
25
+ className: pluginProps?.className,
26
+ overlayClassName: pluginProps?.overlayClassName,
27
+ };
28
+ }, []);
29
+ const [selectedImage, setSelectedImage] = useState<{
30
+ element: HTMLElement;
31
+ originalSrc: string;
32
+ brightness: number;
33
+ blur: number;
34
+ isBackground: boolean;
35
+ } | null>(null);
36
+ const [isOpen, setIsOpen] = useState(false);
37
+ const [userRole, setUserRole] = useState<string | null>(null);
38
+ const [isLoading, setIsLoading] = useState(true);
39
+
40
+ // Check if user is admin/dev
41
+ useEffect(() => {
42
+ const checkUser = async () => {
43
+ try {
44
+ const res = await fetch('/api/me');
45
+ const data = await res.json();
46
+ if (data.loggedIn && (data.user?.role === 'admin' || data.user?.role === 'dev')) {
47
+ setUserRole(data.user.role);
48
+ }
49
+ } catch (error) {
50
+ console.error('Failed to check user role:', error);
51
+ } finally {
52
+ setIsLoading(false);
53
+ }
54
+ };
55
+ checkUser();
56
+ }, []);
57
+
58
+ // Listen for open-image-editor custom event from Image/BackgroundImage components
59
+ useEffect(() => {
60
+ if (!userRole) return;
61
+
62
+ const handleOpenEditor = async (e: CustomEvent) => {
63
+ const { id, currentBrightness, currentBlur } = e.detail || {};
64
+ if (!id) return;
65
+
66
+ console.log('[GlobalImageEditor] open-image-editor event received for id:', id);
67
+
68
+ // Find the element by data-image-id or data-background-image-id
69
+ let element = document.querySelector(`[data-image-id="${id}"], [data-background-image-id="${id}"]`) as HTMLElement;
70
+
71
+ if (!element) {
72
+ // Try finding by background image component
73
+ element = document.querySelector(`[data-background-image-component="true"][data-background-image-id="${id}"]`) as HTMLElement;
74
+ }
75
+
76
+ if (!element) {
77
+ console.error('[GlobalImageEditor] Element not found for id:', id);
78
+ return;
79
+ }
80
+
81
+ // Determine if it's a background image
82
+ const isBackground = element.hasAttribute('data-background-image-component') ||
83
+ element.hasAttribute('data-background-image-id');
84
+
85
+ // Get current image source - handle Next.js Image wrapper
86
+ let currentSrc = '';
87
+ let actualImgElement: HTMLImageElement | null = null;
88
+
89
+ if (isBackground) {
90
+ // For background images, look for nested Image component
91
+ const imgWrapper = element.querySelector('[data-image-id]');
92
+ if (imgWrapper) {
93
+ actualImgElement = imgWrapper.querySelector('img') || null;
94
+ } else {
95
+ // Fallback: look for any img inside
96
+ actualImgElement = element.querySelector('img');
97
+ }
98
+
99
+ if (actualImgElement) {
100
+ currentSrc = actualImgElement.src;
101
+ } else {
102
+ // Try computed style as last resort
103
+ const bgImage = window.getComputedStyle(element).backgroundImage;
104
+ const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
105
+ if (urlMatch && urlMatch[1]) {
106
+ currentSrc = urlMatch[1];
107
+ }
108
+ }
109
+ } else {
110
+ // For regular images, find the actual img element (Next.js wraps it)
111
+ if (element.tagName === 'IMG') {
112
+ actualImgElement = element as HTMLImageElement;
113
+ currentSrc = actualImgElement.src;
114
+ } else {
115
+ // Next.js Image wraps img in span/picture
116
+ actualImgElement = element.querySelector('img');
117
+ if (actualImgElement) {
118
+ currentSrc = actualImgElement.src;
119
+ }
120
+ }
121
+ }
122
+
123
+ if (!currentSrc) {
124
+ console.error('[GlobalImageEditor] Could not determine image source for id:', id);
125
+ return;
126
+ }
127
+
128
+ // Extract semantic ID
129
+ const semanticId = element.getAttribute('data-image-id') ||
130
+ element.getAttribute('data-background-image-id') ||
131
+ id;
132
+
133
+ // Get current brightness and blur from the wrapper element (where filter is applied)
134
+ const filterStyle = element.style.filter || window.getComputedStyle(element).filter || '';
135
+ const brightness = currentBrightness ??
136
+ (parseInt(filterStyle.match(/brightness\((\d+)%\)/)?.[1] || '100'));
137
+ const blur = currentBlur ??
138
+ (parseInt(filterStyle.match(/blur\((\d+)px\)/)?.[1] || '0'));
139
+
140
+ console.log('[GlobalImageEditor] Opening editor for:', { id, semanticId, currentSrc, brightness, blur, isBackground });
141
+
142
+ setSelectedImage({
143
+ element,
144
+ originalSrc: currentSrc,
145
+ brightness,
146
+ blur,
147
+ isBackground,
148
+ });
149
+
150
+ // Store semantic ID on element if not already set
151
+ if (isBackground && !element.hasAttribute('data-background-image-id')) {
152
+ element.setAttribute('data-background-image-id', semanticId);
153
+ } else if (!isBackground && !element.hasAttribute('data-image-id')) {
154
+ element.setAttribute('data-image-id', semanticId);
155
+ }
156
+
157
+ setIsOpen(true);
158
+ };
159
+
160
+ window.addEventListener('open-image-editor', handleOpenEditor as unknown as EventListener);
161
+
162
+ return () => {
163
+ window.removeEventListener('open-image-editor', handleOpenEditor as unknown as EventListener);
164
+ };
165
+ }, [userRole]);
166
+
167
+ // Add click handlers to all images
168
+ useEffect(() => {
169
+ if (isLoading || !userRole) {
170
+ console.log('[GlobalImageEditor] Skipping image handlers - isLoading:', isLoading, 'userRole:', userRole);
171
+ return;
172
+ }
173
+
174
+ console.log('[GlobalImageEditor] Setting up image handlers for role:', userRole);
175
+
176
+ // Declare cleanupFunctions outside setupImages so it's accessible in the cleanup function
177
+ const cleanupFunctions: Array<() => void> = [];
178
+
179
+ // Wait a bit for images to load and DOM to be ready
180
+ const setupImages = () => {
181
+ const handleImageClick = (e: MouseEvent) => {
182
+ const target = e.target as HTMLElement;
183
+
184
+ // Only handle clicks on images that use the plugin components
185
+ // Check if it's an img element with data-image-id (from plugin Image component)
186
+ let img = target.closest('img[data-image-id]') as HTMLImageElement | null;
187
+ let isBackground = false;
188
+ let element: HTMLElement | null = null;
189
+ let currentSrc = '';
190
+
191
+ if (img) {
192
+ // This is a plugin Image component
193
+ element = img;
194
+ currentSrc = img.src;
195
+ } else {
196
+ // Check if it's an element with background-image
197
+ // First check for BackgroundImage component
198
+ let bgElement = target.closest('[data-background-image-component]') as HTMLElement | null;
199
+
200
+ if (bgElement) {
201
+ // This is a BackgroundImage component
202
+ element = bgElement;
203
+ isBackground = true;
204
+
205
+ // Try to get image source from nested Image component first
206
+ const imgInside = bgElement.querySelector('img[data-image-id]') as HTMLImageElement | null;
207
+ if (imgInside) {
208
+ currentSrc = imgInside.src;
209
+ } else {
210
+ // Fallback to background-image CSS
211
+ const bgImage = window.getComputedStyle(bgElement).backgroundImage;
212
+ const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
213
+ if (urlMatch && urlMatch[1]) {
214
+ currentSrc = urlMatch[1];
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ if (!element || !currentSrc) return;
221
+
222
+ // Don't handle images in modals or the editor itself
223
+ if (element.closest('[data-image-editor]') || element.closest('[role="dialog"]')) {
224
+ return;
225
+ }
226
+
227
+ e.preventDefault();
228
+ e.stopPropagation();
229
+
230
+ // Extract semantic ID from data attribute
231
+ let semanticId = element.getAttribute('data-image-id') || element.getAttribute('data-background-image-id');
232
+
233
+ // If not found, try to extract from the src URL
234
+ if (!semanticId && currentSrc.includes('/api/uploads/')) {
235
+ const urlPart = currentSrc.split('/api/uploads/')[1]?.split('?')[0];
236
+ // If it looks like a semantic ID (no extension or timestamp), use it
237
+ if (urlPart && !/^\d+-/.test(urlPart) && !/\.(jpg|jpeg|png|webp|gif|svg)$/i.test(urlPart)) {
238
+ semanticId = decodeURIComponent(urlPart);
239
+ }
240
+ }
241
+
242
+ // If still not found, generate one
243
+ if (!semanticId) {
244
+ semanticId = element.closest('[data-image-id]')?.getAttribute('data-image-id') ||
245
+ `image-${Date.now()}`;
246
+ }
247
+
248
+ // Check for existing filter or data attributes
249
+ const filterStyle = element.style.filter || window.getComputedStyle(element).filter || '';
250
+ const dataBrightness = element.getAttribute('data-brightness');
251
+ const dataBlur = element.getAttribute('data-blur');
252
+ const currentBrightness = dataBrightness
253
+ ? parseInt(dataBrightness)
254
+ : parseInt(filterStyle.match(/brightness\((\d+)%\)/)?.[1] || '100');
255
+ const currentBlur = dataBlur
256
+ ? parseInt(dataBlur)
257
+ : parseInt(filterStyle.match(/blur\((\d+)px\)/)?.[1] || '0');
258
+
259
+ setSelectedImage({
260
+ element,
261
+ originalSrc: currentSrc,
262
+ brightness: currentBrightness,
263
+ blur: currentBlur,
264
+ isBackground,
265
+ });
266
+
267
+ // Store semantic ID on element for later use
268
+ if (isBackground) {
269
+ element.setAttribute('data-background-image-id', semanticId);
270
+ } else {
271
+ element.setAttribute('data-image-id', semanticId);
272
+ }
273
+ setIsOpen(true);
274
+ };
275
+
276
+ // Add click listeners and hover effects to all images
277
+ // Only select images that use the plugin components (have data-image-id or are inside BackgroundImage)
278
+ const images = document.querySelectorAll('img[data-image-id]:not([data-no-edit])') as NodeListOf<HTMLImageElement>;
279
+
280
+ // Find elements with BackgroundImage components only
281
+ // Only BackgroundImage components should be editable, not all elements with background-image
282
+ const bgElements: HTMLElement[] = [];
283
+ const backgroundImageComponents = document.querySelectorAll('[data-background-image-component]');
284
+ backgroundImageComponents.forEach(el => {
285
+ bgElements.push(el as HTMLElement);
286
+ });
287
+
288
+ console.log('[GlobalImageEditor] Found', images.length, 'images and', bgElements.length, 'background images to make editable');
289
+
290
+ images.forEach((img, index) => {
291
+ console.log(`[GlobalImageEditor] Setting up image ${index + 1}:`, img.src);
292
+ // Store original position for cleanup
293
+ const originalPosition = img.style.position;
294
+
295
+ img.style.cursor = 'pointer';
296
+ img.setAttribute('data-editable-image', 'true');
297
+ img.addEventListener('click', handleImageClick as EventListener);
298
+
299
+ // Find the best container for the indicator
300
+ // Next.js Image wraps in span, so we need to find the right parent
301
+ let indicatorContainer: HTMLElement = img;
302
+ let parent = img.parentElement;
303
+
304
+ // Check if parent is a span (Next.js Image wrapper)
305
+ if (parent && (parent.tagName === 'SPAN' || parent.tagName === 'DIV')) {
306
+ const parentStyle = window.getComputedStyle(parent);
307
+ // Use parent if it has relative/absolute positioning or is a block element
308
+ if (parentStyle.position !== 'static' ||
309
+ (parentStyle.display !== 'inline' && parentStyle.display !== 'inline-block')) {
310
+ indicatorContainer = parent as HTMLElement;
311
+ }
312
+ }
313
+
314
+ // Ensure container has relative positioning
315
+ const containerStyle = window.getComputedStyle(indicatorContainer);
316
+ if (containerStyle.position === 'static') {
317
+ indicatorContainer.style.position = 'relative';
318
+ }
319
+
320
+ // Get actual dimensions
321
+ const rect = indicatorContainer.getBoundingClientRect();
322
+
323
+ console.log(`[GlobalImageEditor] Using container for image ${index + 1}:`, indicatorContainer.tagName, `(${rect.width}x${rect.height})`);
324
+
325
+ const indicator = document.createElement('div');
326
+ indicator.className = 'image-edit-indicator';
327
+ indicator.setAttribute('data-image-index', index.toString());
328
+ indicator.style.cssText = `
329
+ position: absolute;
330
+ top: 0;
331
+ left: 0;
332
+ width: 100%;
333
+ height: 100%;
334
+ background: rgba(0, 0, 0, 0.4);
335
+ display: flex;
336
+ align-items: center;
337
+ justify-content: center;
338
+ opacity: 0;
339
+ transition: opacity 0.2s ease;
340
+ pointer-events: none;
341
+ z-index: 10000;
342
+ border-radius: inherit;
343
+ box-sizing: border-box;
344
+ `;
345
+
346
+ const label = document.createElement('div');
347
+ label.style.cssText = `
348
+ background: rgba(0, 0, 0, 0.9);
349
+ color: white;
350
+ padding: 8px 16px;
351
+ border-radius: 8px;
352
+ font-size: 12px;
353
+ font-weight: bold;
354
+ text-transform: uppercase;
355
+ letter-spacing: 0.5px;
356
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
357
+ `;
358
+ label.textContent = 'Click to Edit';
359
+ indicator.appendChild(label);
360
+
361
+ indicatorContainer.appendChild(indicator);
362
+ console.log(`[GlobalImageEditor] Added hover indicator to image ${index + 1}, container:`, indicatorContainer.tagName);
363
+
364
+ const addHoverEffect = () => {
365
+ console.log(`[GlobalImageEditor] Hover enter on image ${index + 1}`);
366
+ indicator.style.opacity = '1';
367
+ };
368
+
369
+ const removeHoverEffect = () => {
370
+ console.log(`[GlobalImageEditor] Hover leave on image ${index + 1}`);
371
+ indicator.style.opacity = '0';
372
+ };
373
+
374
+ // Add hover listeners to both image and container
375
+ img.addEventListener('mouseenter', addHoverEffect);
376
+ img.addEventListener('mouseleave', removeHoverEffect);
377
+ if (indicatorContainer !== img) {
378
+ indicatorContainer.addEventListener('mouseenter', addHoverEffect);
379
+ indicatorContainer.addEventListener('mouseleave', removeHoverEffect);
380
+ }
381
+
382
+ cleanupFunctions.push(() => {
383
+ img.removeEventListener('click', handleImageClick as EventListener);
384
+ img.removeEventListener('mouseenter', addHoverEffect);
385
+ img.removeEventListener('mouseleave', removeHoverEffect);
386
+ if (indicatorContainer !== img) {
387
+ indicatorContainer.removeEventListener('mouseenter', addHoverEffect);
388
+ indicatorContainer.removeEventListener('mouseleave', removeHoverEffect);
389
+ if (indicatorContainer.style.position === 'relative') {
390
+ indicatorContainer.style.position = '';
391
+ }
392
+ }
393
+ img.removeAttribute('data-editable-image');
394
+ img.style.cursor = '';
395
+ img.style.position = originalPosition;
396
+ indicator.remove();
397
+ });
398
+ });
399
+
400
+ // Process background images - show a button instead of hover overlay
401
+ bgElements.forEach((bgEl, index) => {
402
+ // Skip BackgroundImage components - they already have their own edit button
403
+ if (bgEl.hasAttribute('data-background-image-component')) {
404
+ console.log(`[GlobalImageEditor] Skipping BackgroundImage component ${index + 1} - it has its own edit button`);
405
+ return;
406
+ }
407
+ const originalPosition = bgEl.style.position;
408
+ if (!bgEl.style.position || bgEl.style.position === 'static') {
409
+ bgEl.style.position = 'relative';
410
+ }
411
+
412
+ bgEl.setAttribute('data-editable-background', 'true');
413
+ bgEl.setAttribute('data-background-image', 'true');
414
+
415
+ // Create a button indicator instead of hover overlay
416
+ const buttonContainer = document.createElement('div');
417
+ buttonContainer.className = 'background-edit-button-container';
418
+ buttonContainer.setAttribute('data-image-index', `bg-${index}`);
419
+ buttonContainer.style.cssText = `
420
+ position: absolute;
421
+ top: 12px;
422
+ right: 12px;
423
+ z-index: 10000;
424
+ pointer-events: auto;
425
+ `;
426
+
427
+ const editButton = document.createElement('button');
428
+ editButton.className = 'background-edit-button';
429
+ editButton.setAttribute('type', 'button');
430
+ editButton.style.cssText = `
431
+ background: rgba(0, 0, 0, 0.8);
432
+ color: white;
433
+ border: 2px solid rgba(255, 255, 255, 0.3);
434
+ padding: 10px 20px;
435
+ border-radius: 8px;
436
+ font-size: 12px;
437
+ font-weight: bold;
438
+ text-transform: uppercase;
439
+ letter-spacing: 0.5px;
440
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
441
+ cursor: pointer;
442
+ transition: all 0.2s ease;
443
+ display: flex;
444
+ align-items: center;
445
+ gap: 8px;
446
+ `;
447
+ editButton.innerHTML = `
448
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
449
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
450
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
451
+ </svg>
452
+ Edit Background
453
+ `;
454
+
455
+ // Add hover effect to button
456
+ editButton.addEventListener('mouseenter', () => {
457
+ editButton.style.background = 'rgba(0, 0, 0, 0.95)';
458
+ editButton.style.borderColor = 'rgba(255, 255, 255, 0.5)';
459
+ editButton.style.transform = 'translateY(-2px)';
460
+ editButton.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.5)';
461
+ });
462
+
463
+ editButton.addEventListener('mouseleave', () => {
464
+ editButton.style.background = 'rgba(0, 0, 0, 0.8)';
465
+ editButton.style.borderColor = 'rgba(255, 255, 255, 0.3)';
466
+ editButton.style.transform = 'translateY(0)';
467
+ editButton.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.4)';
468
+ });
469
+
470
+ // Handle button click
471
+ editButton.addEventListener('click', (e) => {
472
+ e.preventDefault();
473
+ e.stopPropagation();
474
+ handleImageClick(e as any);
475
+ });
476
+
477
+ buttonContainer.appendChild(editButton);
478
+ bgEl.appendChild(buttonContainer);
479
+
480
+ console.log(`[GlobalImageEditor] Added edit button to background image ${index + 1}`);
481
+
482
+ cleanupFunctions.push(() => {
483
+ bgEl.removeAttribute('data-editable-background');
484
+ bgEl.removeAttribute('data-background-image');
485
+ bgEl.style.position = originalPosition;
486
+ buttonContainer.remove();
487
+ });
488
+ });
489
+
490
+ }; // End of setupImages function
491
+
492
+ // Setup immediately and also after a short delay to catch dynamically loaded images
493
+ setupImages();
494
+ const timeoutId = setTimeout(setupImages, 500);
495
+
496
+ return () => {
497
+ clearTimeout(timeoutId);
498
+ cleanupFunctions.forEach(cleanup => cleanup());
499
+ };
500
+ }, [isLoading, userRole]);
501
+
502
+ // Apply image changes
503
+ const handleImageChange = async (image: ImageMetadata | null) => {
504
+ if (!selectedImage) return;
505
+
506
+ const { element } = selectedImage;
507
+
508
+ if (image) {
509
+ // Get the semantic ID that was stored when the image was clicked
510
+ // Check both data-image-id and data-background-image-id
511
+ const semanticId = element.getAttribute('data-image-id') ||
512
+ element.getAttribute('data-background-image-id');
513
+
514
+ if (!semanticId) {
515
+ console.error('[GlobalImageEditor] No semantic ID found for image', {
516
+ hasDataImageId: element.hasAttribute('data-image-id'),
517
+ hasDataBackgroundImageId: element.hasAttribute('data-background-image-id'),
518
+ element: element,
519
+ });
520
+ return;
521
+ }
522
+
523
+ // Extract filename from image URL or use image.id
524
+ const filename = image.url.split('/').pop()?.split('?')[0] || image.id;
525
+
526
+ // Save the mapping between semantic ID and filename, including effects
527
+ try {
528
+ const response = await fetch('/api/plugin-images/resolve', {
529
+ method: 'POST',
530
+ headers: { 'Content-Type': 'application/json' },
531
+ body: JSON.stringify({
532
+ id: semanticId,
533
+ filename,
534
+ brightness: selectedImage.brightness,
535
+ blur: selectedImage.blur,
536
+ }),
537
+ });
538
+
539
+ if (response.ok) {
540
+ console.log(`[GlobalImageEditor] Saved mapping: ${semanticId} -> ${filename} (brightness: ${selectedImage.brightness}%, blur: ${selectedImage.blur}px)`);
541
+ } else {
542
+ console.error('[GlobalImageEditor] Failed to save mapping:', await response.text());
543
+ }
544
+ } catch (error) {
545
+ console.error('Failed to save image mapping:', error);
546
+ }
547
+
548
+ // Update image src immediately - handle background images differently
549
+ if (selectedImage.isBackground) {
550
+ // For background images, update the nested Image component
551
+ const imgWrapper = element.querySelector('[data-image-id]');
552
+ if (imgWrapper) {
553
+ // Find the actual img element inside the wrapper
554
+ const img = imgWrapper.querySelector('img');
555
+ if (img) {
556
+ img.src = image.url;
557
+ img.setAttribute('data-edited-src', image.url);
558
+ }
559
+ } else {
560
+ // Fallback: update background-image style if no nested Image component
561
+ const bgImage = window.getComputedStyle(element).backgroundImage;
562
+ if (bgImage && bgImage !== 'none') {
563
+ element.style.backgroundImage = `url(${image.url})`;
564
+ }
565
+ }
566
+
567
+ // Store attributes on the background container
568
+ element.setAttribute('data-background-image-id', semanticId);
569
+ element.setAttribute('data-edited-src', image.url);
570
+ element.setAttribute('data-brightness', selectedImage.brightness.toString());
571
+ element.setAttribute('data-blur', selectedImage.blur.toString());
572
+
573
+ // Apply filter to the wrapper element
574
+ if (selectedImage.brightness !== 100 || selectedImage.blur !== 0) {
575
+ element.style.filter = `brightness(${selectedImage.brightness}%) blur(${selectedImage.blur}px)`;
576
+ } else {
577
+ element.style.filter = '';
578
+ }
579
+ } else {
580
+ // For regular images, update the img element directly
581
+ if (element.tagName === 'IMG') {
582
+ (element as HTMLImageElement).src = image.url;
583
+ element.setAttribute('data-edited-src', image.url);
584
+ element.setAttribute('data-image-id', semanticId);
585
+ element.setAttribute('data-brightness', selectedImage.brightness.toString());
586
+ element.setAttribute('data-blur', selectedImage.blur.toString());
587
+
588
+ // Apply filter
589
+ if (selectedImage.brightness !== 100 || selectedImage.blur !== 0) {
590
+ element.style.filter = `brightness(${selectedImage.brightness}%) blur(${selectedImage.blur}px)`;
591
+ } else {
592
+ element.style.filter = '';
593
+ }
594
+ } else {
595
+ // Next.js Image wrapper - find the actual img element
596
+ const img = element.querySelector('img');
597
+ if (img) {
598
+ img.src = image.url;
599
+ img.setAttribute('data-edited-src', image.url);
600
+ }
601
+ element.setAttribute('data-image-id', semanticId);
602
+ element.setAttribute('data-edited-src', image.url);
603
+ element.setAttribute('data-brightness', selectedImage.brightness.toString());
604
+ element.setAttribute('data-blur', selectedImage.blur.toString());
605
+
606
+ // Apply filter to wrapper
607
+ if (selectedImage.brightness !== 100 || selectedImage.blur !== 0) {
608
+ element.style.filter = `brightness(${selectedImage.brightness}%) blur(${selectedImage.blur}px)`;
609
+ } else {
610
+ element.style.filter = '';
611
+ }
612
+ }
613
+ }
614
+
615
+ // Dispatch a custom event to notify Image components to re-resolve
616
+ window.dispatchEvent(new CustomEvent('image-mapping-updated', {
617
+ detail: { id: semanticId, filename, brightness: selectedImage.brightness, blur: selectedImage.blur }
618
+ }));
619
+
620
+ // Close the editor
621
+ handleClose();
622
+ }
623
+ };
624
+
625
+ const handleBrightnessChange = async (brightness: number) => {
626
+ if (!selectedImage) return;
627
+
628
+ const { element } = selectedImage;
629
+ const semanticId = element.getAttribute('data-image-id') ||
630
+ element.getAttribute('data-background-image-id');
631
+ const currentFilter = element.style.filter || '';
632
+ const blurMatch = currentFilter.match(/blur\((\d+)px\)/);
633
+ const blur = blurMatch ? parseInt(blurMatch[1]) : selectedImage.blur;
634
+
635
+ // Update the selectedImage state
636
+ setSelectedImage({
637
+ ...selectedImage,
638
+ brightness,
639
+ });
640
+
641
+ element.style.filter = `brightness(${brightness}%) blur(${blur}px)`;
642
+ element.setAttribute('data-brightness', brightness.toString());
643
+
644
+ // Save the effect immediately
645
+ if (semanticId) {
646
+ try {
647
+ let filename = semanticId;
648
+ if (selectedImage.isBackground) {
649
+ const bgImage = window.getComputedStyle(element).backgroundImage;
650
+ const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
651
+ if (urlMatch && urlMatch[1]) {
652
+ filename = urlMatch[1].split('/api/uploads/')[1]?.split('?')[0] || semanticId;
653
+ }
654
+ } else {
655
+ filename = (element as HTMLImageElement).src.split('/api/uploads/')[1]?.split('?')[0] || semanticId;
656
+ }
657
+ await fetch('/api/plugin-images/resolve', {
658
+ method: 'POST',
659
+ headers: { 'Content-Type': 'application/json' },
660
+ body: JSON.stringify({
661
+ id: semanticId,
662
+ filename,
663
+ brightness,
664
+ blur,
665
+ }),
666
+ });
667
+ } catch (error) {
668
+ console.error('Failed to save brightness:', error);
669
+ }
670
+ }
671
+ };
672
+
673
+ const handleBlurChange = async (blur: number) => {
674
+ if (!selectedImage) return;
675
+
676
+ const { element } = selectedImage;
677
+ const semanticId = element.getAttribute('data-image-id') ||
678
+ element.getAttribute('data-background-image-id');
679
+ const currentFilter = element.style.filter || '';
680
+ const brightnessMatch = currentFilter.match(/brightness\((\d+)%\)/);
681
+ const brightness = brightnessMatch ? parseInt(brightnessMatch[1]) : selectedImage.brightness;
682
+
683
+ // Update the selectedImage state
684
+ setSelectedImage({
685
+ ...selectedImage,
686
+ blur,
687
+ });
688
+
689
+ element.style.filter = `brightness(${brightness}%) blur(${blur}px)`;
690
+ element.setAttribute('data-blur', blur.toString());
691
+
692
+ // Save the effect immediately
693
+ if (semanticId) {
694
+ try {
695
+ let filename = semanticId;
696
+ if (selectedImage.isBackground) {
697
+ const bgImage = window.getComputedStyle(element).backgroundImage;
698
+ const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
699
+ if (urlMatch && urlMatch[1]) {
700
+ filename = urlMatch[1].split('/api/uploads/')[1]?.split('?')[0] || semanticId;
701
+ }
702
+ } else {
703
+ filename = (element as HTMLImageElement).src.split('/api/uploads/')[1]?.split('?')[0] || semanticId;
704
+ }
705
+ await fetch('/api/plugin-images/resolve', {
706
+ method: 'POST',
707
+ headers: { 'Content-Type': 'application/json' },
708
+ body: JSON.stringify({
709
+ id: semanticId,
710
+ filename,
711
+ brightness,
712
+ blur,
713
+ }),
714
+ });
715
+ } catch (error) {
716
+ console.error('Failed to save blur:', error);
717
+ }
718
+ }
719
+ };
720
+
721
+ const handleClose = () => {
722
+ setIsOpen(false);
723
+ setSelectedImage(null);
724
+ };
725
+
726
+ // Don't render if disabled or user is not admin/dev
727
+ if (!config.enabled || isLoading || !userRole) {
728
+ return null;
729
+ }
730
+
731
+ if (!isOpen || !selectedImage) {
732
+ return null;
733
+ }
734
+
735
+ return (
736
+ <div
737
+ data-image-editor
738
+ className={config.overlayClassName || "fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"}
739
+ onClick={handleClose}
740
+ >
741
+ <div
742
+ onClick={(e) => e.stopPropagation()}
743
+ className={config.className || "w-full max-w-2xl max-h-[90vh] bg-dashboard-card rounded-2xl border border-dashboard-border shadow-2xl m-4 overflow-y-auto"}
744
+ >
745
+ {/* Header */}
746
+ <div className="p-6 border-b border-dashboard-border flex items-center justify-between sticky top-0 bg-dashboard-card z-10">
747
+ <div className="flex items-center gap-3">
748
+ <Edit2 size={20} className="text-neutral-600 dark:text-neutral-400" />
749
+ <h2 className="text-lg font-black uppercase tracking-tighter text-neutral-950 dark:text-white">
750
+ Edit Image
751
+ </h2>
752
+ </div>
753
+ <button
754
+ onClick={handleClose}
755
+ className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
756
+ >
757
+ <X size={20} className="text-neutral-500 dark:text-neutral-400" />
758
+ </button>
759
+ </div>
760
+
761
+ {/* Content */}
762
+ <div className="p-6">
763
+ <ImagePicker
764
+ value={selectedImage.originalSrc}
765
+ onChange={handleImageChange}
766
+ brightness={selectedImage.brightness}
767
+ blur={selectedImage.blur}
768
+ onBrightnessChange={handleBrightnessChange}
769
+ onBlurChange={handleBlurChange}
770
+ showEffects={true}
771
+ darkMode={false}
772
+ />
773
+ </div>
774
+ </div>
775
+ </div>
776
+ );
777
+ }
778
+