@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.
- package/package.json +47 -0
- package/src/api/fallback/route.ts +69 -0
- package/src/api/index.ts +10 -0
- package/src/api/list/index.ts +96 -0
- package/src/api/resolve/route.ts +122 -0
- package/src/api/router.ts +85 -0
- package/src/api/upload/index.ts +88 -0
- package/src/api/uploads/[filename]/route.ts +93 -0
- package/src/api-server.ts +11 -0
- package/src/assets/noimagefound.jpg +0 -0
- package/src/components/BackgroundImage.tsx +111 -0
- package/src/components/GlobalImageEditor.tsx +778 -0
- package/src/components/Image.tsx +177 -0
- package/src/components/ImagePicker.tsx +541 -0
- package/src/components/ImagesPluginInit.tsx +31 -0
- package/src/components/index.ts +7 -0
- package/src/config.ts +179 -0
- package/src/index.server.ts +11 -0
- package/src/index.tsx +56 -0
- package/src/init.tsx +58 -0
- package/src/types/index.ts +60 -0
- package/src/utils/fallback.ts +73 -0
- package/src/views/ImageManager.tsx +30 -0
|
@@ -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
|
+
|